Compare commits

..

143 Commits

Author SHA1 Message Date
ethernet
7c3b7c8470 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-10 16:23:29 -04:00
ethernet
8286070cb1 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-10 16:23:29 -04:00
ethernet
631c09384e 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-10 16:23:29 -04:00
ethernet
5b1fca042d feat(ci): load npm workspaces from package.json 2026-07-10 16:23:29 -04:00
ethernet
6e3db7f8c7 test(desktop): replace windows-child-process.test.ts regex with real tests 2026-07-10 16:23:29 -04:00
ethernet
1574b242f9 change(lint): don't ignore config files in eslint conf 2026-07-10 16:23:29 -04:00
ethernet
48a17d57d3 test(desktop): move node tests to vitest as well 2026-07-10 16:23:29 -04:00
ethernet
4bdb2811c5 tests(tui): longer timeout for wrap ansi test 2026-07-10 16:23:29 -04:00
ethernet
36eccbc951 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-10 16:23:29 -04:00
ethernet
20f833dd84 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-10 16:23:29 -04:00
ethernet
da15df24c6 test(desktop): fix a handful of broken tests 2026-07-10 16:23:29 -04:00
ethernet
445d41ff41 cleanup(desktop): note that ts imports don't need extension in one comment 2026-07-10 16:23:29 -04:00
ethernet
9ea034bf81 cleanup(desktop): remove 'use strict' in ts & mjs
it's implied by ts and mjs files already
2026-07-10 16:23:29 -04:00
ethernet
a5d2bf547f change(ci/desktop): move desktop app build into check job 2026-07-10 16:23:29 -04:00
ethernet
d3c24fb172 cleanup(desktop): lint&fmt all 2026-07-10 16:23:29 -04:00
ethernet
2c0d7351f7 test(desktop): fix session preview registry tests fails
clearing the registry sets localstorage values, so we must clear it
afterwards
2026-07-10 16:23:29 -04:00
ethernet
8bdf3ff563 test(desktop): stub CSS global in .test.tsx files 2026-07-10 16:23:29 -04:00
ethernet
7562885e93 test(desktop): fix attachment list test not querying the selector correctly 2026-07-10 16:23:29 -04:00
ethernet
8fb6432b5c test(desktop): warn when using document in tsx tests
this is almost always a mistake
2026-07-10 16:23:29 -04:00
ethernet
e3dc0af8b5 change(desktop): add vitest config for the desktop app 2026-07-10 16:23:29 -04:00
ethernet
0d6c686e03 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-10 16:23:29 -04:00
ethernet
9069587bb2 cleanup(ci): make all tsbuildinfo gitignored 2026-07-10 16:23:29 -04:00
ethernet
7f75b0a7fc test(desktop): handful of broken tests changed to match intended behavior 2026-07-10 16:23:29 -04:00
ethernet
5f41651336 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-10 16:23:29 -04:00
ethernet
d3c7d8def4 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-10 16:23:29 -04:00
ethernet
e7f23f9f80 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-10 16:23:29 -04:00
ethernet
18274b6d35 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-10 16:23:29 -04:00
ethernet
862e019d75 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-10 16:23:29 -04:00
ethernet
99069cdebb 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-10 16:23:29 -04:00
ethernet
a7c696e6ae fix(js): fix long-time broken tests
(
2026-07-10 16:23:29 -04:00
ethernet
12b63489a7 feat(ci): run JS tests in CI, add npm run check in ws root 2026-07-10 14:30:21 -04:00
ethernet
b8880f1245 fix(desktop): type-check electron/ in CI typecheck
removing tsc -b from the build script (previous commit) also removed
the only step that type-checked the electron/ directory — the CI
typecheck job runs tsc -p . --noEmit, which uses tsconfig.json whose
include is only ["src", "../shared/src"], so electron/ was silently
uncovered. extend the typecheck script to also run against
tsconfig.electron.json so electron/ stays type-checked in CI.
2026-07-10 14:06:51 -04:00
ethernet
db8772a062 fix(desktop): don't emit js files when we build desktop 2026-07-10 14:06:51 -04:00
Teknium
b9b463f3bd feat(security): expose deterministic tool output risk (#61793)
* feat(security): expose deterministic tool output risk

* fix(security): emit output-risk events only for findings
2026-07-10 07:58:12 -07:00
Teknium
35d777df07 chore: map WilsonKinyua release attribution 2026-07-10 07:47:10 -07:00
Wilson Kinyua
1a2f3aea9a fix tui finalize persist drop conversation_history so disconnect saves chat
finalize passed conversation_history=history aliasing the snapshot so flush
skipped every message and wrote nothing. now flush _session_messages via
marker dedup like gateway shutdown. add real db e2e tests.
2026-07-10 07:47:10 -07:00
giggling-ginger
cd7a8dfde0 fix(agent): release pool FDs on owning-thread client close (#61979)
force_close_tcp_sockets stayed shutdown-only after #29507 to avoid
cross-thread FD recycle. That left CLOSED sockets unreclaimed when
httpx.close() skipped already-shutdown sockets under long-lived
gateways (~1 CLOSED fd / 6 min via proxy).

Add release_fds= for the owning-thread dispose path only; abort still
defaults to shutdown-only.
2026-07-10 07:28:39 -07:00
PRATHAMESH75
9b72995a1d fix(cron): never stale-remove a one-shot whose run is still alive
get_due_jobs()'s one-shot stale-entry recovery (#38758) treated an
expired run_claim (#59229) as proof the claiming tick died, but a run
stalled on network I/O — or a laptop asleep mid-run — legitimately
outlives the TTL while very much alive. The recovery then deleted the
job record mid-flight: list showed the job gone, and when the run
finished mark_job_run() found nothing to update, so last_run_at /
last_status / last_delivery_error were never recorded.

Two guards, per the liveness signals available:

- Same process (the common single-gateway case): before removing a
  dispatch-limit-reached one-shot, consult the scheduler's running set
  via a lazy import; if the job is still running here it is slow, not
  stale — keep the entry.
- Cross process: run_job's monitor loop now refreshes run_claim.at
  every 60s while the run is alive (including under
  HERMES_CRON_TIMEOUT=0, which previously blocked without polling), so
  an expired claim really does mean the owner died and the TTL stays a
  dead-owner detector.

Fixes #62002
2026-07-10 07:28:33 -07:00
kshitijk4poor
90bd5b0f9b test(telegram): mirror PTB errors in heartbeat recovery 2026-07-10 19:28:03 +05:30
liuhao1024
97fb9e1f62 fix(telegram): classify PTB heartbeat transport errors 2026-07-10 19:28:03 +05:30
kshitijk4poor
54e1864577 fix(acp): unwrap web extract object titles 2026-07-10 19:14:06 +05:30
kshitijk4poor
0b753d8918 fix(display): harden fallback label formatting 2026-07-10 19:14:06 +05:30
kshitijk4poor
c2a40b2dc9 fix(web): handle short extract provider results 2026-07-10 19:14:06 +05:30
kshitijk4poor
459cf3402b fix(web): preserve extract result input order 2026-07-10 19:14:06 +05:30
kshitijk4poor
e640bb5e18 test(web): cover model-facing dict URL dispatch 2026-07-10 19:14:06 +05:30
kshitijk4poor
de33c2413b fix(web): harden extract input and display boundaries 2026-07-10 19:14:06 +05:30
liuhao1024
7ae9faecf7 fix(tools): handle dict URLs in web_extract display and tool processing
When web_search results are passed directly to web_extract, the URLs
field contains dict objects (e.g., {"url": "...", "title": "..."})
rather than plain URL strings. Two code paths assumed URLs were always
strings and crashed:

- agent/display.py get_cute_tool_message for web_extract: tried to call
  url.replace() on a dict, causing AttributeError
- tools/web_tools.py web_extract_tool loop: tried regex search on a dict,
  causing TypeError

Both now extract the URL string from dict objects (url or href field) or
fall back to empty string, preserving the cosmetic display and allowing
the tool to process the URLs correctly.

Fixes #61693
2026-07-10 19:14:06 +05:30
kshitijk4poor
8727e67295 fix(runtime): preserve resolved fork metadata 2026-07-10 18:50:28 +05:30
kshitijk4poor
97e9c64664 fix(runtime): preserve resolved fork metadata 2026-07-10 18:32:32 +05:30
infinitycrew39
f39c88befb test(curator): assert review fork forwards pool and overrides
Regression test that _run_llm_review passes credential_pool and request_overrides from resolve_runtime_provider into the curator AIAgent fork.
2026-07-10 18:32:32 +05:30
infinitycrew39
304cdbdc77 fix(curator): forward credential pool from runtime resolution
Curator review forks now pass credential_pool and request_overrides from resolve_runtime_provider into AIAgent so pool-backed custom providers can rotate credentials on 401 like main chat.
2026-07-10 18:32:32 +05:30
kshitijk4poor
d37090ac36 test(tui): cover profile-local MCP discovery 2026-07-10 18:09:17 +05:30
HexLab98
623165a640 fix(tui): discover MCP tools in slash workers 2026-07-10 18:09:17 +05:30
gigakun3030
dfdc3156fb fix(models): remove unavailable OpenCode Zen free models (#61163) 2026-07-10 05:25:50 -07:00
kshitijk4poor
8fa0d8bbbb test(auth): pin runtime routing persistence on failure 2026-07-10 17:50:43 +05:30
kshitijk4poor
0e67c7231d fix(auth): validate and persist shared Nous routing 2026-07-10 17:50:43 +05:30
kshitijk4poor
03b8a00e26 fix(auth): recompute Nous routing after shared recovery 2026-07-10 17:50:43 +05:30
Sami Rusani
ca6513542d fix(auth): recover runtime Nous token from shared store 2026-07-10 17:50:43 +05:30
brooklyn!
caf557be5b Merge pull request #61973 from NousResearch/bb/desktop-tip-stuck
fix(desktop): stop Tip from sticking open and blocking clicks
2026-07-10 04:03:15 -05:00
Brooklyn Nicholson
29f3dc0809 fix(desktop): stop Tip from sticking open and blocking clicks
Radix's hoverable-content grace area can leave tips stuck over Electron drag regions; disable it and make tip content pointer-events-none so open state tracks the trigger only.
2026-07-10 04:00:32 -05:00
brooklyn!
a9f3f08700 Merge pull request #61916 from NousResearch/bb/desktop-gateway-switch-ux
feat(desktop): soft gateway switch + gateway-settings polish
2026-07-10 03:16:09 -05:00
brooklyn!
79a104d037 Merge pull request #61912 from NousResearch/bb/salvage-55402-desktop-cloud-mode
feat(desktop): Hermes Cloud connection mode (salvage of #55402)
2026-07-10 03:15:59 -05:00
Brooklyn Nicholson
b3bde1fbee feat(desktop): soft gateway switch + gateway-settings polish
Switching connection mode (local / cloud agent / remote) no longer
full-window-reloads into the cold-boot CONNECTING screen. The primary
backend is torn down in place (no renderer reload); the shell + Settings
stay up while session lists are wiped so sidebar skeletons retrigger, then
the socket re-dials and config/sessions refresh. Cold-boot CONNECTING
latches off after the first successful boot; the intentional teardown
suppresses the backend-exit toast. Dev affordance: a "Preview soft switch"
button under Gateway diagnostics (Electron has no ?query= entry).

Gateway settings UI brought in line with the rest of Settings:
- Mode cards use the shared selectableCardClass on an equal-height
  auto-rows-fr grid, stacking 1→3 (never an orphaned 2+1); titles wrap
  instead of truncating.
- Remote gateway's auth detail moves into a ? tooltip in the title; drop
  the redundant "connects to the one you choose" from the cloud card.
- textStrong buttons force px-0 so the underline sits flush with the label.
- Tooltip chip uses box-decoration-break: clone so the background hugs each
  wrapped line (bg only on the text), capped at max-w-64.

Fully i18n'd (en + zh; ja/zh-hant inherit via defineLocale).
2026-07-10 02:57:02 -05:00
kshitijk4poor
a0032f5f92 fix(routing): preserve profile and delegation parity 2026-07-10 13:10:45 +05:30
Gille
3aeaf3755d fix(nous): forward provider routing through Portal 2026-07-10 13:10:45 +05:30
kshitijk4poor
69f1460c3d test(model): isolate custom provider discovery 2026-07-10 13:10:30 +05:30
kshitijk4poor
7628f2770a test(model): assert explicit catalogs never probe 2026-07-10 13:10:30 +05:30
kshitijk4poor
0629caac62 fix(model): derive catalog policy from declarations 2026-07-10 13:10:30 +05:30
luyifan
5f00f36ba9 fix(model): probe no-key custom provider catalogs 2026-07-10 13:10:30 +05:30
brooklyn!
46cf87be16 Merge pull request #61935 from NousResearch/bb/salvage-59902-bootstrap-repin
fix(desktop): prevent bootstrap stale commit repin on existing checkouts
2026-07-10 02:38:44 -05:00
Brooklyn Nicholson
6207d68948 fix(desktop): prevent bootstrap stale commit repin on existing checkouts
Old packaged Desktop apps re-entering bootstrap against an existing
~/.hermes/hermes-agent were still passing the baked-in --commit pin, which
detached the managed checkout back to the app stamp (e.g. 0.15.1) after
hermes update had already moved it forward.

Skip the packaged commit pin when activeRoot already has git metadata;
keep branch args and fresh-install commit pinning unchanged. Port of
#59902 onto the post-ts-ify bootstrap-runner.ts.

Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com>
2026-07-10 02:29:58 -05:00
brooklyn!
678be9f1d8 Merge pull request #61929 from NousResearch/bb/salvage-57912-dash-paste
fix(web): paste/drop images into dashboard Chat via HERMES_HOME/images
2026-07-10 02:25:49 -05:00
Brooklyn Nicholson
301acc9eaa fix(web): paste/drop images into dashboard Chat via HERMES_HOME/images
Dashboard Chat is an xterm mirror of a TUI inside the gateway, so
server-side clipboard.paste never sees the browser clipboard. Upload
pasted/dropped images to the profile's images/ dir (same place
clipboard.paste / image.attach use), then drive /image over the PTY.

Uses a dedicated /api/chat/image-upload endpoint (magic-byte check,
25MB cap, profile scope) instead of relative managed-files uploads that
400 on local dashboards without a locked root. Ctrl/Cmd+Shift+V also
tries clipboard.read() for images before falling back to text, since
preventDefault on that chord suppresses the DOM paste event.

Salvages #57912 (client composition + /image PTY drive) and folds in
#48563's upload endpoint + drop path.

Co-authored-by: bird <6666242+bird@users.noreply.github.com>
Co-authored-by: tt-a1i <53142663+tt-a1i@users.noreply.github.com>
2026-07-10 02:20:04 -05:00
brooklyn!
1318cd9b0d Merge pull request #61925 from NousResearch/bb/salvage-61245-ui-zoom
fix(desktop): re-apply UI zoom on show/restore, scoped to chat windows (supersedes #61245)
2026-07-10 02:14:56 -05:00
Brooklyn Nicholson
57dfebe3db fix(desktop): re-apply UI zoom on show/restore, scoped to chat windows
Windows drops webContents zoom on minimize/restore, so the UI snapped back
to 100% while Settings still read 125%. Zoom was only reasserted on the main
window's did-finish-load, never on show/restore and never for session windows.

Reassert the persisted level on show/restore + first load, wired once in
wireCommonWindowHandlers so the main window and secondary session windows
share it. The pet overlay opts out (zoom:false): it sizes its own OS window
to fit the sprite in unzoomed CSS px and has its own Alt+wheel scale, so
inheriting the global zoom would render the mascot larger than its window and
crop it (and it shares the renderer origin's zoom localStorage key).

Salvages #61245; keeps its pure-helper tests and adds a scope assertion.

Co-authored-by: HexLab98 <liruixinch@outlook.com>
2026-07-10 02:13:10 -05:00
kshitijk4poor
ed36edde41 test(gateway): recognize awaited reset result 2026-07-10 12:38:48 +05:30
kshitijk4poor
b196ce80c8 fix(gateway): unify routing save and reset races 2026-07-10 12:38:48 +05:30
kshitijk4poor
b3f77f5c82 fix(gateway): close SessionStore concurrency gaps 2026-07-10 12:38:48 +05:30
kshitijk4poor
9d38a2309e fix(gateway): enforce one async SessionStore boundary 2026-07-10 12:38:48 +05:30
kenyonxu
08e9dcf182 fix(gateway): move all I/O out of session_store._lock in get_or_create_session
The second lock block in get_or_create_session held self._lock during six
blocking operations on every inbound message: _is_session_ended_in_db
(SQLite SELECT), _should_reset (callback), _save (SQLite write + JSON write
+ os.fsync), and _recover_session_from_db (SQLite SELECT + UPDATE).

A code comment at line 1607 claimed 'SQLite calls are made outside the
lock' -- true only for _compression_tip_for_session_id, which was moved
out in a prior fix. The remaining I/O was never addressed.

Restructure into a four-phase lock/no-lock split that mirrors the pattern
already established at the bottom of the function:

  Phase 1  (lock)    -- read entry + session_id
  Phase 1b (no lock) -- stale check + reset policy
  Phase 2  (lock)    -- apply decisions to _entries, capture snapshot + flags
  Phase 3  (no lock) -- recovery DB query, _save from snapshot, end/create

_save_entries(snapshot) replaces _save() to avoid dict-mutation races when
called outside the lock. _query_recoverable_session splits the DB I/O out
of _recover_session_from_db so only the _entries assignment needs the lock.

Three early returns inside the lock block are eliminated in favour of a
unified save + return path.
2026-07-10 12:38:48 +05:30
kenyonxu
94c2a4016b fix(gateway): offload both blocking sources in compression-in-flight check (#5)
The sync _session_has_compression_in_flight sat on the message hot path
and blocked the event loop twice: under session_store._lock during
_ensure_loaded_locked (JSON read) and via db.get_compression_lock_holder
(SQLite SELECT). Async-ify the method and offload both sources via
asyncio.to_thread; await the call site in _handle_active_session_busy_message.
2026-07-10 12:38:48 +05:30
kenyonxu
24ea21993f fix(gateway): offload session store calls off the event loop via asyncio.to_thread
Every inbound message calls get_or_create_session which synchronously
executes _is_session_ended_in_db → db.get_session → conn.execute on
the asyncio event loop. On a ~1.4GB state.db, this blocks the loop
for seconds to minutes, starving Discord heartbeats.

Upstream #55159 fixed the same pattern for self._session_db in
gateway/run.py but missed SessionStore._db in gateway/session.py.

This follows the exact same approach as #55159:
- session.py internals stay fully synchronous (zero changes)
- Threading.Lock contract is preserved
- All hot-path callers in run.py and slash_commands.py wrap calls
  with await asyncio.to_thread(self.session_store.method, ...)

Affected: get_or_create_session, switch_session, update_session,
load_transcript, rewrite_transcript, rewind_session, reset_session,
set_model_override, _save — ~24 call sites across 2 files.

# Conflicts:
#	gateway/slash_commands.py
2026-07-10 12:38:48 +05:30
brooklyn!
04ca34b5f3 Merge pull request #61915 from NousResearch/bb/salvage-50488-msys-paths
fix(tools): resolve MSYS paths in file tools on Windows (supersedes #50488)
2026-07-10 02:03:42 -05:00
Brooklyn Nicholson
24f6ed53fc simplify(desktop): inline the cloud setup link like the rest of the app
Match the sibling pattern (pet-generate/generate-unavailable.tsx): inline the
portal URL literal in the ExternalLink href instead of a one-off named const.
2026-07-10 02:01:40 -05:00
Brooklyn Nicholson
1c7f31a577 simplify(desktop): hardcode the Hermes Cloud setup link
Drop the portalBaseUrl→IPC→useState plumbing I added for the "create an agent"
link. HERMES_PORTAL_BASE_URL is a dev/staging-only override; threading it
through cloud.status() into React state just to build one link isn't worth it —
in prod it's always portal.nousresearch.com. Module-level constant instead.
2026-07-10 01:59:47 -05:00
Brooklyn Nicholson
703487d7a6 feat(desktop): point the no-agents link at the Hermes Cloud instance-setup page
Per review: the empty-state "create an agent" link went to the generic portal
agents list; point it at the Hermes Cloud create-instance flow
({portal}/cloud?setup=instance) instead. Derive the host from the portalBaseUrl
that cloud.status() already echoes so it honors HERMES_PORTAL_BASE_URL rather
than hardcoding a second copy of the portal host. Link text/copy → "Hermes
Cloud" (en + zh).
2026-07-10 01:55:44 -05:00
Brooklyn Nicholson
3f8b220049 fix(tools): resolve MSYS paths in file tools on Windows
Git Bash hands file tools paths like /c/Users/... which Path() on native
Windows treats as relative \\c\\Users\\... under the process cwd. Reuse
local._msys_to_windows_path (extended for /cygdrive and /mnt drive forms)
in _resolve_path_for_task / _resolve_base_dir so read/write/search land on
the real drive. Container/WSL Linux paths are left untouched.

Salvages #50488 (drops unrelated desktop artifact commit); tests adapted
from #46995.

Co-authored-by: Jeff Watts <186512915+lEWFkRAD@users.noreply.github.com>
Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
2026-07-10 01:54:51 -05:00
brooklyn!
9cb2a8abb0 Merge pull request #60757 from giggling-ginger/bugfix/issue-hunt-20260708
fix(desktop): keep configured MoA presets in model picker
2026-07-10 01:49:00 -05:00
Brooklyn Nicholson
0ff097439e refactor(desktop): DRY the cloud helpers
Tighten the salvaged Hermes Cloud code with no behavior change:
- main: one `trimCloudOrg` projection reused by the success-echo and the 409
  org list (drop the duplicated map), and a `cloudLoginError()` factory for the
  three needsCloudLogin throw sites.
- renderer: a `cloudLoginLapsed()` predicate for the duplicated
  needsCloudLogin→signed-out check.
2026-07-10 01:47:46 -05:00
Brooklyn Nicholson
2d315d30f8 polish(desktop): normalize cloud-URL highlight match + correct signedIn doc
Cleanups on top of @ben's Hermes Cloud salvage:
- isConnectedAgent normalized both sides of the cloud-URL comparison (trim +
  drop trailing slash + lowercase). The saved URL is host-lowercased by
  normalizeRemoteBaseUrl but the discovered dashboardUrl is raw from NAS, so
  a host-casing difference could silently break the connected-highlight.
- DesktopCloudStatus.signedIn doc said "AT-or-RT"; it actually reflects the
  Nous portal Privy session (privy-token), not the gateway cookies.
2026-07-10 01:37:54 -05:00
Ben
c101207b99 feat(desktop): Hermes Cloud connection mode — one sign-in, agent discovery, silent connect
Adds a third "Hermes Cloud" gateway mode to the desktop app: one portal
sign-in auto-discovers the agents on your account and connects to any of
them with no second interactive prompt.

- Electron: widen connection mode to 'local' | 'remote' | 'cloud', routed
  through a centralized modeIsRemoteLike() so every resolution site treats
  cloud exactly like remote; portal discovery (GET /api/agents over the
  OAuth partition), Privy-cookie liveness, multi-org picker (NAS 409), and a
  silent per-agent /oauth cascade (load protected root, not /login).
- Persist a cloudOrg on the cloud block; unselect cloud on mode switch.
- Renderer: Hermes Cloud ModeCard + agent picker (signed-out/loading/empty/
  list), org picker, Change-org, connected-highlight + Connected pill.
- i18n (en + zh full; ja/zh-hant inherit via defineLocale), Cloud icon.
- IPC: hermes☁️{status,login,logout,discover,agent-sign-in}.

Salvage of #55402 onto current main: the original branch predates the
desktop electron .cjs -> .ts migration (39d09453f), so the electron half
was re-authored against the .ts files. Authorship preserved.

cloud-auto-discovery Phases 3 + 4.
2026-07-10 01:37:43 -05:00
kshitijk4poor
6abf195682 fix(agent): keep pending verification behind exit provenance
Only restore held verification text when the loop genuinely ends through budget exhaustion. Preserve later interrupts and failures, keep generated-summary fragment explanations, and add regression coverage for both contracts.
2026-07-10 11:53:58 +05:30
kshitijk4poor
8fc80bc2aa test(agent): pin verification fallback edge cases
Cover empty pending output falling back to summarization and a later verified response superseding the held premature report.
2026-07-10 11:53:58 +05:30
kshitijk4poor
f46e7647eb fix(agent): clear stale intermediate acknowledgments
Treat intent-ack continuation text as non-final so last-turn exhaustion requests a real summary instead of surfacing a premature promise. Keep iteration-limit fallback text free of the abnormal-fragment explainer.
2026-07-10 11:53:58 +05:30
kshitijk4poor
1453431881 refactor(agent): scope pending fallback to verification
Name the continuation fallback for its actual verification-only provenance so unrelated continuation paths cannot accidentally inherit its cron-delivery semantics.
2026-07-10 11:53:58 +05:30
kshitijk4poor
cd7c203ab9 fix(agent): preserve gated responses without masking failures
Track held-back verification responses explicitly so budget exhaustion returns the composed report without a second model call. Keep unrelated error and recovery exit reasons intact, preserve Kanban timeout accounting, and cover the real run_conversation paths.
2026-07-10 11:53:58 +05:30
HexLab98
53231fb00b test(cron): cover verify-on-stop iteration-limit exit normalization
Add turn_finalizer regression tests for unknown/budget_exhausted exits
that must normalize to max_iterations_reached for cron delivery.
2026-07-10 11:53:58 +05:30
HexLab98
3eb937a498 fix(cron): preserve composed reports when verify-on-stop exhausts budget
Clear stale final_response before verify-on-stop/pre_verify loop
continues, and normalize iteration-limit exit reasons in turn_finalizer
when a composed answer survives with unknown/budget_exhausted.

Fixes #61631
2026-07-10 11:53:58 +05:30
kshitijk4poor
f82c71396d fix(cron): scope profile runtime during webhook fire 2026-07-10 11:43:09 +05:30
embwl0x
ec0227b435 fix(cron): isolate profile store paths by context 2026-07-10 11:43:09 +05:30
Teknium
f8361d29c8 fix(tools): enforce registry result contract (#61787) 2026-07-09 21:32:01 -07:00
teknium1
a0972b9748 fix: widen None-deref guards to config-derived sibling sites + tests
Sibling sites of the salvaged #55997 fix, all reading user-editable
config values through .get(key, '').method(): MoA slot provider/model
labels, gateway quick-command alias targets (2 sites), gateway.proxy_url,
and gateway.relay_url. Regression tests for the contributor's two sites
plus the MoA labels.
2026-07-09 21:10:07 -07:00
AlexFucuson9
838aa742cb fix(agent): guard .get(key, "").method() None dereference in adapters
dict.get(key, default) returns None (not the default) when the key
EXISTS with value None. The default only applies when the key is ABSENT.
Chained method calls (.strip(), .upper(), .count()) crash with
AttributeError on NoneType.

Fix two confirmed hits:
- auxiliary_client.py: custom provider base_url/api_key (config null)
- anthropic_adapter.py: text block content (API null response)

Pattern: .get(key, "").method() → (.get(key) or "").method()
2026-07-09 21:10:07 -07:00
Teknium
5e50f18b30 fix(agent): reject malformed tool call arguments (#61784)
* fix(agent): reject malformed tool call arguments

* test(agent): expect malformed tool arguments to fail closed
2026-07-09 20:52:44 -07:00
teknium1
0b0f60bf22 docs: add Feishu group events infographic 2026-07-09 20:31:49 -07:00
teknium1
651e632b6d fix(feishu): ship Channel signaling SDK support 2026-07-09 20:31:49 -07:00
luxuguang-leo
949e4cb72a fix(feishu): add extra_ua_tags=["channel"] to FeishuWSClient for group @mention delivery
Without this UA tag the Feishu server does not push group @mention events
over the WebSocket transport. The "channel" tag tells the server to use
the Channel protocol which enables group-message routing in addition to P2P
direct messages.

Root cause: FeishuWSClient was created without any UA signaling tag, so the
server defaulted to the basic DM-only push mode. Group @mention events were
silently dropped before reaching Hermes.

Fixes https://github.com/NousResearch/hermes-agent/issues/50656

Also adds a regression test verifying the UA tag is present in the
FeishuWSClient constructor call.
2026-07-09 20:31:49 -07:00
teknium1
07271a6f62 fix(tools_config): widen null guard to known_plugin_toolsets write path
Sibling of the salvaged #53196 read-path fix: setdefault() does not
replace a present-but-null key, so saving platform tools with
known_plugin_toolsets: null in config.yaml crashed on indexing None.
2026-07-09 20:28:44 -07:00
teknium1
c9d5491205 chore: AUTHOR_MAP entries for HumphreySun98 + 17324393074 (PR #61142/#53196 salvage) 2026-07-09 20:28:44 -07:00
sonxi
df886d0a45 fix(tools_config): guard against None in known_plugin_toolsets config
When config.yaml has known_plugin_toolsets set to null (or any value
mapped to None by the YAML loader), config.get returns None (dict.get
only falls back to the default when the key is absent, not when its
value is None). The subsequent set(known_map.get(platform, [])) then
crashes with TypeError: NoneType object is not iterable and the gateway
fails to start, even though no plugin toolsets are configured.

Add or-empty-dict and or-empty-list guards so a null/None value is
treated as empty instead of crashing the platform-tools resolver.
2026-07-09 20:28:44 -07:00
HumphreySun98
5693265775 fix(web): don't crash on a null web/backend config value
`_load_web_config()` is typed `-> dict` but returned `load_config().get("web",
{})`, which is `None` when the config has a present-but-null `web:` section
(YAML `web:` with no body). Every caller then does
`_load_web_config().get(...)` and raises `AttributeError: 'NoneType' object
has no attribute 'get'` — this hits `_get_backend`, `check_web_api_key`, and
the extract-char-limit reader.

Separately, `check_web_api_key()` read the backend as
`.get("backend", "").lower()`; a null `web.backend` value yields `None` (the
`""` default only applies when the key is absent), so `None.lower()` raised.
`check_web_api_key` is the `check_fn` gate for `web_search`/`web_extract`, so
this surfaced as an exception during tool-availability checking.

- Make `_load_web_config()` honor its `-> dict` contract (`... or {}`), fixing
  the null-`web:`-section crash at every call site.
- Guard the backend value in `check_web_api_key` with `or ""`, mirroring the
  existing guard in `_get_backend`.

Adds regression tests for both the null-backend-value and null-web-section
cases.
2026-07-09 20:28:44 -07:00
Teknium
1a47769715 test: deflake CI and dev-machine flaky tests in bulk (11 tests, 10 files) (#61816)
* test: deflake CI and dev-machine flaky tests in bulk

Fixes ten distinct flake sources found by mining recent CI failures and
running the full suite on a dev machine with real user state:

CI-observed races:
- tests/conftest.py live-system guard: allow signal 0 (pure liveness
  probe) through _guarded_kill/_guarded_killpg. psutil.pid_exists()
  probes a just-killed grandchild reparented to init; the subtree check
  fails for it and the guard RuntimeError'd
  test_entire_tree_is_sigkilled_not_just_parent intermittently on
  unrelated PRs.

Hermeticity flakes (fail on dev machines with real state, pass on CI):
- agent/coding_context.py: _marker_root() now skips the shared temp
  root (tempfile.gettempdir()) like it skips $HOME — a stray
  /tmp/package.json flipped every tmp_path test into the coding
  posture (9 failures in test_coding_context.py).
- test_agent_guardrails.py: pin MAX_CONCURRENT_CHILDREN=3 via autouse
  monkeypatch instead of freezing the user's real config value at
  import time (import-time vs call-time config mismatch).
- test_web_tools_config.py: TestCheckWebApiKey now neutralizes the
  ddgs package probe and registry providers — the optional ddgs
  package in a dev venv lit up the fallback backend.
- test_credential_pool.py: block claude_code/hermes-oauth credential
  autodiscovery in the two pool-merge tests that assert exact id
  lists (a real ~/.claude/.credentials.json seeded an extra entry).
- test_modal_sandbox_fixes.py: clear _permanent_approved /
  _session_approved — the user's real command_allowlist silently
  approved the guard-escalation commands under test.
- test_setup_irc.py: stub prompt_checklist to select only the IRC row;
  the non-TTY cancel fallback re-ran the real configured platforms'
  interactive setup_fn, which hit input() under captured stdin.
- test_doctor.py: TestGitHubTokenCheck now patches the module-level
  HERMES_HOME constant (the file's established pattern) instead of
  only setenv — doctor was running PRAGMA integrity_check against the
  real multi-GB state.db and blowing the 300s per-file budget.

Latent atexit-duplication (same _enter_buffered_busy class as #34217):
- test_undo_command.py: drop importlib.reload(tui_gateway.server) in
  fixture teardown; reload re-registers the module's atexit hooks.
- test_session_platform_resolution.py: drop per-test reload of
  tui_gateway.server; every resolver reads env at call time.

* test: sentinel model value in ignore-user-config fallback assertion

With HERMES_IGNORE_USER_CONFIG=1, load_cli_config() falls back to the
repo-root cli-config.yaml (untracked, gitignored). On a dev machine that
file can legitimately set the same popular model the test hardcoded
(anthropic/claude-sonnet-4.6), flipping the != assertion locally while
CI (no cli-config.yaml) stayed green. Use an impossible sentinel model
name instead.
2026-07-09 20:03:11 -07:00
teknium1
3a394210ff fix(stt/tts): widen null-subsection guards to all provider config reads
Sibling sites of the salvaged #47334 fix: xai/openai/elevenlabs/gemini/
mistral/piper/neutts subsection reads in transcription_tools.py and
tts_tool.py used .get(key, {}) which passes a present-but-null value
through as None. All provider-subsection reads now use .get(key) or {}.

Providers without a DEFAULT_CONFIG entry (e.g. stt.xai) were still
receiving None even after the load_config() deep-merge fix, since the
merge can only fill sections that have defaults.
2026-07-09 19:58:05 -07:00
x7peeps
89371216b2 fix(stt/tts): guard against null config subsections crashing with AttributeError
When stt.local, tts.edge, or other config subsections are explicitly set
to null in config.yaml (which happens by default on a fresh --voice
setup), stt_config.get('local', {}) returns None instead of {} because
YAML null preserves the key.  The chained .get('model') then crashes
with 'NoneType' object has no attribute 'get'.

Apply the defensive (x or {}) pattern to every place a config subsection
is read via .get('xxx', {}).  Covers local, edge, openai, mistral, and
elevenlabs subsections in both transcription_tools.py and tts_tool.py.

Closes #47318
2026-07-09 19:58:05 -07:00
Teknium
4c03032a24 fix(cli): normalize malformed skills config in get_disabled_skills (#61797)
skills: null crashed with AttributeError, and a bare scalar
disabled: my-skill was split into a set of characters. Both now
normalize the same way agent.skill_utils._normalize_string_set does:
null -> empty set, scalar -> single-item set. Non-dict skills
sections are ignored.

Closes #13026.
2026-07-09 19:57:54 -07:00
teknium1
881a9520e3 test: regression coverage for null context_lengths key (#47135) 2026-07-09 19:57:43 -07:00
ygd58
b2e227d249 fix(agent): handle YAML null value in context_length_cache
_load_context_cache() returned None when context_length_cache.yaml
contained 'context_lengths:' (no value) — YAML parses this as
{'context_lengths': None} and dict.get(key, default) only returns
the default when the key is absent, not when the value is None.

This caused AttributeError in every downstream caller (issue #47135).

Fix: use 'or {}' instead of default= so both absent key and
None value return an empty dict.

Fixes #47135
2026-07-09 19:57:43 -07:00
teknium1
c49d51bf72 chore: AUTHOR_MAP entry for kohoj (PR #61667 salvage) 2026-07-09 19:54:02 -07:00
Koho Zheng
a4dd08a977 fix(session-export): escape html tool call names 2026-07-09 19:54:02 -07:00
briandevans
b5c655c89e fix(cli): normalize role into a single CSS token for the message class
Addresses Copilot review on #61348: the HTML-escaped role, while safe from
injection (quotes are escaped), still contains whitespace when a crafted role
is supplied, which splits the class attribute into several unintended CSS
classes. Keep the escaped role for the display badge, and reduce the raw role
to a single safe CSS token (alnum/-/_) for the class name. Real roles
(user/assistant/system/tool) are unchanged, so the existing .message-<role>
rules still match.
2026-07-09 19:54:02 -07:00
teknium1
540f90190f chore: map reconnect contract contributor 2026-07-09 19:09:38 -07:00
teknium1
16b841b7f4 docs: add gateway reconnect contract infographic 2026-07-09 19:09:38 -07:00
teknium1
9c40fc2f2c chore: map QQBot fix contributor 2026-07-09 19:09:38 -07:00
lemonwan
0f8603c571 test(gateway): regression: every adapter.connect() must accept is_reconnect
The gateway reconnect watcher forwards is_reconnect=True to every
adapter.connect() call on every retry. Adapters whose signature omits
the kwarg raise TypeError at every reconnect attempt and stay silently
disconnected — the exact bug that shipped for QQAdapter and only
surfaced after messages stopped flowing on the QQ channel for hours.

This test statically parses every adapter.py under gateway/platforms/
and plugins/platforms/ (via AST, so third-party SDKs like slack_sdk,
matrix-nio, aiohttp, telegram, etc. are NOT required in the test env)
and asserts every *Adapter class with an async connect() accepts
is_reconnect — either as a keyword-only argument or absorbed by
**kwargs.

Also fixes plugins/platforms/wecom/callback_adapter.py:WecomCallbackAdapter,
which the new test caught as a second offender. Same class of bug: bare
'async def connect(self)' signature would die on the first reconnect.

Companion to #59429 (which fixed the original QQAdapter offender).
2026-07-09 19:09:38 -07:00
luxuguang-leo
276542c729 fix(qqbot): add is_reconnect param to QQAdapter.connect for gateway reconnect compat
The base adapter's  signature was updated to include
, which the reconnect watcher passes as
 during reconnection. All other platform adapters were
updated, but QQAdapter was missed, causing:

    TypeError: QQAdapter.connect() got an unexpected keyword argument 'is_reconnect'

This leads to an infinite retry loop since every reconnect attempt fails
immediately with the same TypeError.

Fix: add  to QQAdapter.connect()'s signature.
QQBot has no server-side update queue, so the flag is accepted only for
interface conformance.

Test: new test_connect_accepts_is_reconnect_param verifies both
adapter.connect() and adapter.connect(is_reconnect=True) succeed without
raising.
2026-07-09 19:09:38 -07:00
sharziki
a7f65e3bcd fix(gateway): tolerate scalar gateway config block
The streaming fallback path read yaml_cfg.get("gateway", {}).get("streaming") when top-level streaming was absent or malformed. If a user accidentally set gateway to a scalar value, config loading crashed with AttributeError instead of ignoring the malformed block and using defaults.

Read the gateway block once, verify it is a mapping before accessing nested streaming, and keep the existing gateway.platforms fallback using the same checked value.

Adds a regression test for config.yaml containing gateway: disabled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 18:23:27 -07:00
sharziki
50c66b2f8e fix(gateway): ignore malformed config sections
GatewayConfig.from_dict(), PlatformConfig.from_dict(), SessionResetPolicy.from_dict(), and StreamingConfig.from_dict() assumed their input sections were mappings. A malformed scalar from legacy gateway.json or an internal caller could crash config loading with AttributeError before env overrides/defaults had a chance to recover.

Coerce non-mapping sections to empty dicts, skip malformed platform entries, and keep valid sibling platform configs loading normally.

Tests cover scalar platform blocks, scalar nested reset/streaming sections, and malformed PlatformConfig home_channel/extra values.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 18:23:27 -07:00
teknium1
46613071e4 fix(config): widen empty-section guard to _deep_merge in load_config
Sibling site of the load_cli_config fix (#58277): _deep_merge treated a
YAML-null section (terminal: with no value) as an override, replacing
the entire DEFAULT_CONFIG dict for that section with None. Every
downstream consumer expecting a mapping was a latent crash, and default
sub-keys were silently lost. A None override of a dict default is now
ignored, matching the CLI loader's behavior. Scalar-null overrides are
unchanged.
2026-07-09 18:23:18 -07:00
yungchentang
bdecf0ab94 fix(cli): ignore empty config sections 2026-07-09 18:23:18 -07:00
Teknium
bd16395255 chore: add AlexFucuson9 to AUTHOR_MAP (PR #61347 salvage) 2026-07-09 18:23:10 -07:00
Teknium
c75789f245 test: regression coverage for header reapplication on model switch
Three tests for the #61099 salvage: OpenRouter attribution headers
present after switching to openrouter.ai, Kimi User-Agent sentinel
present after switching to api.kimi.com, and stale headers cleared
when switching to a provider with no URL-specific headers.
2/3 fail on unpatched main (DID NOT ATTACH), confirming the bug.
2026-07-09 18:23:10 -07:00
AlexFucuson9
0a4b4d6df5 fix(agent): reapply provider headers after model switch
switch_model() rebuilds _client_kwargs from scratch (api_key + base_url)
but does not call _apply_client_headers_for_base_url(), so provider-
specific headers like OpenRouter HTTP-Referer and X-Title are lost.
Subsequent requests show "Unknown" in OpenRouter dashboard logs.

Call _apply_client_headers_for_base_url() after rebuilding _client_kwargs
and before creating the new client.

Fixes #61099
2026-07-09 18:23:10 -07:00
teknium1
a801046669 fix(memory): resolve() the shared-connection registry key; symlink test
Follow-ups for salvaged PR #43819: the registry key was
str(Path(db_path).expanduser()) — a symlinked or relative path to the
same DB file got its own connection, silently reintroducing the exact
multi-writer contention the registry prevents. Key on Path.resolve()
(OSError-tolerant fallback). Adds a symlink regression test and the
AUTHOR_MAP entry for adambiggs.
2026-07-09 18:17:40 -07:00
Adam Biggs
b5226caff8 fix(memory): share one SQLite connection per holographic store database
Every MemoryStore instance opened its own SQLite connection guarded by
its own RLock. Several providers coexist in one process (the main agent
plus every delegate_task subagent), so instances pointing at the same
memory_store.db raced as independent WAL writers. Combined with writes
that were not rolled back on error, one connection could leave an open
write transaction that pinned the write lock and made every other
connection's writes fail with "database is locked" for the full busy
timeout.

Instances for the same database now share ONE process-wide connection
and ONE re-entrant lock, so access is fully serialized and
cross-connection contention is impossible. The shared connection is
refcounted: closing one instance never tears it out from under a live
sibling, and the last close releases it. The connection runs in
autocommit (isolation_level=None) so a write that raises mid-method can
never leave a dangling transaction holding the write lock; the existing
explicit commit() calls become harmless no-ops.

The provider's shutdown() now calls the refcount-guarded close() instead
of just dropping the reference: leaving finalization to GC kept the
connection (and its write lock) alive indefinitely on long-running
gateways, prolonging the exact contention this fix removes. The last
provider now releases the connection deterministically while siblings
stay live; regression tests fail without the wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 18:17:40 -07:00
teknium1
79f1274802 chore: AUTHOR_MAP entry for AlexFucuson9 (PR #61209 salvage)
His noreply email has no numeric-id+ prefix, so the attribution CI's
auto-resolve pattern doesn't match it.
2026-07-09 18:04:00 -07:00
teknium1
9cbac6418b test(gateway): pin in-place compaction skipping the destructive rewrite
Flip the two tests that pinned the old buggy behavior (rewrite_transcript
called after in-place compaction) to assert the corrected invariant from
#61145: archive_and_compact() already persisted, so the handler must NOT
call rewrite_transcript — its replace_messages(active_only=False) would
DELETE the just-archived rows.

E2E-verified against a real SessionDB: 6 soft-archived rows are wiped by
replace_messages' default path, confirming the data-loss premise.
2026-07-09 18:04:00 -07:00
AlexFucuson9
549b87c9aa fix(gateway): prevent hygiene compression from destroying archived transcript
When gateway session-hygiene auto-compression fires with in-place
compaction, the flow was:

1. _compress_context() calls archive_and_compact() — soft-archives old
   rows (active=0, compacted=1) and inserts compacted messages as the
   new active set.  This is the non-destructive, durable path.

2. The hygiene handler then called rewrite_transcript() — which calls
   replace_messages(active_only=False) — DELETEing ALL rows including
   the just-archived turns.  Silent permanent data loss (#61145).

The interactive /compress handler had the same bug.

Fix: only call rewrite_transcript() when session rotation produced a new
session id (legacy path).  When in-place compaction succeeded, skip the
rewrite — archive_and_compact() already handled persistence.

Closes #61145.
2026-07-09 18:04:00 -07:00
Teknium
b298fd5db1 test(cli): deflake --accept-hooks position test — one driver, one import (#61734)
test_accepted_at_every_position spawned 11 separate
'python -m hermes_cli.main' subprocesses, each cold-importing the full
CLI module tree under a 15s TimeoutExpired deadline. On a loaded CI
worker the import alone can exceed that (slice 2/8 flaked exactly here
on PR #61726's run, TimeoutExpired at subprocess.py:1253), failing PRs
that never touched the CLI.

Replace with ONE driver subprocess that imports hermes_cli.main once
and parses all 11 argvs in-process (catching SystemExit per argv),
reporting JSON results. Same assertions per argv, identical semantics
(verified the --help-before-unknown-flag exit behavior matches the old
method), ~11x less import work, and the 180s timeout only trips on a
genuine hang.
2026-07-09 17:56:50 -07:00
teknium1
10c0d9b2a7 fix(cron): contain any per-job exception in the due scan; harden as a class
Structural completion of the malformed-job freeze fixes (#61382 id-less,
#61525 non-dict schedule, #61581 bad next_run_at): wrap the per-job body
of _get_due_jobs_locked in try/except so any FUTURE malformed-field
variant degrades to skipping that one job for the tick instead of
aborting the scan before save_jobs() and freezing the whole profile's
scheduler.

Also: restore test_repeated_concurrent_runs_accumulate_completed_count
to TestMarkJobRunConcurrency (accidentally re-parented by the #61581
diff), add a containment regression test, and AUTHOR_MAP for hydracoco7.

E2E: one jobs.json carrying all five malformed shapes (drifted job_id,
missing id, null schedule, garbage next_run_at, non-string last_run_at)
plus a healthy sibling — single tick contains all five, sibling fires,
repairs persist, second tick stable. 670 cron tests green.
2026-07-09 17:31:47 -07:00
dsad
26f040ef20 fix(cron): malformed next_run_at no longer freezes the scheduler
One bad next_run_at value in jobs.json aborts the due-jobs scan with
ValueError from fromisoformat, before any save_jobs, so siblings lose
progress (fast-forwards etc).

Early normalization in _get_due_jobs_locked + defensive parses in
compute_next_run / _recoverable_oneshot_run_at.

Added test_bad_next_run_at_does_not_crash_or_block_sibling_jobs.
2026-07-09 17:31:47 -07:00
dsad
8e2ce43525 fix(cron): non-dict schedule no longer freezes the whole scheduler
A job record in jobs.json can have a non-dict 'schedule' value (null, string,
etc.) from direct edit or old writers.

In _get_due_jobs_locked:
  schedule = job.get('schedule', {})
  kind = schedule.get('kind')

This (and direct schedule['kind'] in compute_next_run etc.) raises and
aborts the entire due-jobs scan before save_jobs() or advancing next_run_at
for healthy jobs. Exactly the same failure mode as the id-less job P1.

Fix: normalize non-dict schedules to {} early (before any use), matching the
defense added for id-less records. Also added defensive guards in compute
functions.

Added regression test that a bad schedule does not crash and healthy sibling
is still returned.

Refs similar pattern in #61382.
2026-07-09 17:31:47 -07:00
bassis ho
c71d19c0ea fix(cron): id-less job no longer freezes the whole scheduler
A cron record authored by a direct jobs.json edit that bypassed
add_job() can lack an "id" key (older writers used "job_id"). Every
site in _get_due_jobs_locked indexes job["id"] eagerly — both the
logging helpers (job.get("name", job["id"]) evaluates the default
argument unconditionally) and the 'for rj in raw_jobs: if rj["id"] ==
job["id"]' persistence loops. A single malformed record therefore
raised KeyError mid-tick, aborting the entire scan before save_jobs()
ran. Result: healthy jobs' fast-forwarded next_run_at was computed in
memory then discarded on the exception unwind, freezing the whole
profile's scheduler in a per-minute loop (observed dormant for weeks).

Fix: normalize id-less records at the top of _get_due_jobs_locked before
anything keys off job["id"] — recover the id from a drifted "job_id"
key when present, else synthesize one via uuid4, and persist. This
repairs the whole bug class at the source rather than guarding each of
the ~12 downstream index sites.

Adds a regression test that fails with KeyError on the current code and
passes with the fix, asserting a healthy sibling job is still returned
when an id-less record shares the store.
2026-07-09 17:31:47 -07:00
giggling-ginger
8eac52054b fix(desktop): keep configured MoA presets in model picker 2026-07-08 07:34:00 +00:00
245 changed files with 11115 additions and 2132 deletions

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.

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)')
"

44
.github/workflows/js-tests.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
# .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: |
echo "packages=$(npm query .workspace | jq -c '[.[].location]')" >> "$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

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

2
.gitignore vendored
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).

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.

View File

@@ -110,7 +110,12 @@ def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str:
if tool_name == "web_extract":
urls = args.get("urls", [])
if urls:
return f"extract: {urls[0]}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "")
first = urls[0]
if isinstance(first, dict):
first = first.get("url") or first.get("href") or "?"
elif not isinstance(first, str):
first = "?"
return f"extract: {first}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "")
return "web extract"
if tool_name == "process":
action = str(args.get("action") or "").strip() or "manage"

View File

@@ -1953,6 +1953,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
_sm_timeout = get_provider_request_timeout(agent.provider, agent.model)
if _sm_timeout is not None:
agent._client_kwargs["timeout"] = _sm_timeout
# Reapply provider-specific headers (e.g. OpenRouter HTTP-Referer,
# X-Title) that were lost when _client_kwargs was rebuilt from
# scratch. Without this, model switches clear attribution headers
# and OpenRouter logs show "Unknown" for subsequent requests.
agent._apply_client_headers_for_base_url(effective_base)
agent.client = agent._create_openai_client(
dict(agent._client_kwargs),
reason="switch_model",
@@ -3156,21 +3161,20 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in
def force_close_tcp_sockets(client: Any) -> int:
"""Abort in-flight TCP I/O by shutting down sockets WITHOUT closing FDs.
def force_close_tcp_sockets(client: Any, *, release_fds: bool = False) -> int:
"""Abort in-flight TCP I/O by shutting down pool sockets.
When a provider drops a connection mid-stream — or the user issues an
interrupt — we want to unblock httpx's reader/writer immediately rather
than waiting for the kernel's per-connection timeout. ``shutdown(SHUT_RDWR)``
achieves that: it sends FIN, breaks any pending ``recv``/``send`` with EOF
or ``EPIPE``, but does NOT release the file descriptor.
or ``EPIPE``.
Historically this helper also called ``socket.close()`` so the FD got
released immediately, but that's unsafe when (as is the case for both the
interrupt-abort path and stale-call kill path) the helper runs on a
different thread than the one driving the request:
By default (``release_fds=False``) this helper does **not** call
``socket.close()`` / release the FD. That default is load-bearing for
cross-thread abort paths (#29507):
* The Python ``socket.socket`` we close here is the SAME object held by
* The Python ``socket.socket`` we close is the SAME object held by
httpx's pool, so closing it via Python sets its ``_fd`` to -1 and
future operations on that Python object fail safely.
* BUT the SSL wrapper (``ssl.SSLSocket``'s underlying OpenSSL ``BIO``)
@@ -3182,15 +3186,20 @@ def force_close_tcp_sockets(client: Any) -> int:
wrong file (issue #29507: 24-byte TLS application-data record
clobbering SQLite header bytes 5..28).
The fix is to let the owning thread own the close. ``shutdown()`` from any
thread is FD-safe; ``close()`` is not. The httpx connection's own close
path — which runs from the worker thread when it unwinds — will release
the FD via the same ``socket.socket`` object, and because Python's socket
close atomically swaps ``_fd`` to -1 *before* issuing ``os.close``, there
is no FD-aliasing window when only one thread closes.
``shutdown()`` from any thread is FD-safe; ``close()`` is not when a
stranger thread still has the BIO holding the raw FD.
Returns the number of sockets shut down. (Field kept as
``tcp_force_closed=N`` in the log line for backwards-compatible parsing.)
When the **owning** thread is disposing of a client that is no longer
shared (``_close_openai_client`` after replace / request-complete), pass
``release_fds=True``. httpx's own ``client.close()`` does not reliably
``os.close()`` sockets that were already ``shutdown()``'d, so without an
explicit ``sock.close()`` those FDs stay in kernel CLOSED state forever
and accumulate under long-lived gateways (issue #61979 — ~1 CLOSED fd
per ~6 minutes through a local proxy path).
Returns the number of sockets shut down (and optionally closed). Field
kept as ``tcp_force_closed=N`` in log lines for backwards-compatible
parsing.
"""
import socket as _socket
@@ -3202,7 +3211,13 @@ def force_close_tcp_sockets(client: Any) -> int:
except OSError:
# Already shut down / not connected / FD invalid — all benign.
pass
# IMPORTANT (#29507): do NOT call sock.close() here. See docstring.
# IMPORTANT (#29507): never release FDs from stranger-thread
# abort paths. Only the owning-thread close path may opt in.
if release_fds:
try:
sock.close()
except OSError:
pass
shutdown_count += 1
except Exception as exc:
_ra().logger.debug("Force-close TCP sockets sweep error: %s", exc)

View File

@@ -2102,7 +2102,7 @@ def _convert_user_message(content: Any) -> Dict[str, Any]:
if isinstance(content, list):
converted_blocks = _convert_content_to_anthropic(content)
if not converted_blocks or all(
b.get("text", "").strip() == ""
(b.get("text") or "").strip() == ""
for b in converted_blocks
if isinstance(b, dict) and b.get("type") == "text"
):

View File

@@ -4716,8 +4716,8 @@ def resolve_provider_client(
if custom_entry is None:
custom_entry = _get_named_custom_provider(provider)
if custom_entry:
custom_base = custom_entry.get("base_url", "").strip()
custom_key = custom_entry.get("api_key", "").strip()
custom_base = (custom_entry.get("base_url") or "").strip()
custom_key = (custom_entry.get("api_key") or "").strip()
custom_key_env = (custom_entry.get("key_env") or custom_entry.get("api_key_env") or "").strip()
if not custom_key and custom_key_env:
custom_key = os.getenv(custom_key_env, "").strip()

View File

@@ -62,6 +62,11 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
"api_key": parent_runtime.get("api_key") or None,
"base_url": parent_runtime.get("base_url") or None,
"api_mode": parent_api_mode,
"credential_pool": getattr(agent, "_credential_pool", None),
"request_overrides": dict(getattr(agent, "request_overrides", {}) or {}),
"max_tokens": getattr(agent, "max_tokens", None),
"command": getattr(agent, "acp_command", None),
"args": list(getattr(agent, "acp_args", []) or []),
"routed": False,
}
try:
@@ -89,10 +94,15 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
)
return {
"provider": rp.get("provider") or task_provider,
"model": task_model,
"model": rp.get("model") or task_model,
"api_key": rp.get("api_key"),
"base_url": rp.get("base_url"),
"api_mode": rp.get("api_mode"),
"credential_pool": rp.get("credential_pool"),
"request_overrides": dict(rp.get("request_overrides") or {}),
"max_tokens": rp.get("max_output_tokens"),
"command": rp.get("command"),
"args": list(rp.get("args") or []),
"routed": True,
}
except Exception as e:
@@ -680,6 +690,12 @@ def _run_review_in_thread(
# Match parent's toolset config so ``tools[]`` is byte-identical
# in the request body — Anthropic's cache key includes it.
# (The runtime whitelist below still restricts dispatch.)
_fork_kwargs: Dict[str, Any] = {}
if isinstance(_rt.get("max_tokens"), int):
_fork_kwargs["max_tokens"] = _rt["max_tokens"]
if isinstance(_rt.get("command"), str) and _rt["command"]:
_fork_kwargs["acp_command"] = _rt["command"]
_fork_kwargs["acp_args"] = _rt.get("args") or []
review_agent = AIAgent(
model=_rt.get("model") or agent.model,
max_iterations=16,
@@ -689,11 +705,13 @@ def _run_review_in_thread(
api_mode=_rt.get("api_mode"),
base_url=_rt.get("base_url") or None,
api_key=_rt.get("api_key") or None,
credential_pool=getattr(agent, "_credential_pool", None),
credential_pool=_rt.get("credential_pool"),
request_overrides=_rt.get("request_overrides") or {},
parent_session_id=agent.session_id,
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
skip_memory=True,
**_fork_kwargs,
)
review_agent._memory_write_origin = "background_review"
review_agent._memory_write_context = "background_review"

View File

@@ -164,6 +164,25 @@ def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]:
return None
def _provider_preferences_for_agent(agent) -> Dict[str, Any]:
"""Build the validated provider-routing object shared by request paths."""
preferences: Dict[str, Any] = {}
if agent.providers_allowed:
preferences["only"] = agent.providers_allowed
if agent.providers_ignored:
preferences["ignore"] = agent.providers_ignored
if agent.providers_order:
preferences["order"] = agent.providers_order
provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
if provider_sort:
preferences["sort"] = provider_sort
if agent.provider_require_parameters:
preferences["require_parameters"] = True
if agent.provider_data_collection:
preferences["data_collection"] = agent.provider_data_collection
return preferences
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
@@ -801,21 +820,8 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
_omit_temp = False
_fixed_temp = None
# Provider preferences (OpenRouter-style)
_prefs: Dict[str, Any] = {}
if agent.providers_allowed:
_prefs["only"] = agent.providers_allowed
if agent.providers_ignored:
_prefs["ignore"] = agent.providers_ignored
if agent.providers_order:
_prefs["order"] = agent.providers_order
_provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
if _provider_sort:
_prefs["sort"] = _provider_sort
if agent.provider_require_parameters:
_prefs["require_parameters"] = True
if agent.provider_data_collection:
_prefs["data_collection"] = agent.provider_data_collection
# Provider preferences (aggregator profile decides whether to emit them).
_prefs = _provider_preferences_for_agent(agent)
# Anthropic-compatible max-output fallback (last resort only — applied in
# build_kwargs *after* ephemeral/user/profile max_tokens, never overriding
@@ -1690,18 +1696,28 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
if _lm_reasoning_effort is not None:
summary_kwargs["reasoning_effort"] = _lm_reasoning_effort
# Include provider routing preferences
provider_preferences = {}
if agent.providers_allowed:
provider_preferences["only"] = agent.providers_allowed
if agent.providers_ignored:
provider_preferences["ignore"] = agent.providers_ignored
if agent.providers_order:
provider_preferences["order"] = agent.providers_order
_provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
if _provider_sort:
provider_preferences["sort"] = _provider_sort
if provider_preferences and (
# Merge the profile's canonical body even when routing is unset:
# profiles may always emit required metadata such as Portal tags.
provider_preferences = _provider_preferences_for_agent(agent)
profile_extra_body = {}
try:
from providers import get_provider_profile
provider_profile = get_provider_profile(agent.provider)
if provider_profile is not None:
profile_extra_body = provider_profile.build_extra_body(
session_id=getattr(agent, "session_id", None),
provider_preferences=provider_preferences or None,
model=agent.model,
base_url=agent.base_url,
reasoning_config=agent.reasoning_config,
)
except Exception:
pass
if profile_extra_body:
summary_extra_body.update(profile_extra_body)
if provider_preferences and "provider" not in profile_extra_body and (
(agent.provider or "").strip().lower() == "openrouter"
or agent._is_openrouter_url()
):

View File

@@ -56,6 +56,7 @@ import logging
import os
import re
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
@@ -412,10 +413,18 @@ def _marker_root(cwd: Path) -> Optional[Path]:
"""
current = cwd.resolve()
home = _home()
# Shared world-writable temp roots are never project roots: a stray
# manifest in /tmp (left by any process) must not flip every session
# whose cwd lives under the temp dir into the coding posture. Same
# reasoning as the $HOME skip below.
try:
temp_root = Path(tempfile.gettempdir()).resolve()
except Exception:
temp_root = None
for depth, parent in enumerate([current, *current.parents]):
if depth > 6:
break
if parent == home:
if parent == home or (temp_root is not None and parent == temp_root):
continue
for marker in _PROJECT_MARKERS:
if (parent / marker).exists():

View File

@@ -613,6 +613,11 @@ def run_conversation(
truncated_response_parts: List[str] = []
compression_attempts = 0
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
# Last composed answer intentionally held back by a verification gate. If
# that continuation consumes the remaining budget, this is the best
# user-facing result available; it must not be confused with error or
# recovery text produced by unrelated exit paths.
_pending_verification_response = None
# Per-turn tally of consecutive successful credential-pool token refreshes,
# keyed by (provider, pool-entry-id). A persistent upstream 401 lets
@@ -5099,6 +5104,10 @@ def run_conversation(
}
messages.append(continue_msg)
agent._session_messages = messages
# An acknowledgment is explicitly non-final. Do not let its
# text suppress iteration-limit summarization if this
# continuation consumes the remaining budget.
final_response = None
continue
codex_ack_continuations = 0
@@ -5173,6 +5182,12 @@ def run_conversation(
# terminal. Keep a debug breadcrumb in agent.log for tracing.
logger.debug("verification stop-loop nudge issued (attempt %d)",
agent._verification_stop_nudges)
# Keep the attempted answer only as an explicit fallback for
# continuation-budget exhaustion. ``final_response`` itself
# must be cleared so the finalizer can distinguish this gate
# from unrelated error/recovery exits. (#61631)
_pending_verification_response = final_response
final_response = None
continue
# User verification-loop gate: when the agent edited code this
@@ -5224,6 +5239,8 @@ def run_conversation(
agent._session_messages = messages
logger.debug("pre_verify nudge issued (attempt %d)",
agent._pre_verify_nudges)
_pending_verification_response = final_response
final_response = None
continue
messages.append(final_msg)
@@ -5308,6 +5325,7 @@ def run_conversation(
original_user_message=original_user_message,
_should_review_memory=_should_review_memory,
_turn_exit_reason=_turn_exit_reason,
_pending_verification_response=_pending_verification_response,
)

View File

@@ -45,12 +45,26 @@ def _strip_aux_credential(value: Any) -> Optional[str]:
class _ReviewRuntimeBinding(NamedTuple):
"""Provider/model for the curator review fork plus optional per-slot overrides."""
"""Provider/model for the curator review fork plus per-slot overrides."""
provider: str
model: str
explicit_api_key: Optional[str]
explicit_base_url: Optional[str]
request_overrides: Dict[str, Any]
def _merge_request_overrides(
runtime_overrides: Any,
slot_extra_body: Any,
) -> Dict[str, Any]:
"""Merge resolver metadata with task-local request body fields."""
merged = dict(runtime_overrides or {})
if isinstance(slot_extra_body, dict) and slot_extra_body:
extra_body = dict(merged.get("extra_body") or {})
extra_body.update(slot_extra_body)
merged["extra_body"] = extra_body
return merged
DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
@@ -1764,6 +1778,7 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding:
_task_model,
_strip_aux_credential(_cur_task.get("api_key")),
_strip_aux_credential(_cur_task.get("base_url")),
_merge_request_overrides({}, _cur_task.get("extra_body")),
)
# 2. Legacy curator.auxiliary.{provider,model} (deprecated, pre-unification)
@@ -1781,10 +1796,11 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding:
str(_legacy_model),
_strip_aux_credential(_legacy.get("api_key")),
_strip_aux_credential(_legacy.get("base_url")),
_merge_request_overrides({}, _legacy.get("extra_body")),
)
# 3. Fall through to the main chat model
return _ReviewRuntimeBinding(_main_provider, _main_model, None, None)
return _ReviewRuntimeBinding(_main_provider, _main_model, None, None, {})
def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]:
@@ -1850,6 +1866,11 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
_base_url = None
_api_mode = None
_resolved_provider = None
_credential_pool = None
_request_overrides: Dict[str, Any] = {}
_max_tokens = None
_acp_command = None
_acp_args = None
_model_name = ""
try:
from hermes_cli.config import load_config
@@ -1867,6 +1888,16 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
_base_url = _rp.get("base_url")
_api_mode = _rp.get("api_mode")
_resolved_provider = _rp.get("provider") or _provider
_credential_pool = _rp.get("credential_pool")
_request_overrides = _merge_request_overrides(
_rp.get("request_overrides"),
_binding.request_overrides.get("extra_body"),
)
_max_tokens = _rp.get("max_output_tokens")
_acp_command = _rp.get("command")
_acp_args = list(_rp.get("args") or [])
if isinstance(_rp.get("model"), str) and _rp["model"].strip():
_model_name = _rp["model"].strip()
except Exception as e:
logger.debug("Curator provider resolution failed: %s", e, exc_info=True)
@@ -1875,12 +1906,21 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
review_agent = None
try:
_agent_kwargs: Dict[str, Any] = {}
if isinstance(_max_tokens, int):
_agent_kwargs["max_tokens"] = _max_tokens
if isinstance(_acp_command, str) and _acp_command:
_agent_kwargs["acp_command"] = _acp_command
_agent_kwargs["acp_args"] = _acp_args or []
review_agent = AIAgent(
model=_model_name,
provider=_resolved_provider,
api_key=_api_key,
base_url=_base_url,
api_mode=_api_mode,
credential_pool=_credential_pool,
request_overrides=_request_overrides,
**_agent_kwargs,
# Umbrella-building over a large skill collection is worth a
# high iteration ceiling — the pass typically takes 50-100
# API calls against hundreds of candidate skills. The

View File

@@ -27,6 +27,14 @@ logger = logging.getLogger(__name__)
_ANSI_RESET = "\033[0m"
def _display_url(value: Any) -> str:
"""Extract a display-only URL without assuming model argument types."""
if isinstance(value, dict):
value = value.get("url") or value.get("href")
return value.strip() if isinstance(value, str) else ""
# Diff colors — resolved lazily from the skin engine so they adapt
# to light/dark themes. Falls back to sensible defaults on import
# failure. We cache after first resolution for performance.
@@ -1259,7 +1267,7 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
return False, ""
def get_cute_tool_message(
def _get_cute_tool_message(
tool_name: str, args: dict, duration: float, result: str | None = None,
) -> str:
"""Generate a formatted tool completion line for CLI quiet mode.
@@ -1301,9 +1309,11 @@ def get_cute_tool_message(
if tool_name == "web_extract":
urls = args.get("urls", [])
if urls:
url = urls[0] if isinstance(urls, list) else str(urls)
url = _display_url(urls[0] if isinstance(urls, list) else urls)
if not url:
return _wrap(f"┊ 📄 fetch pages {dur}")
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
extra = f" +{len(urls)-1}" if isinstance(urls, list) and len(urls) > 1 else ""
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
return _wrap(f"┊ 📄 fetch pages {dur}")
if tool_name == "terminal":
@@ -1433,6 +1443,19 @@ def get_cute_tool_message(
return _wrap(f"┊ ⚡ {tool_name[:9]:9} {_trunc(preview, 35)} {dur}")
def get_cute_tool_message(
tool_name: str, args: dict, duration: float, result: str | None = None,
) -> str:
"""Render a completion label without letting cosmetic failures escape."""
try:
return _get_cute_tool_message(tool_name, args, duration, result=result)
except Exception as exc: # noqa: BLE001 — display must never abort a turn
logger.debug("Tool completion label failed for %s: %s", tool_name, exc)
safe_name = tool_name[:9] if isinstance(tool_name, str) and tool_name else "tool"
safe_duration = f"{duration:.1f}s" if isinstance(duration, (int, float)) else "done"
return f"┊ ⚡ {safe_name:9} completed {safe_duration}"
# =========================================================================
# Honcho session line (one-liner with clickable OSC 8 hyperlink)
# =========================================================================

View File

@@ -267,8 +267,6 @@ _CONTEXT_OVERFLOW_PATTERNS = [
# Chinese error messages (some providers return these)
"超过最大长度",
"上下文长度",
# Z.AI / Zhipu GLM pattern (English form; error code 1210)
"tokens in request more than max tokens allowed",
# AWS Bedrock Converse API error patterns
"input is too long",
"max input token",

View File

@@ -120,7 +120,7 @@ _REFERENCE_SYSTEM_PROMPT = (
def _slot_label(slot: dict[str, str]) -> str:
return f"{slot.get('provider', '').strip()}:{slot.get('model', '').strip()}"
return f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}"
def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]:

View File

@@ -1077,7 +1077,7 @@ def _load_context_cache() -> Dict[str, int]:
try:
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return data.get("context_lengths", {})
return data.get("context_lengths") or {}
except Exception as e:
logger.debug("Failed to load context length cache: %s", e)
return {}

View File

@@ -34,6 +34,7 @@ from typing import Any, Dict, List, Optional
from agent.tool_result_classification import (
FILE_MUTATING_TOOL_NAMES as _FILE_MUTATING_TOOLS,
)
from tools.threat_patterns import scan_for_threats
logger = logging.getLogger(__name__)
@@ -379,13 +380,21 @@ def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict
callers should compare by value, not by ``is``.
"""
wrapped = _maybe_wrap_untrusted(name, content)
return {
message = {
"role": "tool",
"name": name,
"tool_name": name,
"content": wrapped,
"tool_call_id": tool_call_id,
}
try:
risk_metadata = _tool_output_risk_metadata(name, content)
except Exception as exc:
logger.debug("Tool output risk scan failed for %s: %s", name, exc)
else:
if risk_metadata is not None:
message["_tool_output_risk"] = risk_metadata
return message
# Tools whose results carry attacker-controllable content. Wrapping their
@@ -419,6 +428,42 @@ def _is_untrusted_tool(name: Optional[str]) -> bool:
return any(name.startswith(p) for p in _UNTRUSTED_TOOL_PREFIXES)
def _tool_output_risk_metadata(name: str, content: Any) -> Optional[Dict[str, Any]]:
"""Classify textual attacker-controlled output without retaining a copy.
The advisory metadata is internal-only. It records deterministic finding
identifiers, never blocks or redacts the normal result, and deliberately
omits raw scanned text.
"""
if not _is_untrusted_tool(name):
return None
if isinstance(content, str):
text_parts = [content]
elif isinstance(content, list):
text_parts = [
item["text"]
for item in content
if isinstance(item, dict)
and item.get("type") == "text"
and isinstance(item.get("text"), str)
]
if not text_parts:
return None
else:
return None
findings: List[str] = []
for text in text_parts:
for finding in scan_for_threats(text, scope="context"):
if finding not in findings:
findings.append(finding)
return {
"risk": "high" if findings else "low",
"findings": findings,
"redacted": False,
}
def _neutralize_delimiters(content: str) -> str:
"""Defang any literal ``untrusted_tool_result`` delimiter embedded in
attacker-controlled content so it can't break out of the wrapper.

View File

@@ -74,6 +74,25 @@ _MAX_TOOL_WORKERS = 8
_DEFAULT_CONCURRENT_TOOL_TIMEOUT_S = 420.0
def _parse_tool_arguments(raw_arguments: Any) -> tuple[dict, Optional[str]]:
"""Parse model-emitted arguments without repairing or coercing them."""
try:
arguments = json.loads(raw_arguments)
except (json.JSONDecodeError, TypeError):
arguments = None
if isinstance(arguments, dict):
return arguments, None
return {}, json.dumps(
{
"error": "Invalid tool arguments",
"message": (
"Tool arguments must be a valid JSON object; tool was not executed."
),
},
ensure_ascii=False,
)
def _resolve_concurrent_tool_timeout() -> float | None:
raw = os.getenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "").strip()
if not raw:
@@ -337,19 +356,29 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
for tool_call in tool_calls:
function_name = tool_call.function.name
# Reset nudge counters
function_args, malformed_args_result = _parse_tool_arguments(
tool_call.function.arguments
)
if malformed_args_result is not None:
parsed_calls.append(
(
tool_call,
function_name,
function_args,
[],
malformed_args_result,
False,
)
)
continue
# Reset nudge counters only for a structurally valid invocation.
if function_name == "memory":
agent._turns_since_memory = 0
elif function_name == "skill_manage":
agent._iters_since_skill = 0
try:
function_args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError:
function_args = {}
if not isinstance(function_args, dict):
function_args = {}
# ── Tool Search unwrap ────────────────────────────────────────
# When the model invokes the tool_call bridge, peel it open so
# every downstream check (checkpointing, guardrails, plugin
@@ -935,7 +964,25 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# image tool result never poisons canonical session history.
# String results pass through unchanged.
_tool_content = agent._tool_result_content_for_active_model(name, function_result)
messages.append(make_tool_result_message(name, _tool_content, tc.id))
tool_message = make_tool_result_message(name, _tool_content, tc.id)
messages.append(tool_message)
risk_metadata = tool_message.get("_tool_output_risk")
if (
risk_metadata is not None
and risk_metadata.get("risk") != "low"
and agent.tool_progress_callback
):
try:
agent.tool_progress_callback(
"tool.output_risk",
name,
None,
None,
tool_call_id=tc.id,
risk_metadata=risk_metadata,
)
except Exception as cb_err:
logging.debug("Tool output risk callback error: %s", cb_err)
_flush_session_db_after_tool_progress(
agent,
messages,
@@ -990,13 +1037,24 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
function_name = tool_call.function.name
try:
function_args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError as e:
logger.warning(f"Unexpected JSON error after validation: {e}")
function_args = {}
if not isinstance(function_args, dict):
function_args = {}
function_args, malformed_args_result = _parse_tool_arguments(
tool_call.function.arguments
)
if malformed_args_result is not None:
messages.append(
make_tool_result_message(
function_name,
malformed_args_result,
tool_call.id,
)
)
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"invalid tool arguments {function_name}",
)
agent._apply_pending_steer_to_tool_results(messages, 1)
continue
# Tool Search unwrap — see execute_tool_calls_concurrent for full
# rationale, including the scope gate (the unwrap dispatches the
@@ -1584,7 +1642,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
# Unwrap _multimodal dicts to an OpenAI-style content list
# (see parallel path for rationale). String results pass through.
_tool_content = agent._tool_result_content_for_active_model(function_name, function_result)
messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id))
tool_message = make_tool_result_message(function_name, _tool_content, tool_call.id)
messages.append(tool_message)
risk_metadata = tool_message.get("_tool_output_risk")
if (
risk_metadata is not None
and risk_metadata.get("risk") != "low"
and agent.tool_progress_callback
):
try:
agent.tool_progress_callback(
"tool.output_risk",
function_name,
None,
None,
tool_call_id=tool_call.id,
risk_metadata=risk_metadata,
)
except Exception as cb_err:
logging.debug("Tool output risk callback error: %s", cb_err)
_flush_session_db_after_tool_progress(
agent,
messages,

View File

@@ -42,6 +42,7 @@ def finalize_turn(
original_user_message,
_should_review_memory,
_turn_exit_reason,
_pending_verification_response=None,
):
"""Run the post-loop finalization and return the turn ``result`` dict.
@@ -50,10 +51,35 @@ def finalize_turn(
"""
from agent.conversation_loop import logger
if final_response is None and (
budget_exhausted = (
api_call_count >= agent.max_iterations
or agent.iteration_budget.remaining <= 0
):
)
budget_fallback_eligible = (
budget_exhausted
and not interrupted
and not failed
and str(_turn_exit_reason) in {"unknown", "budget_exhausted"}
)
continuation_budget_exhausted = (
final_response is None
and bool(_pending_verification_response)
and budget_fallback_eligible
)
iteration_limit_fallback = False
preserved_verification_fallback = False
if continuation_budget_exhausted:
# A verification/continuation gate deliberately withheld a composed
# answer, then consumed the remaining budget before producing a newer
# one. Preserve that exact answer instead of replacing it with another
# fallible model call. The explicit pending value is the provenance
# guard: unrelated error/recovery exits can never enter this branch.
final_response = _pending_verification_response
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})"
iteration_limit_fallback = True
preserved_verification_fallback = True
elif final_response is None and budget_fallback_eligible:
# Budget exhausted — ask the model for a summary via one extra
# API call with tools stripped. _handle_max_iterations injects a
# user message and makes a single toolless request.
@@ -68,20 +94,18 @@ def finalize_turn(
"— requesting summary..."
)
final_response = agent._handle_max_iterations(messages, api_call_count)
iteration_limit_fallback = True
if iteration_limit_fallback:
# If running as a kanban worker, signal the dispatcher that the
# worker could not complete (rather than treating it as a
# protocol violation). The agent loop strips tools before calling
# _handle_max_iterations, so the model cannot call kanban_block
# itself — we must do it on its behalf.
# protocol violation). This applies whether the user-facing fallback
# came from the summary call or an explicitly pending continuation;
# both exhausted the task budget and must advance the failure circuit.
#
# We route through ``_record_task_failure(outcome="timed_out")``
# rather than ``kanban_block`` so this counts toward the
# ``consecutive_failures`` counter and the dispatcher's
# ``failure_limit`` circuit breaker (#29747 gap 2). Without this,
# a task whose worker keeps exhausting its budget would block
# silently each run, get auto-promoted by the operator (or never
# surface), and re-block in an endless loop with no signal.
# rather than ``kanban_block`` so this counts toward the dispatcher's
# consecutive-failure circuit breaker (#29747 gap 2).
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
if _kanban_task:
try:
@@ -304,6 +328,7 @@ def finalize_turn(
# truncated partial (the "The" case from #34452).
_is_partial_fragment = (
not _is_empty_terminal
and not preserved_verification_fallback
and not str(_turn_exit_reason).startswith("text_response")
and len(_stripped) <= 24
and _stripped[-1:] not in {".", "!", "?", "", "", "", "`", ")"}

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",

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.
}
}

View File

@@ -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'

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

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,

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)

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'

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,

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()
})
}

View File

@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
bundledRuntimeImportCheck,

View File

@@ -2,9 +2,18 @@ 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 { cachedScriptPath, installedAgentInstallScript, resolveInstallScript, runBootstrap } from './bootstrap-runner'
import { test } from 'vitest'
import {
buildPinArgs,
buildPosixPinArgs,
cachedScriptPath,
hasExistingGitCheckout,
installedAgentInstallScript,
resolveInstallScript,
runBootstrap
} from './bootstrap-runner'
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
@@ -54,6 +63,58 @@ test('installedAgentInstallScript resolves the installer in the agent checkout',
}
})
test('existing checkout detection requires git metadata', () => {
const home = mkTmpHome()
try {
const activeRoot = path.join(home, 'hermes-agent')
assert.equal(hasExistingGitCheckout(activeRoot), false)
fs.mkdirSync(path.join(activeRoot, '.git'), { recursive: true })
assert.equal(hasExistingGitCheckout(activeRoot), true)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('fresh bootstrap args include the packaged commit pin', () => {
const installStamp = { commit: 'a'.repeat(40), branch: 'main' }
assert.deepEqual(buildPinArgs(installStamp), ['-Commit', installStamp.commit, '-Branch', 'main'])
assert.deepEqual(
buildPosixPinArgs({
installStamp,
activeRoot: '/tmp/hermes-agent',
hermesHome: '/tmp/hermes'
}),
[
'--dir',
'/tmp/hermes-agent',
'--hermes-home',
'/tmp/hermes',
'--branch',
'main',
'--commit',
installStamp.commit
]
)
})
test('existing-checkout bootstrap args keep branch but skip the packaged commit pin', () => {
const installStamp = { commit: 'a'.repeat(40), branch: 'main' }
assert.deepEqual(buildPinArgs(installStamp, { pinCommit: false }), ['-Branch', 'main'])
assert.deepEqual(
buildPosixPinArgs({
installStamp,
activeRoot: '/tmp/hermes-agent',
hermesHome: '/tmp/hermes',
pinCommit: false
}),
['--dir', '/tmp/hermes-agent', '--hermes-home', '/tmp/hermes', '--branch', 'main']
)
})
test('resolveInstallScript prefers a cached script without touching the network', async () => {
const home = mkTmpHome()

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 {
@@ -107,6 +103,18 @@ function installedAgentInstallScript(hermesHome) {
}
}
function hasExistingGitCheckout(activeRoot) {
if (!activeRoot) {
return false
}
try {
return fs.existsSync(path.join(activeRoot, '.git'))
} catch {
return false
}
}
function cachedScriptPath(hermesHome, commit) {
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`)
}
@@ -413,6 +421,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
reject(err)
})
@@ -429,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)
})
})
@@ -505,6 +515,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
reject(err)
})
@@ -520,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 })
})
})
@@ -529,13 +541,14 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
// Manifest + stage dispatch
// ---------------------------------------------------------------------------
// Build the install.ps1 pin args (-Commit / -Branch) from the install-stamp
// so the repository stage clones the exact SHA the .exe was tested with
// instead of falling back to install.ps1's default ($Branch = "main").
function buildPinArgs(installStamp) {
// Build the installer branch/pin args from the install stamp. The commit pin
// is fresh-install only: once a managed checkout already exists, bootstrap is
// a repair/update path and must not let an old packaged app detach the checkout
// back to the commit baked into that app.
function buildPinArgs(installStamp, { pinCommit = true } = {}) {
const args = []
if (installStamp && installStamp.commit) {
if (pinCommit && installStamp && installStamp.commit) {
args.push('-Commit', installStamp.commit)
}
@@ -546,26 +559,34 @@ function buildPinArgs(installStamp) {
return args
}
function buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) {
function buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit = true }) {
const args = ['--dir', activeRoot, '--hermes-home', hermesHome]
if (installStamp && installStamp.branch) {
args.push('--branch', installStamp.branch)
}
if (installStamp && installStamp.commit) {
if (pinCommit && installStamp && installStamp.commit) {
args.push('--commit', installStamp.commit)
}
return args
}
async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp }) {
async function fetchManifest({
scriptPath,
installerKind,
emit,
hermesHome,
activeRoot,
installStamp,
pinCommit
}) {
const isPosix = installerKind === 'posix'
const args = isPosix
? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
: ['-Manifest', ...buildPinArgs(installStamp)]
? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit })]
: ['-Manifest', ...buildPinArgs(installStamp, { pinCommit })]
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit,
@@ -622,7 +643,17 @@ function parseStageResult(stdout) {
return null
}
async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, activeRoot, abortSignal, installStamp }) {
async function runStage({
scriptPath,
installerKind,
stage,
emit,
hermesHome,
activeRoot,
abortSignal,
installStamp,
pinCommit
}) {
const startedAt = Date.now()
emit({ type: 'stage', name: stage.name, state: 'running' })
@@ -634,9 +665,9 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
stage.name,
'--non-interactive',
'--json',
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit })
]
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp, { pinCommit })]
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit,
@@ -774,6 +805,18 @@ async function runBootstrap(opts) {
})
try {
const existingCheckout = hasExistingGitCheckout(activeRoot)
const pinCommit = !existingCheckout
if (existingCheckout && installStamp && installStamp.commit) {
emit({
type: 'log',
line:
`[bootstrap] existing checkout detected at ${activeRoot}; ` +
`not pinning to packaged install stamp ${installStamp.commit.slice(0, 12)}`
})
}
// 1. Resolve the platform installer.
const scriptInfo = await resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit })
const installerKind = scriptInfo.kind || 'powershell'
@@ -785,7 +828,8 @@ async function runBootstrap(opts) {
emit,
hermesHome,
activeRoot,
installStamp
installStamp,
pinCommit
})
emit({
@@ -813,7 +857,8 @@ async function runBootstrap(opts) {
hermesHome,
activeRoot,
abortSignal,
installStamp
installStamp,
pinCommit
})
if (ev.state === 'failed') {
@@ -847,7 +892,10 @@ async function runBootstrap(opts) {
}
export {
buildPinArgs,
buildPosixPinArgs,
cachedScriptPath,
hasExistingGitCheckout,
installedAgentInstallScript,
// Exposed for testability
parseStageResult,

View File

@@ -11,7 +11,8 @@
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
AT_COOKIE_VARIANTS,
@@ -20,7 +21,9 @@ import {
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveLiveSession,
cookiesHavePrivySession,
cookiesHaveSession,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normAuthMode,
pathWithGlobalRemoteProfile,
@@ -47,6 +50,19 @@ test('normAuthMode coerces to token unless explicitly oauth', () => {
assert.equal(normAuthMode('weird'), 'token')
})
// --- modeIsRemoteLike ---
test('modeIsRemoteLike is true for remote and cloud, false otherwise', () => {
// cloud resolves to a remote backend under the hood (Q6), so every resolution
// site treats it like remote.
assert.equal(modeIsRemoteLike('remote'), true)
assert.equal(modeIsRemoteLike('cloud'), true)
assert.equal(modeIsRemoteLike('local'), false)
assert.equal(modeIsRemoteLike(undefined), false)
assert.equal(modeIsRemoteLike(null), false)
assert.equal(modeIsRemoteLike('weird'), false)
})
// --- profileRemoteOverride ---
test('profileRemoteOverride returns null when no profile is given', () => {
@@ -86,6 +102,21 @@ test('profileRemoteOverride preserves an explicit oauth auth mode', () => {
assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth')
})
test('profileRemoteOverride treats a cloud entry as a remote override', () => {
// A 'cloud' per-profile entry resolves to the same remote backend a 'remote'
// entry would (Q6) — the override must be returned, not dropped.
const config = {
profiles: {
coder: { mode: 'cloud', url: 'https://agent-1.agents.nousresearch.com', authMode: 'oauth' }
}
}
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
url: 'https://agent-1.agents.nousresearch.com',
authMode: 'oauth',
token: undefined
})
})
test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride({}, 'coder'), null)
assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null)
@@ -332,6 +363,35 @@ test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', ()
assert.equal(cookiesHaveLiveSession([]), false)
})
// --- cookiesHavePrivySession (Nous portal / Privy auth, NOT gateway cookies) ---
test('cookiesHavePrivySession detects the privy-token access cookie', () => {
assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: 'jwt' }]), true)
})
test('cookiesHavePrivySession detects __Host-/__Secure- prefixes and the legacy privy-session name', () => {
assert.equal(cookiesHavePrivySession([{ name: '__Host-privy-token', value: 'x' }]), true)
assert.equal(cookiesHavePrivySession([{ name: '__Secure-privy-token', value: 'x' }]), true)
assert.equal(cookiesHavePrivySession([{ name: 'privy-session', value: 'x' }]), true)
})
test('cookiesHavePrivySession is false for an empty value', () => {
assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: '' }]), false)
})
test('cookiesHavePrivySession does NOT treat hermes gateway cookies as a portal session', () => {
// The whole point of Q7: a gateway session cookie is NOT a portal sign-in.
assert.equal(cookiesHavePrivySession([{ name: 'hermes_session_at', value: 'x' }]), false)
assert.equal(cookiesHavePrivySession([{ name: '__Host-hermes_session_rt', value: 'x' }]), false)
})
test('cookiesHavePrivySession is false for unrelated cookies and non-arrays', () => {
assert.equal(cookiesHavePrivySession([{ name: 'other', value: 'x' }]), false)
assert.equal(cookiesHavePrivySession(null), false)
assert.equal(cookiesHavePrivySession(undefined), false)
assert.equal(cookiesHavePrivySession([]), false)
})
// --- tokenPreview ---
test('tokenPreview returns null for empty', () => {

View File

@@ -37,6 +37,15 @@
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
// The Nous portal (NAS) does NOT use Hermes gateway session cookies — it is a
// Privy-authed Next.js app. NAS `auth()` (src/server/auth/session.ts) reads the
// `privy-token` access-token cookie (with `privy-id-token` alongside), which is
// also exactly what the `/api/agents` cookie-auth path validates. So portal
// sign-in / discovery liveness must look for the Privy cookie, NOT the gateway
// cookies above. `privy-token` is the access token (the required signal);
// variants cover the secured-prefix forms and the older `privy-session` name.
const PRIVY_SESSION_COOKIE_VARIANTS = ['__Host-privy-token', '__Secure-privy-token', 'privy-token', 'privy-session']
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
@@ -150,20 +159,31 @@ function normAuthMode(mode) {
return mode === 'oauth' ? 'oauth' : 'token'
}
// True for connection modes that resolve to a REMOTE backend. 'cloud' is a
// Hermes Cloud connection (cloud-auto-discovery Q3/Q6): it carries a
// remote-shaped block and reuses the entire remote connect/probe/reconnect
// path, so every resolution site treats it exactly like 'remote'. The only
// places that distinguish cloud from remote are the settings UI (which card to
// show) and config persistence (remembering the provenance). Centralized here
// so no resolution site forgets the third arm.
function modeIsRemoteLike(mode) {
return mode === 'remote' || mode === 'cloud'
}
/**
* Select a profile's explicit remote override from a connection config, or null
* when it has none (so the caller falls back to env → global remote → local).
*
* The config may carry a `profiles` map keyed by name; an entry counts as an
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
* is the raw stored secret; main.ts decrypts it. Returns
* override only with a remote-like `mode` (remote or cloud) and a non-empty
* `url`. Pure: `token` is the raw stored secret; main.ts decrypts it. Returns
* `{ url, authMode, token } | null`.
*/
function profileRemoteOverride(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
if (!entry || typeof entry !== 'object' || !modeIsRemoteLike(entry.mode)) {
return null
}
@@ -292,6 +312,22 @@ function cookiesHaveLiveSession(cookies) {
return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
}
/**
* True if the cookie jar holds a live Nous PORTAL (Privy) session — a non-empty
* `privy-token` (access-token) cookie, or a variant. This is the portal
* analogue of `cookiesHaveLiveSession`: the portal authenticates via Privy, not
* the Hermes gateway session cookies, so cloud sign-in / discovery liveness
* must check THIS, not the gateway helpers. (NAS `auth()` and the `/api/agents`
* cookie path both key off `privy-token`.)
*/
function cookiesHavePrivySession(cookies) {
if (!Array.isArray(cookies)) {
return false
}
return cookies.some(c => c && c.value && PRIVY_SESSION_COOKIE_VARIANTS.includes(c.name))
}
export {
AT_COOKIE_VARIANTS,
authModeFromStatus,
@@ -299,10 +335,13 @@ export {
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveLiveSession,
cookiesHavePrivySession,
cookiesHaveSession,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normAuthMode,
pathWithGlobalRemoteProfile,
PRIVY_SESSION_COOKIE_VARIANTS,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,

View File

@@ -6,7 +6,8 @@
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
adoptServedDashboardToken,

View File

@@ -10,7 +10,8 @@
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
buildPosixCleanupScript,

View File

@@ -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)

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() {

View File

@@ -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'

View File

@@ -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) {

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'

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 })
}
})

View File

@@ -3,7 +3,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 {
addWorktree,

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)
})

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

View File

@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
createLinkTitleWindow,

View File

@@ -55,6 +55,7 @@ export function readLinkTitleWindowTitle(window) {
if (!window || window.isDestroyed()) {
return ''
}
const contents = window.webContents
if (!contents || contents.isDestroyed()) {

File diff suppressed because it is too large Load Diff

View File

@@ -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'

View File

@@ -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\)/)
})

View File

@@ -43,6 +43,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
// Hermes Cloud: one portal login powers discovery + silent per-agent sign-in
// (cloud-auto-discovery Phase 3).
cloud: {
status: () => ipcRenderer.invoke('hermes:cloud:status'),
login: () => ipcRenderer.invoke('hermes:cloud:login'),
logout: () => ipcRenderer.invoke('hermes:cloud:logout'),
discover: org => ipcRenderer.invoke('hermes:cloud:discover', org),
agentSignIn: dashboardUrl => ipcRenderer.invoke('hermes:cloud:agent-sign-in', dashboardUrl)
},
profile: {
get: () => ipcRenderer.invoke('hermes:profile:get'),
set: name => ipcRenderer.invoke('hermes:profile:set', name)
@@ -195,6 +204,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
},
// Soft gateway-mode apply finished tearing down the primary backend. Renderer
// should wipe session lists + re-dial without a window reload.
onConnectionApplied: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:connection:applied', listener)
return () => ipcRenderer.removeListener('hermes:connection:applied', listener)
},
onPowerResume: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:power-resume', listener)

View File

@@ -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'
)
})

View File

@@ -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)
})

View File

@@ -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
}

View File

@@ -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'

View File

@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
MACOS_TAHOE_DARWIN_MAJOR,

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'

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
}

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'

View File

@@ -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,

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

View File

@@ -16,7 +16,8 @@
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
canonicalGitHubRemote,

View File

@@ -22,6 +22,7 @@ function canonicalGitHubRemote(url) {
if (!url) {
return ''
}
let value = String(url).trim()
if (value.startsWith('git@github.com:')) {

View File

@@ -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'

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()
})

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)

View File

@@ -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
})
})
})

View File

@@ -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
}

View File

@@ -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/)
})

View File

@@ -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 }), [])
})

View File

@@ -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
}
}

View File

@@ -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'
)
})

View File

@@ -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'

View File

@@ -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

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'

View File

@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { test } from 'vitest'
import {
decodeClipboardImageBase64,

View File

@@ -5,9 +5,18 @@
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom'
import { test } from 'vitest'
import {
clampZoomLevel,
installZoomReassertOnWindowEvents,
percentToZoomLevel,
ZOOM_REASSERT_WINDOW_EVENTS,
ZOOM_STORAGE_KEY,
zoomLevelToPercent,
zoomWiringForWindowKind
} from './zoom'
test('storage key stays stable so persisted zoom survives upgrades', () => {
assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel')
@@ -53,3 +62,59 @@ test('extreme percentages clamp to the level bounds', () => {
assert.equal(percentToZoomLevel(1), -9)
assert.equal(percentToZoomLevel(1_000_000), 9)
})
test('installZoomReassertOnWindowEvents wires show and restore', () => {
const handlers = new Map()
const win = {
isDestroyed: () => false,
on(event, listener) {
handlers.set(event, listener)
}
}
let calls = 0
installZoomReassertOnWindowEvents(win, () => {
calls += 1
})
assert.deepEqual([...handlers.keys()], [...ZOOM_REASSERT_WINDOW_EVENTS])
handlers.get('show')()
handlers.get('restore')()
assert.equal(calls, 2)
})
test('installZoomReassertOnWindowEvents skips destroyed windows', () => {
const handlers = new Map()
let destroyed = false
const win = {
isDestroyed: () => destroyed,
on(event, listener) {
handlers.set(event, listener)
}
}
let calls = 0
installZoomReassertOnWindowEvents(win, () => {
calls += 1
})
destroyed = true
handlers.get('show')()
assert.equal(calls, 0)
})
// Zoom-wiring contract: chat windows keep global UI zoom, the pet overlay
// opts out. Tested via the extracted config — no source-text regex.
test('chat windows opt into zoom', () => {
assert.deepEqual(zoomWiringForWindowKind('chat'), { zoom: true })
})
test('pet overlay opts out of zoom', () => {
assert.deepEqual(zoomWiringForWindowKind('petOverlay'), { zoom: false })
})
test('unknown window kinds default to chat (zoom enabled)', () => {
assert.deepEqual(zoomWiringForWindowKind('unknown'), { zoom: true })
assert.deepEqual(zoomWiringForWindowKind(undefined), { zoom: true })
})

View File

@@ -31,3 +31,40 @@ export function percentToZoomLevel(percent) {
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
}
// Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized
// and restored. Re-apply the persisted level on these lifecycle transitions.
export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore']
export function installZoomReassertOnWindowEvents(win, reassert) {
if (!win?.on) {
return
}
for (const event of ZOOM_REASSERT_WINDOW_EVENTS) {
win.on(event, () => {
if (win.isDestroyed?.()) {
return
}
reassert()
})
}
}
/**
* Zoom-wiring decision per window kind. Chat windows (main + session) keep
* global UI zoom; the pet overlay opts out because it sizes its own OS window
* to the sprite and inheriting zoom would crop it.
*
* Extracted so the "pet opts out, everything else opts in" contract is
* unit-testable without booting a BrowserWindow or reading source.
*/
export const ZOOM_WINDOW_CONFIG = {
chat: { zoom: true },
petOverlay: { zoom: false }
} as const
export function zoomWiringForWindowKind(kind) {
return ZOOM_WINDOW_CONFIG[kind] ?? ZOOM_WINDOW_CONFIG.chat
}

View File

@@ -114,6 +114,9 @@ export default [
}
},
{
ignores: ['*.config.*']
files: ['**/*.test.tsx'],
rules: {
'no-restricted-globals': ['warn', 'document']
}
}
]

View File

@@ -18,7 +18,7 @@
"profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
"build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
"postbuild": "node scripts/assert-dist-built.mjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs",
@@ -37,14 +37,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/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",
"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",

View File

@@ -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`.

View File

@@ -1,4 +1,3 @@
'use strict'
/**
* before-pack.mjs — electron-builder beforePack hook.
*

View File

@@ -19,6 +19,7 @@ import {
readdirSync,
rmSync
} from 'node:fs'
import { spawnSync } from 'node:child_process'
import { isMain } from './utils.mjs'
const here = dirname(fileURLToPath(import.meta.url))
@@ -91,11 +92,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
@@ -117,13 +113,45 @@ export function stageNodePty({ platform = process.platform, arch = process.arch
cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name))
}
}
} 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).
// Some installs won't have this at all if prebuild-install succeeded.
const buildReleaseDir = join(srcRoot, 'build/Release')
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
// If neither a prebuild nor build/Release produced a .node binary for this
// target, run electron-rebuild to compile one from source. This happens on
// CI (npm ci --ignore-scripts skips postinstall) and on platforms where
// node-pty doesn't publish prebuilds (e.g. linux-x64).
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) {
console.log(
`[stage-native-deps] no prebuilt or compiled native binary for ${platform}-${arch}; ` +
`running electron-rebuild to compile from source...`
)
const result = spawnSync(
process.execPath,
['../../node_modules/.bin/electron-rebuild', '-f', '-w', 'node-pty'],
{ 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.
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
}
console.log(`[stage-native-deps] staged node-pty (${platform}-${arch}) -> ${destRoot}`)

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.`
)
}

View File

@@ -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

View File

@@ -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()
})

View File

@@ -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')

View File

@@ -97,6 +97,7 @@ function fakeDesktop() {
})),
onBootProgress: vi.fn(() => () => undefined),
onBackendExit: vi.fn(() => () => undefined),
onConnectionApplied: vi.fn(() => () => undefined),
onPowerResume: vi.fn(() => () => undefined),
onWindowStateChanged: vi.fn(() => () => undefined),
touchBackend: vi.fn(async () => undefined),

View File

@@ -23,6 +23,7 @@ import {
setPrimaryGateway,
touchSecondaryGateways
} from '@/store/gateway'
import { $gatewaySwitching, wipeSessionListsForGatewaySwitch } from '@/store/gateway-switch'
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
@@ -130,7 +131,7 @@ export function useGatewayBoot({
}
const attemptReconnect = async () => {
if (cancelled || reconnecting || gatewayOpen()) {
if (cancelled || reconnecting || gatewayOpen() || $gatewaySwitching.get()) {
return
}
@@ -181,7 +182,7 @@ export function useGatewayBoot({
} finally {
reconnecting = false
if (!cancelled && !gatewayOpen()) {
if (!cancelled && !gatewayOpen() && !$gatewaySwitching.get()) {
if (reconnectAttempt >= RECONNECT_ESCALATE_AFTER && !escalated) {
escalated = true
failDesktopBoot(translateNow('boot.errors.gatewayConnectionLost'))
@@ -193,7 +194,7 @@ export function useGatewayBoot({
}
function scheduleReconnect() {
if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen()) {
if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen() || $gatewaySwitching.get()) {
return
}
@@ -207,7 +208,7 @@ export function useGatewayBoot({
}
const reconnectNow = () => {
if (cancelled || !bootCompleted) {
if (cancelled || !bootCompleted || $gatewaySwitching.get()) {
return
}
@@ -221,7 +222,97 @@ export function useGatewayBoot({
}
}
const offBootProgress = desktop.onBootProgress(payload => applyDesktopBootProgress(payload))
// Adopt the profile the primary (window) backend booted as, so same-profile
// resumes are no-op swaps and reconnects target the right backend.
// Best-effort: a missing preference means "default". Shared by boot + soft
// switch.
async function adoptPrimaryProfile() {
try {
const pref = await desktop.profile?.get?.()
const profileKey = (pref?.profile ?? '').trim() || 'default'
$activeGatewayProfile.set(profileKey)
setPrimaryGateway(gateway, profileKey)
void ensureGatewayForProfile(profileKey)
} catch {
$activeGatewayProfile.set('default')
}
}
// Seed the working dir from the backend default on a fresh view (nothing
// open yet). Shared by boot + soft switch.
async function seedDefaultCwd() {
await ensureDefaultWorkspaceCwd()
const remoteDefault = await desktopDefaultCwd().catch(() => null)
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
setCurrentCwd(remoteDefault.cwd)
setCurrentBranch(remoteDefault.branch || '')
}
}
// Soft gateway-mode apply: main tore down the primary without reloading.
// Wipe session lists so skeletons retrigger, then re-dial in place.
const softSwitch = async () => {
if (cancelled) {
return
}
$gatewaySwitching.set(true)
clearReconnectTimer()
reconnectAttempt = 0
escalated = false
reauthNotified = false
wipeSessionListsForGatewaySwitch()
try {
gateway.close()
closeSecondaryGateways()
const conn = await desktop.getConnection()
if (cancelled) {
return
}
publish(conn)
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(wsUrl)
if (cancelled) {
return
}
await adoptPrimaryProfile()
await seedDefaultCwd()
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
await callbacksRef.current.refreshSessions().catch(() => undefined)
completeDesktopBoot()
bootCompleted = true
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : String(err)
failDesktopBoot(message)
notifyError(err, translateNow('boot.errors.desktopBootFailed'))
setSessionsLoading(false)
}
} finally {
$gatewaySwitching.set(false)
}
}
const offBootProgress = desktop.onBootProgress(payload => {
// Soft switch / post-boot startHermes re-emits progress — ignore so the
// cold-boot CONNECTING overlay stays down. Errors still surface.
if ($gatewaySwitching.get() || bootCompleted) {
if (payload.error) {
applyDesktopBootProgress(payload)
}
return
}
applyDesktopBootProgress(payload)
})
void desktop
.getBootProgress()
.then(snapshot => applyDesktopBootProgress(snapshot))
@@ -258,7 +349,7 @@ export function useGatewayBoot({
if (bootCompleted) {
completeDesktopBoot()
}
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
} else if (bootCompleted && !$gatewaySwitching.get() && (st === 'closed' || st === 'error')) {
// The socket dropped after a healthy boot (typically sleep/wake). Try
// to bring it back instead of leaving the composer stuck disabled.
scheduleReconnect()
@@ -270,6 +361,7 @@ export function useGatewayBoot({
// Wake signals: power resume (macOS/Windows), network coming back, and the
// window regaining focus/visibility. Each nudges an immediate reconnect.
const offPowerResume = desktop.onPowerResume?.(() => reconnectNow())
const offConnectionApplied = desktop.onConnectionApplied?.(() => void softSwitch())
const onOnline = () => reconnectNow()
@@ -319,6 +411,10 @@ export function useGatewayBoot({
})
const offExit = desktop.onBackendExit(() => {
if ($gatewaySwitching.get()) {
return
}
if ($desktopBoot.get().running || $desktopBoot.get().visible) {
failDesktopBoot(translateNow('boot.errors.backgroundExitedDuringStartup'))
}
@@ -357,31 +453,14 @@ export function useGatewayBoot({
return
}
// Record which profile the primary (window) backend booted as, so
// same-profile resumes are no-op swaps and any reconnect targets the
// right backend. Best-effort: a missing preference means "default".
try {
const pref = await desktop.profile?.get?.()
const profileKey = (pref?.profile ?? '').trim() || 'default'
$activeGatewayProfile.set(profileKey)
setPrimaryGateway(gateway, profileKey)
void ensureGatewayForProfile(profileKey)
} catch {
$activeGatewayProfile.set('default')
}
await adoptPrimaryProfile()
setDesktopBootStep({
phase: 'renderer.config',
message: translateNow('boot.steps.loadingSettings'),
progress: 97
})
await ensureDefaultWorkspaceCwd()
const remoteDefault = await desktopDefaultCwd().catch(() => null)
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
setCurrentCwd(remoteDefault.cwd)
setCurrentBranch(remoteDefault.branch || '')
}
await seedDefaultCwd()
await callbacksRef.current.refreshHermesConfig()
@@ -411,6 +490,7 @@ export function useGatewayBoot({
return () => {
cancelled = true
$gatewaySwitching.set(false)
clearReconnectTimer()
clearInterval(keepaliveTimer)
offWorking()
@@ -419,6 +499,7 @@ export function useGatewayBoot({
window.removeEventListener('online', onOnline)
document.removeEventListener('visibilitychange', onVisible)
offPowerResume?.()
offConnectionApplied?.()
offState()
offEvent()
offExit()

View File

@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -53,12 +53,16 @@ afterEach(() => {
async function renderMessaging() {
const { MessagingView } = await import('./index')
let result: ReturnType<typeof render>
await act(async () => {
result = render(
<MemoryRouter>
<MessagingView />
</MemoryRouter>
)
})
return render(
<MemoryRouter>
<MessagingView />
</MemoryRouter>
)
return result!
}
describe('MessagingView setup-guide link', () => {
@@ -82,7 +86,9 @@ describe('MessagingView setup-guide link', () => {
await renderMessaging()
const link = await screen.findByText('Open setup guide')
fireEvent.click(link)
await act(async () => {
fireEvent.click(link)
})
await waitFor(() => expect(openExternalLink).toHaveBeenCalledWith(docsUrl))
})

View File

@@ -62,9 +62,9 @@ describe('usePreviewRouting', () => {
$currentCwd.set('/work')
$messages.set([])
$previewTarget.set(null)
window.localStorage.clear()
clearSessionPreviewRegistry()
handleEvent = () => undefined
window.localStorage.clear()
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
@@ -78,9 +78,9 @@ describe('usePreviewRouting', () => {
cleanup()
$messages.set([])
$previewTarget.set(null)
window.localStorage.clear()
clearSessionPreviewRegistry()
vi.restoreAllMocks()
clearSessionPreviewRegistry()
window.localStorage.clear()
})
it('opens the active session preview from the registry', async () => {

View File

@@ -1,4 +1,4 @@
import { cleanup, render, waitFor } from '@testing-library/react'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -43,6 +43,18 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
}
}
// Wrap render() in act() so the Harness's useEffect (onReady callback +
// internal state from usePromptActions) flushes synchronously instead of
// spilling async state updates outside act().
async function actRender(ui: React.ReactElement) {
let result: ReturnType<typeof render>
await act(async () => {
result = render(ui)
})
return result!
}
interface HarnessHandle {
cancelRun: () => Promise<void>
restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
@@ -118,10 +130,14 @@ function Harness({
useEffect(() => {
onReady({
cancelRun: actions.cancelRun,
restoreToMessage: actions.restoreToMessage,
steerPrompt: actions.steerPrompt,
submitText: actions.submitText
cancelRun: (...args: Parameters<typeof actions.cancelRun>) =>
act(async () => actions.cancelRun(...args)) as Promise<void>,
restoreToMessage: (...args: Parameters<typeof actions.restoreToMessage>) =>
act(async () => actions.restoreToMessage(...args)) as Promise<void>,
steerPrompt: (...args: Parameters<typeof actions.steerPrompt>) =>
act(async () => actions.steerPrompt(...args)) as Promise<boolean>,
submitText: (...args: Parameters<typeof actions.submitText>) =>
act(async () => actions.submitText(...args)) as Promise<boolean>
})
}, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady])
@@ -146,7 +162,9 @@ describe('usePromptActions /title', () => {
)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />
)
await handle!.submitText('/title New title')
@@ -170,7 +188,9 @@ describe('usePromptActions /title', () => {
)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />
)
await handle!.submitText('/title Fresh chat')
@@ -188,7 +208,9 @@ describe('usePromptActions /title', () => {
const requestGateway = vi.fn(async () => ({ output: 'Title: Old title' }) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />
)
await handle!.submitText('/title')
@@ -208,7 +230,9 @@ describe('usePromptActions /title', () => {
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />
)
await handle!.submitText('/title way too long title')
@@ -247,7 +271,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => states.push(s)}
@@ -297,7 +321,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => states.push(s)}
@@ -335,7 +359,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@@ -365,7 +389,7 @@ describe('usePromptActions desktop slash pickers', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
@@ -385,7 +409,7 @@ describe('usePromptActions desktop slash pickers', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
openMemoryGraph={openMemoryGraph}
@@ -418,7 +442,7 @@ describe('usePromptActions desktop slash pickers', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@@ -448,7 +472,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
@@ -481,7 +505,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
busyRef={busyRef}
onReady={h => (handle = h)}
@@ -523,7 +547,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
@@ -567,7 +591,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
@@ -589,7 +613,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
busyRef={busyRef}
onReady={h => (handle = h)}
@@ -615,7 +639,7 @@ describe('usePromptActions steerPrompt', () => {
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@@ -634,7 +658,7 @@ describe('usePromptActions steerPrompt', () => {
const requestGateway = vi.fn(async () => ({ status: 'rejected' }) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@@ -647,7 +671,7 @@ describe('usePromptActions steerPrompt', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@@ -658,7 +682,7 @@ describe('usePromptActions steerPrompt', () => {
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@@ -690,7 +714,7 @@ describe('usePromptActions restoreToMessage', () => {
let lastState: Record<string, unknown> = {}
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => (lastState = state)}
@@ -725,7 +749,7 @@ describe('usePromptActions restoreToMessage', () => {
let lastState: Record<string, unknown> = {}
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => (lastState = state)}
@@ -758,7 +782,7 @@ describe('usePromptActions restoreToMessage', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
@@ -786,7 +810,7 @@ describe('usePromptActions restoreToMessage', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@@ -801,7 +825,7 @@ describe('usePromptActions restoreToMessage', () => {
let lastState: Record<string, unknown> = {}
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => (lastState = state)}
@@ -875,7 +899,7 @@ describe('usePromptActions file attachment sync', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@@ -929,7 +953,7 @@ describe('usePromptActions file attachment sync', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@@ -958,7 +982,7 @@ describe('usePromptActions file attachment sync', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@@ -1018,7 +1042,7 @@ describe('usePromptActions eager-upload races', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
await waitFor(() => expect(handle).not.toBeNull())
@@ -1076,7 +1100,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
@@ -1119,7 +1143,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
@@ -1152,7 +1176,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => states.push(s)}
@@ -1182,7 +1206,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
@@ -1226,7 +1250,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
@@ -1239,7 +1263,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(ok).toBe(true)
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[2]?.params).toEqual({
session_id: RECOVERED_SESSION_ID,
text: 'message during starved loop'
@@ -1266,7 +1290,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
activeSessionId={null}
createBackendSessionForSend={createBackendSessionForSend}
@@ -1297,7 +1321,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
activeSessionId={null}
createBackendSessionForSend={createBackendSessionForSend}
@@ -1353,7 +1377,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => {
{ id: 'file:devis', kind: 'file', label: 'DEVIS_signed.pdf', path: '/Users/mahmoud/Downloads/DEVIS_signed.pdf' }
])
render(
await actRender(
<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@@ -1383,7 +1407,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => {
$composerAttachments.set([{ id: 'file:x', kind: 'file', label: 'x.pdf', path: '/abs/x.pdf' }])
render(
await actRender(
<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@@ -1407,7 +1431,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => {
}
])
render(
await actRender(
<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)

View File

@@ -3,19 +3,25 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
import { Tip } from '@/components/ui/tooltip'
import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global'
import { useI18n } from '@/i18n'
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
import { ExternalLink } from '@/lib/external-link'
import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { previewGatewaySwitch } from '@/store/gateway-switch'
import { notify, notifyError } from '@/store/notifications'
import { $profiles, refreshActiveProfile } from '@/store/profile'
import { CONTROL_TEXT } from './constants'
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
type Mode = 'local' | 'remote'
type Mode = 'local' | 'remote' | 'cloud'
type AuthMode = 'oauth' | 'token'
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
// Hermes Cloud discovery lifecycle for the cloud-mode panel.
type CloudDiscoverStatus = 'idle' | 'loading' | 'done' | 'error'
interface GatewaySettingsState {
envOverride: boolean
@@ -25,6 +31,7 @@ interface GatewaySettingsState {
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
cloudOrg: string
}
const EMPTY_STATE: GatewaySettingsState = {
@@ -34,13 +41,15 @@ const EMPTY_STATE: GatewaySettingsState = {
remoteOauthConnected: false,
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: ''
remoteUrl: '',
cloudOrg: ''
}
function ModeCard({
active,
description,
disabled,
hint,
icon: Icon,
onSelect,
title
@@ -48,6 +57,7 @@ function ModeCard({
active: boolean
description: string
disabled?: boolean
hint?: string
icon: typeof Monitor
onSelect: () => void
title: string
@@ -55,22 +65,29 @@ function ModeCard({
return (
<button
className={cn(
'rounded-xl border p-3 text-left transition',
active
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)',
disabled && 'cursor-not-allowed opacity-50'
'flex h-full min-h-0 w-full flex-col p-3 text-left disabled:cursor-not-allowed disabled:opacity-50',
selectableCardClass({ active, prominent: true })
)}
disabled={disabled}
onClick={onSelect}
type="button"
>
<div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Icon className="size-4 text-muted-foreground" />
<span>{title}</span>
{active ? <Check className="ml-auto size-4 text-primary" /> : null}
<div className="flex items-center gap-1.5">
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 text-[length:var(--conversation-text-font-size)] font-medium">{title}</span>
{hint ? (
<Tip label={hint}>
<span
className="grid size-3.5 shrink-0 cursor-help place-items-center text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)"
onClick={event => event.stopPropagation()}
>
<HelpCircle className="size-3.5" />
</span>
</Tip>
) : null}
{active ? <Check className="ml-auto size-3.5 shrink-0 text-primary" /> : null}
</div>
<p className="mt-1.5 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
<p className="mt-1.5 flex-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
</button>
@@ -100,11 +117,38 @@ export function GatewaySettings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [previewingSwitch, setPreviewingSwitch] = useState(false)
const [signingIn, setSigningIn] = useState(false)
const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE)
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
// --- Hermes Cloud (cloud mode) state ---
// One portal session powers discovery + the silent per-agent cascade. These
// track the cloud panel: whether we're signed in, the discovered agent list,
// and which agent is mid-connect.
const [cloudSignedIn, setCloudSignedIn] = useState(false)
const [cloudSigningIn, setCloudSigningIn] = useState(false)
const [cloudAgents, setCloudAgents] = useState<DesktopCloudAgent[]>([])
const [cloudDiscover, setCloudDiscover] = useState<CloudDiscoverStatus>('idle')
const [cloudConnectingId, setCloudConnectingId] = useState<null | string>(null)
// Multi-org users: when discovery returns needsOrgSelection, we hold the org
// list here and show a picker. `cloudOrg` is the chosen org slug/id (null =
// not yet chosen / single-org user).
const [cloudOrgs, setCloudOrgs] = useState<DesktopCloudOrg[]>([])
const [cloudOrg, setCloudOrgState] = useState<null | string>(null)
// Mirror the selected org into a ref so connect reads the CURRENT value, not a
// value captured in a stale render closure. discoverCloud() resolves the org
// asynchronously (from the NAS response) and a user can click Connect in the
// same render tick; without the ref, connectCloudAgent could persist a null
// org even though discovery just resolved one. Always set both together.
const cloudOrgRef = useRef<null | string>(null)
const setCloudOrg = (value: null | string) => {
cloudOrgRef.current = value
setCloudOrgState(value)
}
// Connection scope: null = the global/default connection (the original
// behavior); a profile name = that profile's per-profile remote override, so
// each profile can point at its own backend.
@@ -163,6 +207,22 @@ export function GatewaySettings() {
// OAuth login button or the session-token entry box. The effective auth mode
// prefers a fresh probe result over the saved value.
const trimmedUrl = state.remoteUrl.trim()
// The dashboardUrl of the currently-connected cloud instance (the saved
// cloud connection's remoteUrl), normalized for comparison against each
// discovered agent's dashboardUrl so we can highlight the active one and hide
// its Connect button. Empty unless the saved connection is a cloud one.
// The saved cloud URL was stored via the main-side normalizeRemoteBaseUrl
// (which lowercases the host through URL.toString()), but a discovered agent's
// dashboardUrl arrives raw from NAS — so normalize both sides the same way
// (trim, drop trailing slash, lowercase) or a host-casing difference would
// silently break the connected-highlight.
const normalizeCloudUrl = (url: string) => url.trim().replace(/\/+$/, '').toLowerCase()
const connectedCloudUrl = state.mode === 'cloud' ? normalizeCloudUrl(state.remoteUrl) : ''
const isConnectedAgent = (agent: DesktopCloudAgent) =>
Boolean(connectedCloudUrl && agent.dashboardUrl && normalizeCloudUrl(agent.dashboardUrl) === connectedCloudUrl)
useEffect(() => {
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
setProbeStatus('idle')
@@ -379,6 +439,234 @@ export function GatewaySettings() {
}
}
// --- Hermes Cloud handlers ---
// Pull the discovered agent list over the shared portal session. Tolerant of
// a lapsed session: a needsCloudLogin error flips us back to signed-out.
// `org` scopes discovery for multi-org users; when discovery comes back with
// needsOrgSelection we surface the org list and show a picker instead.
const discoverCloud = async (org?: string) => {
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudDiscover('loading')
try {
const result = await desktop.cloud.discover(org)
if ('needsOrgSelection' in result && result.needsOrgSelection) {
// Multi-org user with no org chosen yet: show the picker. Don't clear a
// previously-chosen org list on a refresh.
setCloudOrgs(result.orgs)
setCloudAgents([])
setCloudDiscover('done')
return
}
// Single org (or org now chosen): we have agents.
setCloudAgents('agents' in result ? result.agents : [])
// Record the org AUTHORITATIVELY from the response (NAS echoes the org the
// list was scoped to), falling back to the org we requested. This is what
// gets persisted on connect, so it must be set even on single-membership
// auto-resolve where no picker ran and no `org` arg was passed.
const resolvedOrgRef = 'org' in result && result.org ? (result.org.slug ?? result.org.id) : null
if (resolvedOrgRef) {
setCloudOrg(resolvedOrgRef)
} else if (org) {
setCloudOrg(org)
}
setCloudDiscover('done')
} catch (err) {
setCloudAgents([])
setCloudDiscover('error')
// A lapsed/absent portal session means we're effectively signed out.
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
setCloudSignedIn(false)
}
notifyError(err, g.cloudDiscoverFailed)
}
}
// User picked an org from the multi-org picker: remember it and re-run
// discovery scoped to it.
const selectCloudOrg = (org: DesktopCloudOrg) => {
const ref = org.slug ?? org.id
setCloudOrg(ref)
void discoverCloud(ref)
}
// "Change org": clear the selected org and re-discover with no org arg. A
// multi-org user gets NAS's 409 → the picker; a single-org user auto-resolves
// back to their one org. Also clear the agent list so the current org's
// agents don't linger under the picker while discovery re-runs.
const changeCloudOrg = () => {
setCloudOrg(null)
setCloudAgents([])
void discoverCloud()
}
// On entering cloud mode (or scope change), read the portal session status and
// auto-discover when already signed in, so the picker is populated on open.
useEffect(() => {
if (state.mode !== 'cloud') {
return
}
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
let cancelled = false
desktop.cloud
.status()
.then(status => {
if (cancelled) {
return
}
setCloudSignedIn(status.signedIn)
if (status.signedIn) {
// Restore the persisted org (if any) so we reopen straight into that
// org's agent list instead of the picker; discoverCloud(org) also
// records it as the selected org. Empty → normal discovery (single-org
// resolves automatically; multi-org shows the picker).
const savedOrg = state.cloudOrg || ''
if (savedOrg) {
setCloudOrg(savedOrg)
}
void discoverCloud(savedOrg || undefined)
} else {
setCloudAgents([])
setCloudOrgs([])
setCloudOrg(null)
setCloudDiscover('idle')
}
})
.catch(() => {
if (!cancelled) {
setCloudSignedIn(false)
}
})
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload on mode/scope change only
}, [state.mode, scope])
const cloudSignIn = async () => {
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudSigningIn(true)
try {
const result = await desktop.cloud.login()
setCloudSignedIn(result.signedIn)
if (result.signedIn) {
await discoverCloud()
}
} catch (err) {
notifyError(err, g.cloudSignInFailed)
} finally {
setCloudSigningIn(false)
}
}
const cloudSignOut = async () => {
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudSigningIn(true)
try {
await desktop.cloud.logout()
setCloudSignedIn(false)
setCloudAgents([])
setCloudOrgs([])
setCloudOrg(null)
setCloudDiscover('idle')
notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage })
} catch (err) {
notifyError(err, g.signOutFailed)
} finally {
setCloudSigningIn(false)
}
}
// Select a discovered agent: drive the silent per-agent cascade (no second
// prompt — the shared portal session auto-approves), then persist a cloud-mode
// connection pointed at its dashboardUrl and apply it (soft-reconnects in place).
const connectCloudAgent = async (agent: DesktopCloudAgent) => {
if (!agent.dashboardUrl) {
return
}
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudConnectingId(agent.id)
try {
const result = await desktop.cloud.agentSignIn(agent.dashboardUrl)
if (!result.connected) {
notify({
kind: 'warning',
title: t.boot.failure.signInIncompleteTitle,
message: t.boot.failure.signInIncompleteMessage
})
return
}
// Persist a cloud-mode connection (remote-shaped, oauth) and soft-reconnect.
// Include the selected org so Settings reopens into the same org + instance.
// Read the REF (not the cloudOrg state) so a just-resolved org from
// discovery in this same render tick is captured, not a stale null.
const next = await desktop.applyConnectionConfig({
mode: 'cloud',
profile: scope ?? undefined,
remoteAuthMode: 'oauth',
remoteUrl: agent.dashboardUrl,
cloudOrg: cloudOrgRef.current ?? undefined
})
setState(next)
notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) })
} catch (err) {
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
setCloudSignedIn(false)
}
notifyError(err, g.cloudConnectFailed)
} finally {
setCloudConnectingId(null)
}
}
const testRemote = async () => {
if (!canUseRemote) {
notify({
@@ -465,131 +753,306 @@ export function GatewaySettings() {
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
disabled={state.envOverride}
icon={Monitor}
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title={g.localTitle}
/>
<ModeCard
active={state.mode === 'remote'}
description={g.remoteDesc}
disabled={state.envOverride}
icon={Globe}
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
title={g.remoteTitle}
/>
<div className="mb-5 grid gap-2">
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
{g.modeTitle}
</div>
<div className="grid auto-rows-fr grid-cols-1 gap-2 min-[42rem]:grid-cols-3">
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
disabled={state.envOverride}
icon={Monitor}
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title={g.localTitle}
/>
<ModeCard
active={state.mode === 'cloud'}
description={g.cloudDesc}
disabled={state.envOverride}
icon={Cloud}
onSelect={() => setState(current => ({ ...current, mode: 'cloud' }))}
title={g.cloudTitle}
/>
<ModeCard
active={state.mode === 'remote'}
description={g.remoteDesc}
disabled={state.envOverride}
hint={g.remoteAuthHint}
icon={Globe}
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
title={g.remoteTitle}
/>
</div>
</div>
<div className="mt-5 grid gap-1">
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
placeholder="https://gateway.example.com/hermes"
value={state.remoteUrl}
/>
}
description={g.remoteUrlDesc}
title={g.remoteUrlTitle}
/>
{state.mode === 'remote' && probeStatus === 'probing' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-4 animate-spin" />
{g.probing}
</div>
) : null}
{state.mode === 'remote' && probeStatus === 'error' ? (
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
{g.probeError}
</div>
) : null}
{/* OAuth / password gateways: present a sign-in button + connection status. */}
{state.mode === 'remote' && authResolved && authMode === 'oauth' ? (
{/* Hermes Cloud panel: one portal sign-in, then a discovered-agent picker
whose selection drives the silent per-agent cascade + a cloud
connection. Replaces the URL/token form while in cloud mode. */}
{state.mode === 'cloud' && !state.envOverride ? (
<div className="mt-5 grid gap-1">
<ListRow
action={
oauthConnected ? (
cloudSignedIn ? (
<div className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" /> {g.signedIn}
<Check className="size-3" /> {g.cloudSignedIn}
</Pill>
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
{signingIn ? <Loader2 className="animate-spin" /> : null}
<Button disabled={cloudSigningIn} onClick={() => void cloudSignOut()} variant="outline">
{cloudSigningIn ? <Loader2 className="animate-spin" /> : null}
{g.signOut}
</Button>
</div>
) : (
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
{signingIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
<Button disabled={cloudSigningIn} onClick={() => void cloudSignIn()}>
{cloudSigningIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{g.cloudSignIn}
</Button>
)
}
description={
oauthConnected
? isPasswordProvider
? g.authSignedInPassword
: g.authSignedInOauth
: isPasswordProvider
? g.authNeedsPassword
: g.authNeedsOauth(providerLabel)
}
title={g.authTitle}
description={cloudSignedIn ? g.cloudSignedInDesc : g.cloudNeedsSignIn}
title={g.cloudSignInTitle}
/>
) : null}
{/* Session-token gateways: keep the existing token entry box. */}
{state.mode === 'remote' && authResolved && authMode === 'token' ? (
{cloudSignedIn ? (
cloudOrgs.length > 0 && !cloudOrg ? (
// Multi-org user who hasn't picked an org yet: show the org picker
// instead of the agent list. Selecting one re-runs discovery
// scoped to it.
<div className="mt-3">
<div className="mb-2 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
{g.cloudOrgPickerTitle}
</div>
<div className="grid gap-1">
{cloudOrgs.map(orgEntry => (
<ListRow
action={
<Button onClick={() => selectCloudOrg(orgEntry)} size="sm">
{g.cloudOrgSelect}
</Button>
}
description={g.cloudOrgRole(orgEntry.role)}
key={orgEntry.id}
title={orgEntry.name}
/>
))}
</div>
</div>
) : (
<div className="mt-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
{g.cloudAgentsTitle}
</div>
<div className="flex items-center gap-2">
{cloudOrg ? (
// Let the user switch orgs. Gating on cloudOrgs.length would
// hide this after a restore-open (which discovers straight
// into the saved org and never populates the org list). So
// show it whenever an org is selected: clicking clears the
// org and re-runs discovery with no org arg — a multi-org
// user gets the picker (NAS 409), a single-org user simply
// auto-resolves back to their one org (harmless).
<Button onClick={() => changeCloudOrg()} size="sm" variant="text">
{g.cloudOrgChange}
</Button>
) : null}
<Button
disabled={cloudDiscover === 'loading'}
onClick={() => void discoverCloud(cloudOrg ?? undefined)}
size="sm"
variant="text"
>
{cloudDiscover === 'loading' ? <Loader2 className="animate-spin" /> : <RefreshCw />}
{g.cloudRefresh}
</Button>
</div>
</div>
{cloudDiscover === 'loading' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-4 animate-spin" />
{g.cloudLoadingAgents}
</div>
) : cloudAgents.length === 0 ? (
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>
{g.cloudNoAgents.before}
<ExternalLink href="https://portal.nousresearch.com/agents" showExternalIcon={false}>
{g.cloudNoAgents.linkText}
</ExternalLink>
{g.cloudNoAgents.after}
</span>
</div>
) : (
<div className="grid gap-1">
{cloudAgents.map(agent => {
const connected = isConnectedAgent(agent)
return (
<div
className={cn('rounded-md px-2', connected && 'bg-primary/5 ring-1 ring-primary/25')}
key={agent.id}
>
<ListRow
action={
connected ? (
<Pill tone="primary">
<Check className="mr-1 inline size-3" />
{g.cloudConnectedPill}
</Pill>
) : (
<Button
disabled={!agent.dashboardUrl || cloudConnectingId !== null}
onClick={() => void connectCloudAgent(agent)}
size="sm"
>
{cloudConnectingId === agent.id ? <Loader2 className="animate-spin" /> : null}
{agent.dashboardUrl
? cloudConnectingId === agent.id
? g.cloudConnecting
: g.cloudConnect
: g.cloudAgentProvisioning}
</Button>
)
}
description={g.cloudStatusLabel(agent.dashboardGatewayState)}
title={agent.name}
/>
</div>
)
})}
</div>
)}
</div>
)
) : null}
</div>
) : null}
{state.mode === 'remote' && !state.envOverride ? (
<div className="mt-5 grid gap-1">
<ListRow
action={
<Input
autoComplete="off"
className={cn('h-8 font-mono', CONTROL_TEXT)}
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={
state.remoteTokenSet ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) : g.pasteSessionToken
}
type="password"
value={remoteToken}
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
placeholder="https://gateway.example.com/hermes"
value={state.remoteUrl}
/>
}
description={g.tokenDesc}
title={g.tokenTitle}
description={g.remoteUrlDesc}
title={g.remoteUrlTitle}
/>
) : null}
</div>
{state.mode === 'remote' && probeStatus === 'probing' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-4 animate-spin" />
{g.probing}
</div>
) : null}
{state.mode === 'remote' && probeStatus === 'error' ? (
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
{g.probeError}
</div>
) : null}
{/* OAuth / password gateways: present a sign-in button + connection status. */}
{state.mode === 'remote' && authResolved && authMode === 'oauth' ? (
<ListRow
action={
oauthConnected ? (
<div className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" /> {g.signedIn}
</Pill>
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
{signingIn ? <Loader2 className="animate-spin" /> : null}
{g.signOut}
</Button>
</div>
) : (
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
{signingIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
</Button>
)
}
description={
oauthConnected
? isPasswordProvider
? g.authSignedInPassword
: g.authSignedInOauth
: isPasswordProvider
? g.authNeedsPassword
: g.authNeedsOauth(providerLabel)
}
title={g.authTitle}
/>
) : null}
{/* Session-token gateways: keep the existing token entry box. */}
{state.mode === 'remote' && authResolved && authMode === 'token' ? (
<ListRow
action={
<Input
autoComplete="off"
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={
state.remoteTokenSet
? g.existingToken(state.remoteTokenPreview ?? g.savedToken)
: g.pasteSessionToken
}
type="password"
value={remoteToken}
/>
}
description={g.tokenDesc}
title={g.tokenTitle}
/>
) : null}
</div>
) : null}
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
size="sm"
variant="text"
>
{testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</div>
{/* Test/Save apply to local + remote. Cloud connects via the agent picker
above (which applies a cloud connection on select), so its only
bottom-row action would be redundant — hidden in cloud mode. */}
{state.mode !== 'cloud' ? (
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
{state.mode === 'remote' ? (
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
size="sm"
variant="text"
>
{testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
) : null}
<Button
disabled={state.envOverride || saving}
onClick={() => void save(false)}
size="sm"
variant="textStrong"
>
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</div>
) : null}
<div className="mt-6 grid gap-1">
<ListRow
@@ -602,6 +1065,26 @@ export function GatewaySettings() {
description={g.diagnosticsDesc}
title={g.diagnostics}
/>
{import.meta.env.DEV ? (
<ListRow
action={
<Button
disabled={previewingSwitch}
onClick={() => {
setPreviewingSwitch(true)
void previewGatewaySwitch().finally(() => setPreviewingSwitch(false))
}}
size="sm"
variant="textStrong"
>
{previewingSwitch ? <Loader2 className="animate-spin" /> : null}
Preview soft switch
</Button>
}
description="Wipe session lists so sidebar skeletons retrigger — no real backend teardown."
title="Dev · soft switch"
/>
) : null}
</div>
</SettingsContent>
)

View File

@@ -1,3 +1,4 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -32,7 +33,8 @@ vi.mock('@/hermes', () => ({
saveMoaModels: (body: unknown) => saveMoaModels(body),
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
saveHermesConfig: (config: unknown) => saveHermesConfig(config),
setApiRequestProfile: () => {}
}))
vi.mock('@/store/onboarding', () => ({
@@ -71,8 +73,13 @@ afterEach(() => {
async function renderModelSettings() {
const { ModelSettings } = await import('./model-settings')
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<ModelSettings />)
return render(
<QueryClientProvider client={client}>
<ModelSettings />
</QueryClientProvider>
)
}
describe('ModelSettings', () => {

View File

@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MemoryProviderConfig } from '@/types/hermes'
@@ -97,7 +97,12 @@ afterEach(() => {
async function renderPanel(provider = 'hindsight') {
const { ProviderConfigPanel } = await import('./provider-config-panel')
return render(<ProviderConfigPanel provider={provider} />)
let result: ReturnType<typeof render>
await act(async () => {
result = render(<ProviderConfigPanel provider={provider} />)
})
return result!
}
describe('ProviderConfigPanel', () => {
@@ -115,9 +120,13 @@ describe('ProviderConfigPanel', () => {
await renderPanel()
expect(await screen.findByLabelText('API URL')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
})
expect(screen.queryByLabelText('API URL')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
})
expect(await screen.findByLabelText('API URL')).toBeTruthy()
})
@@ -125,9 +134,11 @@ describe('ProviderConfigPanel', () => {
await renderPanel()
const apiUrl = await screen.findByLabelText('API URL')
fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } })
fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await act(async () => {
fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } })
fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
})
await waitFor(() =>
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', {

View File

@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -73,8 +73,12 @@ afterEach(() => {
async function renderProvidersSettings() {
const { ProvidersSettings } = await import('./providers-settings')
let result: ReturnType<typeof render>
await act(async () => {
result = render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />)
})
return render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />)
return result!
}
describe('ProvidersSettings', () => {
@@ -82,7 +86,9 @@ describe('ProvidersSettings', () => {
await renderProvidersSettings()
const remove = await screen.findByRole('button', { name: 'Remove Nous Portal' })
fireEvent.click(remove)
await act(async () => {
fireEvent.click(remove)
})
await waitFor(() => expect(disconnectOAuthProvider).toHaveBeenCalledWith('nous'))
expect(listOAuthProviders).toHaveBeenCalledTimes(2)
@@ -91,7 +97,9 @@ describe('ProvidersSettings', () => {
it('keeps provider selection separate from account removal', async () => {
await renderProvidersSettings()
fireEvent.click(await screen.findByText('Nous Portal'))
await act(async () => {
fireEvent.click(await screen.findByText('Nous Portal'))
})
expect(startManualProviderOAuth).toHaveBeenCalledWith('nous')
expect(disconnectOAuthProvider).not.toHaveBeenCalled()
@@ -132,7 +140,9 @@ describe('ProvidersSettings', () => {
listOAuthProviders.mockResolvedValue({ providers: [] })
const { ProvidersSettings } = await import('./providers-settings')
render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />)
await act(async () => {
render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />)
})
expect(await screen.findByText('WidgetAI')).toBeTruthy()
})
@@ -158,14 +168,18 @@ describe('ProvidersSettings', () => {
// Typing narrows the list to matching providers only.
const search = screen.getByPlaceholderText('Search providers…')
fireEvent.change(search, { target: { value: 'mid' } })
await act(async () => {
fireEvent.change(search, { target: { value: 'mid' } })
})
await waitFor(() => expect(screen.queryByText('Acme')).toBeNull())
expect(screen.getByText('Middle')).toBeTruthy()
expect(screen.queryByText('Zebra')).toBeNull()
// A non-matching query shows the empty-state copy.
fireEvent.change(search, { target: { value: 'nonesuch-xyz' } })
await act(async () => {
fireEvent.change(search, { target: { value: 'nonesuch-xyz' } })
})
expect(await screen.findByText('No providers match your search.')).toBeTruthy()
})
})

View File

@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, findByText, fireEvent, render } from '@testing-library/react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { DropdownMenu, DropdownMenuContent } from '@/components/ui/dropdown-menu'
@@ -39,7 +39,8 @@ afterEach(() => {
function renderPanel(onSelectModel = vi.fn()) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
const content = render(
<QueryClientProvider client={client}>
<DropdownMenu open>
<DropdownMenuContent>
@@ -49,15 +50,15 @@ function renderPanel(onSelectModel = vi.fn()) {
</QueryClientProvider>
)
return onSelectModel
return { onSelectModel, content }
}
describe('ModelMenuPanel MoA presets', () => {
it('selecting a MoA preset switches PERSISTENTLY via onSelectModel (not the one-shot dispatch)', async () => {
const onSelectModel = renderPanel()
const { content, onSelectModel } = renderPanel()
// moaOptions is async (useQuery) — wait for the preset row to mount.
const row = await findByText(document.body, 'MoA: BeastMode')
const row = await content.findByText('MoA: BeastMode')
fireEvent.click(row)
// #54670: must route through the persistent model-switch path
@@ -69,30 +70,35 @@ describe('ModelMenuPanel MoA presets', () => {
it('shows the check on the preset that matches the current moa selection', async () => {
$currentProvider.set('moa')
$currentModel.set('BeastMode')
renderPanel()
const { content } = renderPanel()
const row = await findByText(document.body, 'MoA: BeastMode')
const row = await content.findByText('MoA: BeastMode')
// The check codicon renders as a sibling within the same row item.
const item = row.closest('[role="menuitem"]') ?? row.parentElement
expect(item?.querySelector('.codicon-check')).not.toBeNull()
})
it('keeps the virtual moa provider out of the main model groups (presets section only)', async () => {
renderPanel()
const { content } = renderPanel()
await findByText(document.body, 'MoA: BeastMode')
await content.findByText('MoA: BeastMode')
// The provider group header would read "Mixture of Agents"; the presets
// section header reads "MoA presets". Only the latter should exist.
// Radix DropdownMenu portals its content to document.body, so assert
// against the body (not content.container) to see the rendered items.
// eslint-disable-next-line no-restricted-globals
expect(document.body.textContent).toContain('MoA presets')
// eslint-disable-next-line no-restricted-globals
expect(document.body.textContent).not.toContain('Mixture of Agents')
})
it('renders presets from the catalog even before a session exists', async () => {
$activeSessionId.set('')
const onSelectModel = renderPanel()
const { onSelectModel, content } = renderPanel()
const row = await findByText(document.body, 'MoA: BeastMode')
const row = await content.findByText('MoA: BeastMode')
fireEvent.click(row)
// Pre-session picks are UI state shipped on the next session.create — the

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -48,9 +48,11 @@ function toolset(overrides: Record<string, unknown> = {}) {
}
}
function renderSkills() {
return import('./index').then(({ SkillsView }) =>
render(
async function renderSkills() {
const { SkillsView } = await import('./index')
let result: ReturnType<typeof render>
await act(async () => {
result = render(
// SkillsView reads skills/toolsets via useQuery, so it needs a provider.
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
@@ -58,7 +60,9 @@ function renderSkills() {
</MemoryRouter>
</QueryClientProvider>
)
)
})
return result!
}
beforeEach(() => {
@@ -83,7 +87,9 @@ describe('SkillsView toolset management', () => {
const sw = await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
expect(sw.getAttribute('aria-checked')).toBe('true')
fireEvent.click(sw)
await act(async () => {
fireEvent.click(sw)
})
await waitFor(() => expect(toggleToolset).toHaveBeenCalledWith('web', false))
})

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