Compare commits

...

296 Commits

Author SHA1 Message Date
ethernet
daa88feddd fix(shared): add missing 'fix' script alias
apps/shared had lint:fix but not the 'fix' alias that other workspaces
have. The js-tests check job runs 'npm run fix' as a second step, so
this workspace was failing with 'Missing script: fix'.
2026-07-10 20:07:34 -04:00
ethernet
78f6e78e72 fix(web): resolve all eslint errors, downgrade react-hooks v7 to warnings
- Fix no-useless-escape in i18n/ko.ts and i18n/uk.ts (remove backslash
  escapes inside single-quoted strings)
- Add eslint-disable-next-line for no-control-regex in pty-mobile-input.ts
  (terminal data legitimately contains control characters)
- Configure react-refresh/only-export-components with allowConstantExport
  so context providers that export hooks don't trigger the rule
- Downgrade react-hooks v7 rules (set-state-in-effect, refs,
  preserve-manual-memoization, static-components) from error to warn —
  these are real concerns but the existing code uses common patterns
  (data loading on mount, ref-as-instance-var) that need careful refactoring
2026-07-10 20:02:11 -04:00
ethernet
e0ed9dea7c feat(fmt): add "npm run fix" in root 2026-07-10 20:02:11 -04:00
ethernet
7a0aae1427 ci: add desktop autofix-on-merge with two-job security split 2026-07-10 19:22:28 -04:00
ethernet
212f4bb2e2 ci: add ci-reviewed label gate for CI-sensitive files 2026-07-10 19:22:28 -04:00
ethernet
92d3c39e5a ci: add eslint lint matrix to js-tests.yml
Add a 'lint' job to the JS tests workflow that runs 'eslint --fix'
across all discovered npm workspaces (same matrix as the check job).
Fixable issues auto-correct and don't block; eslint exits non-zero
only when un-fixable errors remain.

Also fix duplicate 'needs: workspaces' in the check job.
2026-07-10 19:22:27 -04:00
ethernet
47dcc0c9d0 refactor(lint): hoist shared eslint + prettier config to root 2026-07-10 19:22:27 -04:00
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
teknium1
d2e64fcb89 fix(cli): widen --yolo env guarantee to the _prepare_agent_startup chokepoint + AUTHOR_MAP
The salvaged fix sets HERMES_YOLO_MODE in main()'s dispatch path before
_prepare_agent_startup(); this follow-up also sets it inside
_prepare_agent_startup() itself so every launcher that triggers plugin/tool
discovery (incl. the Termux fast-CLI path) gets the same ordering guarantee
before tools.approval freezes _YOLO_MODE_FROZEN (#60328).
2026-07-09 16:58:37 -07:00
chenkun
501616e8e6 fix(cli): set HERMES_YOLO_MODE before plugin discovery at startup 2026-07-09 16:58:37 -07:00
Adolanium
1f57ed2a53 fix(export): escape tool-call name in HTML session export
The HTML session export interpolated the tool-call name into the page
without escaping, while every sibling field went through _escape_html. A
tool-call name is attacker-influenced, so a prompt-injected model can emit
a name containing HTML that executes when the export is opened in a browser.

Escape the tool-call name like the other fields.
2026-07-09 16:46:07 -07:00
joaomarcos
a23d5073fb fix(agent): stop switch_model from pairing new provider with stale base_url
switch_model() unconditionally set agent.provider but only set
agent.base_url when the resolved value was truthy. When a real
provider change resolved an empty base_url (e.g. minimax after
copilot), the agent ended up with provider="minimax" but
base_url still pointing at api.githubcopilot.com. That incoherent
pair then got snapshotted into agent._primary_runtime, so it kept
re-applying on every subsequent turn via restore_primary_runtime()
until the process restarted.

try_activate_fallback() and _swap_credential() were audited and
confirmed unaffected: both always derive base_url from an actually
constructed client, never from a possibly-empty resolver hint.

Fix: when base_url is empty AND the provider is genuinely changing,
raise ValueError instead of silently keeping the old provider's URL.
This routes through switch_model()'s existing snapshot/rollback
path, and callers (tui_gateway/server.py's _apply_model_switch)
already catch and surface a clean "switch failed, staying on X"
message. Re-selecting the SAME provider with an empty base_url
(credential-only refresh) still keeps the current URL, unchanged.

Fixes #47828

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:36:06 -07:00
Teknium
fe25806a6b fix(config): retain last-known-good config when config.yaml fails to parse (#60591)
Port from openai/codex#31188: a parse failure in a policy-bearing config
file must not silently replace the effective policy with an empty/default
one. Codex's load_exec_policy_with_warning replaced the whole exec policy
with Policy::empty() when a .rules file failed to parse, silently dropping
managed prompt/forbidden rules; the fix preserves the managed policy while
still warning.

Hermes had the same bug shape in load_config(): a YAML parse error made
_load_config_impl() fall through to DEFAULT_CONFIG, dropping every user
override — including approvals.deny rules, which are documented to block
commands even under --yolo. In a long-running gateway, a user mid-editing
config.yaml into broken YAML silently disarmed their own deny rules on the
next load.

Now, when the process has a last successfully loaded config for that path
(_LAST_EXPANDED_CONFIG_BY_PATH), a parse failure keeps serving it (cached
under the corrupt file's signature so the broken file isn't re-parsed) and
the warning says edits are being ignored until the YAML is fixed. Fresh
processes with no last-known-good keep the existing DEFAULT_CONFIG
fallback and warning.

E2E-verified: deny rule 'curl*evil.com*' still blocks after mid-process
corruption; fixed file reloads normally; fresh-process fallback unchanged.
2026-07-09 16:36:03 -07:00
brooklyn!
8e3f9537db Merge pull request #61649 from NousResearch/bb/kanban-worker-headless
fix(kanban): headless workers, live-retry diagnostics, and re-queue respawn
2026-07-09 16:40:09 -05:00
Brooklyn Nicholson
b06e2f846c fix(kanban): no-TTY gate in _wants_tui_early — the actual worker-crash fix
The earlier fix gated _resolve_use_tui, but the EARLY launcher
(_wants_tui_early) decides TUI from display.interface before cmd_chat
runs — so a `display.interface: tui` default still booted the Ink UI for
headless spawns (kanban workers), whose no-TTY bail-out exits 0 →
"protocol violation". Gate the early resolver on a real TTY: headless
stdio never boots the TUI regardless of config; explicit --tui still does.
2026-07-09 16:13:59 -05:00
Brooklyn Nicholson
77db9d6bf3 fix(kanban): explicit re-queue bypasses the recent-success respawn guard
Dragging a task done→ready did nothing: the respawn guard saw a run that
completed within the success window and deferred forever, unable to tell a
deliberate operator re-run from a status flap. Now a re-queue event
(status change, promote, unblock, reclaim) AFTER the completion bypasses
the recent_success guard, so an explicit done→ready runs again.
2026-07-09 16:13:59 -05:00
Brooklyn Nicholson
aea570db4e fix(kanban): clear failure/crash diagnostics while a retry is in flight
A retried task (→ running) kept showing "crashed Nx": the in-flight run
has no outcome yet, so the trailing crash scan skipped it and kept
counting the prior streak, and the consecutive_failures counter lingers.
Exempt `running` from both repeated_failures and repeated_crashes so a
fresh attempt clears the banner until it itself resolves (re-fires if the
new run also fails).
2026-07-09 16:13:59 -05:00
Brooklyn Nicholson
e87c495dc2 fix(kanban): spawn workers headless — TUI can never eat a worker run
An inherited HERMES_TUI=1 or a `display.interface: tui` config default sent
kanban workers into the Ink TUI, whose no-TTY bail-out exits 0 without doing
the task — every attempt ended in "protocol violation". Two layers:

- _default_spawn pins `--cli` (highest-precedence interface flag) and strips
  HERMES_TUI from the child env (covers older builds on PATH).
- _resolve_use_tui gates ambient TUI prefs (env/config) behind a real TTY;
  an explicit --tui still wins so the informative bail-out stays reachable.
2026-07-09 16:13:59 -05:00
Brooklyn Nicholson
5829fe1378 fix(kanban): failure diagnostics exempt done/archived tasks
A manual done (dashboard/desktop drag) runs complete_task but ends no
run, so a trailing crashed/crashed run history never gains the
'completed' outcome that breaks the repeated_crashes streak — the card
kept flagging "needs attention" forever after being finished.
repeated_failures had the same hole via a stale counter. Terminal
statuses are now exempt from both: done means done; the history stays
on the event log for audit. Regression test included.
2026-07-09 16:13:59 -05:00
Kshitij Kapoor
111544d544 test(codex-picker): raise max_models so count invariant survives catalog growth
Adding the 6 gpt-5.6 slugs to DEFAULT_CODEX_MODELS grew the curated codex
catalog to 11, above the test's max_models=10 cap. That truncated the
picker list to 10 while total_models reported 11, breaking the
total_models == len(models) assertion. The cap was an implicit
change-detector on catalog size; raise it to 100 so the list is never
truncated and the count-consistency invariant stays meaningful as new
gpt-5.x slugs land.
2026-07-10 00:47:51 +05:30
Kshitij Kapoor
4af484d3dd feat(openai): complete gpt-5.6 E2E — codex catalog + 272K compaction auto-raise
Close the remaining end-to-end gaps so the full gpt-5.6 family (sol/
terra/luna + their -pro high-effort modes, 6 slugs) works on every
surface a user can reach them through:

- agent/auxiliary_client.py: the Codex OAuth backend hard-caps context
  at 272K for gpt-5.6 exactly as it does for 5.4/5.5, but the default
  50% compaction trigger would summarize at ~136K and waste half the
  usable window. Extend the existing _is_codex_gpt54_or_gpt55 chokepoint
  (single enforced predicate feeding _compression_threshold_for_model)
  to match gpt-5.6* on the openai-codex route so those sessions get the
  same 0.85 auto-raise. Direct-API/OpenRouter routes (full 1.05M window)
  are unaffected; the historical codex_gpt55_autoraise opt-out still
  applies. The one-time notice banner is model-dynamic and already
  renders the correct slug/cap.
- hermes_cli/config.py, agent/agent_init.py: refresh the autoraise
  comments/notice to mention the 5.6 family.
- hermes_cli/codex_models.py: add the -pro variants to DEFAULT_CODEX_MODELS
  + forward-compat so ChatGPT-OAuth (openai-codex) Pro users see the full
  family in /model, not just the base tiers.

Supersedes the earlier commit's note that 5.6 was intentionally kept out
of the codex catalog: the slugs are confirmed routable (OpenRouter live
+ codex backend), so they belong there like every other codex-capable
gpt-5.x slug.

E2E verified across all 6 slugs: direct-API ctx 1.05M, codex ctx 272K,
pricing reachable from openai + openai-api routes, codex compaction
override 0.85 (and None on direct-API + when opted out), present in
openai-api picker + codex catalog, /model gpt resolves to sol on both
native routes. Guard tests added for the compaction route matrix.
2026-07-10 00:47:51 +05:30
Kshitij Kapoor
5da7b23d6f chore(catalog): regenerate model-catalog.json from source
Rerun scripts/build_model_catalog.py so the manifest is source-generated
rather than hand-edited (the -pro rows from the cherry-picked #61587 were
already correct; only updated_at changes).
2026-07-10 00:47:51 +05:30
rob-maron
7efee32868 pro variants 2026-07-10 00:47:51 +05:30
Kshitij Kapoor
a3828a94d0 feat(openai): cover gpt-5.6 -pro variants (PR #61587 complement)
PR #61587 adds sol-pro/terra-pro/luna-pro to the aggregator lists.
Complete those on the native surfaces the same way this PR completes
the base tiers:

- hermes_cli/models.py: -pro variants in _PROVIDER_MODELS[openai-api].
- agent/usage_pricing.py: alias ("openai", "gpt-5.6-*-pro") onto the
  base-tier PricingEntry rows — the -pro high-effort modes bill at the
  SAME per-token rates (verified against OpenRouter live pricing
  2026-07-09: identical prompt/completion prices for base and -pro);
  they cost more per task by consuming more tokens, not a higher rate.
- Context lengths need no new entries: "gpt-5.6-sol" et al. are
  substrings of their -pro variants and both lookup tables match
  longest-key-first (verified: sol-pro -> 1.05M direct / 272K codex).
- model_switch sort: -pro variants parse as suffix "sol-pro" (rank 1),
  so /model gpt still defaults to base sol — pinned by test.
- Not added to DEFAULT_CODEX_MODELS: only confirmed routable via API/
  OpenRouter so far; codex live discovery will surface them if ChatGPT
  exposes them, same policy as other unconfirmed codex slugs.

Tests: invariant tests extended (pro aliases share base entries, base
sol outranks sol-pro); 191 targeted tests pass.
2026-07-10 00:47:51 +05:30
Kshitij Kapoor
db117af478 review fixes: openai-api pricing route normalization, GA pricing_version, invariant tests
Phase-2 review findings addressed:
- resolve_billing_route: normalize the "openai-api" picker slug to the
  "openai" billing provider — without this the ("openai", <model>)
  _OFFICIAL_DOCS_PRICING keys (incl. every pre-existing gpt-4o/gpt-4.1
  entry, not just 5.6) were unreachable when the provider is openai-api.
- pricing_version: drop the "preview" tag (GA 2026-07-09 at same rates).
- model_metadata comment: dict order is cosmetic — lookups length-sort
  keys at match time; the old comment implied a positional invariant.
- model_switch comment: note "sol" is a series codename, not a generic
  quality word.
- tests/hermes_cli/test_gpt56_registration.py: behavior contracts (no
  list snapshots) — sol > terra/luna > 5.5 sort invariant, pricing
  reachability from both openai and openai-api routes, cache-write
  1.25x / cache-read 0.10x input relation.
2026-07-10 00:47:51 +05:30
Kshitij Kapoor
bd767b574b feat(openai): complete gpt-5.6 registration — context, codex catalog, native picker, pricing
PR #61578 added the GPT-5.6 series (sol/terra/luna) to the two aggregator
surfaces (OPENROUTER_MODELS, _PROVIDER_MODELS[nous]). This completes the
registration on the remaining surfaces per the standard add-model checklist:

- agent/model_metadata.py: DEFAULT_CONTEXT_LENGTHS 1.05M (direct API, same
  as gpt-5.5; more-specific keys precede gpt-5.5 for longest-substring
  matching) + _CODEX_OAUTH_CONTEXT_FALLBACK 272K for all three slugs.
  Without these the direct-API fallback matched generic "gpt-5" = 400K.
- hermes_cli/codex_models.py: DEFAULT_CODEX_MODELS + forward-compat
  templates so ChatGPT-OAuth (openai-codex) pickers surface the series.
- hermes_cli/models.py: _PROVIDER_MODELS[openai-api] (native API picker).
- agent/usage_pricing.py: _OFFICIAL_DOCS_PRICING snapshot — sol 5/30,
  terra 2.50/15, luna 1/6 per 1M in/out; cache read 0.10x input, cache
  write 1.25x input (OpenAI billing change starting with the 5.6 series).
  GA 2026-07-09 at preview rates. Sol Fast mode (Cerebras tier) excluded.
- hermes_cli/model_switch.py: rank "sol" as a flagship suffix so
  /model gpt resolves to gpt-5.6-sol, not alphabetical-first luna.

Verified: registry E2E via real imports (both context tables, codex
forward-compat from a gpt-5.5 template, billing-route lookup for
openai/gpt-5.6-sol -> 5.00/M), alias resolution on openai-codex and
openai-api resolves to gpt-5.6-sol; 183 targeted tests pass
(model_metadata, usage_pricing, codex_models, model_catalog).
2026-07-10 00:47:51 +05:30
rob-maron
3a1a3c7e67 add 5.6 (#61578) 2026-07-09 17:20:40 +00:00
teknium1
daedf4f627 chore: AUTHOR_MAP entry for embwl0x (PR #60810 salvage) 2026-07-09 06:27:04 -07:00
embwl0x
d23990f527 fix(gateway): offload channel directory session scans 2026-07-09 06:27:04 -07:00
kshitij
73b611ad19 Merge pull request #61415 from kshitijk4poor/fix/media-tag-caption
feat(gateway): attach MEDIA: caption to the media bubble on standalone sends
2026-07-09 15:47:39 +05:30
kshitijk4poor
709da844b5 feat(gateway): attach MEDIA: caption to the media bubble on standalone sends
hermes send "MEDIA:/x.png This Caption" now arrives as one native captioned
bubble instead of a separate text message followed by an uncaptioned bubble.

Root cause: the standalone senders (hermes send / cron / send_message tool)
stripped the MEDIA: tag, sent the remaining text as its own message, and
called the media send with no caption -- even though hermes send's help
advertises the captioned form and the bridges/adapters already support a
caption. Signal already captioned correctly.

- tools/send_message_tool.py: new _media_caption_split() chokepoint decides
  caption-vs-separate-body (single captionable non-voice file within the
  platform's message-length cap). Wired into the Telegram, WhatsApp and
  Discord dispatch paths.
- Telegram/WhatsApp/Discord: when the single captioned file is missing, the
  caption text is delivered as a plain message so it is never silently lost.
- Telegram caption send gets a MarkdownV2->plain parse fallback.
- Tests: _media_caption_split unit tests + per-platform caption tests
  (ride, multi-file fallback, voice exclusion, over-limit fallback,
  missing-file text fallback); updated the 3 tests that asserted the old
  text-then-media split.

Closes the gap reported against #58911 (the MEDIA_CAPTION directive PR);
credit to @ferreiraesilva for surfacing the caption behavior.
2026-07-09 15:38:32 +05:30
kshitijk4poor
cbdf87b21f fix: return per-call copies from the skill-discovery cache
Review finding: callers mutate the returned dicts in place —
hermes_cli/web_server.py annotates s['enabled']/s['usage'] on the skills
list — so handing out the cached objects poisons the cache for every
subsequent caller (and is a cross-thread shared-mutable hazard in the
gateway). Return [dict(s) for s in cached] on both hit and miss paths;
warm-path cost is negligible (241x speedup retained on a 300-skill
fixture). Regression test mutates a returned list/dict and asserts the
next cached call is clean.
2026-07-09 15:38:14 +05:30
kshitijk4poor
9e9608ecc3 fix: harden skill-discovery cache signature + TTL
Review findings on the cherry-picked cache (follow-up to #58985):

- The cache key was the max mtime of only the TOP-LEVEL scan dirs.
  Adding/removing a skill inside a category subdir bumps the category
  dir's mtime, NOT the root's, so the cache served a stale list
  indefinitely. Replace with a per-dir signature covering roots +
  immediate children (one scandir per dir; mirrors
  hermes_cli/profiles.py::_count_skills from d5eee133e).
- The disabled-set is config-driven and changes with no filesystem
  mtime bump; fold it into the signature so /skills disable takes
  effect without a restart.
- Platform is part of the signature (gateway processes serve multiple
  platform scopes; scan results are platform-filtered).
- Add a 30s TTL to bound staleness from in-place SKILL.md edits (file
  mtime is invisible to any directory signature).
- The original also keyed dirs off the module-level SKILLS_DIR constant;
  the scan itself uses _skills_dir() (live profile HERMES_HOME) — use
  the same resolution for the signature.

Mutation-verified: nested-add, disabled-set, and TTL tests fail against
the pre-fix cache and pass with it.
2026-07-09 15:38:14 +05:30
nankingjing
5a4249146f perf(skills): cache skill discovery results by directory mtime
_find_all_skills() re-reads every SKILL.md on every call, which is
wasteful when nothing changed between turns. Cache results keyed by
the max mtime across all scanned skill directories — a skill write
touches the directory, bumping mtime past the cached value and
triggering an automatic re-scan.

skip_disabled True/False are cached separately.

This commit is unstacked from #58984; it carries only the skill
discovery cache change.

(cherry picked from commit cd65673a8f)
2026-07-09 15:38:14 +05:30
kshitijk4poor
411d599764 test: fold deepseek-v4 cases into canonical reasoning-floor test, drop duplicate file
The salvaged PR added a standalone test_reasoning_timeouts.py that duplicated
the structure of the existing parametrized test_reasoning_stale_timeout_floor.py.
Fold the v4-flash/v4-pro/-free positive cases and deepseek-chat negative cases
into the canonical parametrized tables and remove the redundant file.
2026-07-09 15:04:14 +05:30
liuhao1024
1e16120603 fix(reasoning): add deepseek-v4-flash and deepseek-v4-pro to reasoning timeout floor
DeepSeek V4 models (deepseek-v4-flash, deepseek-v4-pro) emit
reasoning_content in a separate delta field before final content,
requiring the same 600s stale timeout floor as R1. Without this,
streams hang for 30–50s with APITimeoutError on providers like
opencode-go while direct calls succeed in ~3s.

Fixes #60338.
2026-07-09 15:04:14 +05:30
kshitij
3ed7c8a8da Merge pull request #61388 from kshitijk4poor/fix/dashboard-validate-web-dist
fix(dashboard): validate HERMES_WEB_DIST before startup (#17845 follow-through)
2026-07-09 14:55:44 +05:30
kshitij
cb79518d4f Merge pull request #61385 from kshitijk4poor/fix/dashboard-residual-cron-event-loop-io
fix(dashboard): run residual cron profile I/O off the event loop (#50948 follow-through)
2026-07-09 14:49:12 +05:30
kshitijk4poor
f5bc18f901 fix: write expanded HERMES_WEB_DIST back for web_server's raw read
Phase-2 review finding: the validation branch expanduser()s the path but
web_server.py reads os.environ['HERMES_WEB_DIST'] raw at import — a
'~/dist' value would validate here and still 404 there. Write the
expanded path back before the web_server import. Adds a regression test
asserting the env var holds the expanded path after cmd_dashboard.
2026-07-09 14:48:50 +05:30
kshitijk4poor
e7648d5912 test: restore gemma-3-27b to keep-extra_content coverage
Review finding: PR #40632's branch had silently dropped gemma-3-27b
from the keep-extra_content test loop (part of its Gemma narrowing,
which this salvage reverts). Restore main's original coverage so a
future narrowing back to Gemini-only fails loudly.
2026-07-09 14:35:14 +05:30
kshitijk4poor
63ddd022a2 refactor(salvage): scope #40632 to the two live copy-on-write sites
Trim the salvaged commit to its two still-valid conversions:
- ChatCompletionsTransport.convert_messages (copy-on-write sanitize)
- QwenProfile.prepare_messages (copy-on-write normalize + cache_control)

Dropped from the original PR:
- agent/prompt_caching.py selective-copy: superseded by #57229 which
  already rewrites apply_anthropic_cache_control on current main.
- Gemma extra_content narrowing (_model_consumes_thought_signature
  'gemini or gemma' -> 'gemini' only) + its two tests: unrelated
  behavior change reverting deliberate e8c3ac2f5; belongs in its own
  PR with its own justification if pursued.

Conflict resolution: preserved main's newer timestamp-stripping
(#47868) inside the copy-on-write path.
2026-07-09 14:35:14 +05:30
pedrommaiaa
724ab9098d perf: avoid broad message prep deepcopies
(cherry picked from commit 030746c560)
2026-07-09 14:35:14 +05:30
Tranquil-Flow
4ed910c689 fix(cli): mock systemd preflight in gateway service tests for non-systemd environments (#15187)
Mock _preflight_user_systemd and _select_systemd_scope in
test_systemd_start_refreshes_outdated_unit and
test_systemd_restart_refreshes_outdated_unit. These tests target
unit-file refresh logic, not D-Bus reachability, so the preflight
check was causing spurious UserSystemdUnavailableError on macOS,
WSL, and Docker where systemd is unavailable.

(cherry picked from commit 34113300a1)
2026-07-09 14:35:09 +05:30
kshitijk4poor
d928017742 fix(dashboard): validate HERMES_WEB_DIST before startup
A custom HERMES_WEB_DIST without --skip-build skipped BOTH the web UI
build and any validation: cmd_dashboard fell through the build gate and
started the server against a dist that may not exist, serving 404s with
no obvious cause. This is the same failure mode issue #23817 fixed for
the --skip-build branch — the env-var branch was left unvalidated.

Add the missing else-branch: fail fast with actionable guidance when
HERMES_WEB_DIST has no index.html, proceed (still without building) when
it does.

Credit: @Caelier (#17845) originally proposed dist validation for the
dashboard startup path; the --skip-build half of that PR's scope has
since landed via the #23817 fix, this covers the remaining env-var path
on the rewritten cmd_dashboard surface.
2026-07-09 14:34:01 +05:30
kshitijk4poor
8cfada0df4 test(dashboard): pin cron fire + blueprint handlers off the event loop
Mutation-verified: both tests fail against main's inline-call version and
pass with the threadpool routing.
2026-07-09 14:30:16 +05:30
kshitijk4poor
74609f926c fix(dashboard): run residual cron profile I/O off the event loop
Two async handlers still called the cron profile-walk helpers directly on
the FastAPI event loop after the 49fa04a23/346e5673d threadpool migration:

- POST /api/cron/fire called _find_cron_job_profile() inline — it walks
  every profile and lists its jobs (file I/O per profile), stalling the
  loop before the 202 is returned.
- POST /api/cron/blueprints/instantiate called _call_cron_for_profile()
  inline for create_job.

Route both through the existing _run_cron_dashboard_io threadpool wrapper
like every other cron dashboard endpoint.

Credit: @riceharvest (#50948) originally identified the sync-I/O-in-async-
handlers bug class for the desktop boot endpoints; 49fa04a23, 346e5673d,
7d0ddbb2f, d5eee133e and 24d5bda1e have since fixed most of that PR's scope
via the managed threadpool + PID cache + alias-map surfaces. This covers
the two cron handlers those merges missed.
2026-07-09 14:27:35 +05:30
kshitijk4poor
1d689e1920 fix(caching): use canonical Kimi-family matcher in cache policy
Review finding: the substring check ('kimi' or 'moonshot' in model)
under-matches bare release slugs like k2-thinking that the repo's
canonical _model_name_is_kimi_family matcher (anthropic_adapter.py)
already covers. Reuse it instead of a second ad-hoc matcher; add a
regression test for the bare-slug case.
2026-07-09 14:27:22 +05:30
kshitijk4poor
fbbb8415c3 test(caching): pin Kimi/Moonshot OpenRouter cache policy (#25970)
Adapted from PR #26014's test file to the canonical seam
(tests/run_agent/test_anthropic_prompt_cache_policy.py _make_agent
helper) instead of a new top-level file with sys.path manipulation.
Covers: kimi-k2.6 + moonshot-v1 on OpenRouter (envelope layout),
kimi via Nous Portal, and the non-OpenRouter negative case.
2026-07-09 14:27:22 +05:30
zccyman
750c1310a6 fix(caching): include Kimi/Moonshot in OpenRouter prompt cache policy (#25970)
Kimi/Moonshot models on OpenRouter honour the same envelope-layout
cache_control markers as Claude on OpenRouter, but the policy fell
through to (False, False) — serving ~1% cache hits on 64K-token prompts
and re-billing the full prompt every turn. Observed within-turn
progression with cache enabled: 1% -> 67% -> 84% -> 97%.

(cherry picked from commit 3b857a35e, agent/agent_runtime_helpers.py hunk only;
the original PR #26014 branch also carried unrelated .dev-workflow artifacts
which are intentionally not included)
2026-07-09 14:27:22 +05:30
Kshitij Kapoor
f556edc10d fix(model_metadata): address Phase-2 review findings on probe caches
Structured review (2a/2b/2c) findings, all fixed:

- MAJOR: detect_local_server_type memo was process-lifetime with no
  invalidation, permanently pinning a URL's server type. Now a bounded
  1h TTL ((type, monotonic) tuples) so a backend swap on the same port
  is re-detected. Test covers ollama->lm-studio swap after expiry.

- MAJOR: legacy disk-row compat was one-way. get_cached_context_length
  and _invalidate_cached_context_length now consult the same key-shape
  set {canonical, literal, canonical+slash} in both directions, so an
  old slashed row is found (and cleared) when the runtime passes the
  normalized URL. Tests pin both migration directions.

- MINOR: _localhost_to_ipv4 did whole-string replacement, which could
  corrupt a proxy URL embedding http://localhost in its query. Now a
  scheme-anchored host-only regex; localhost.example.com and embedded
  substrings pass through. Tests added.

- MINOR: _invalidate_cached_context_length now also drops the
  in-memory TTL probe rows for the pair, so a resolution inside the
  TTL window can't re-persist the value just declared stale.

- Test gaps closed: detect-type cache hit + TTL-expiry re-detection,
  ollama-show TTL expiry re-probe, reverse legacy-row lookups.

- Attribution gate: added zhchl@hermes-agent.local -> 8294 (PR #50572
  author) to AUTHOR_MAP; the strict CI grep needs bare non-plus emails
  literal in release.py.

Gates: ruff clean; targeted suites 202 passed / 0 failed; full
tests/agent 5426 passed with 17 failures identical on clean
upstream/main (pre-existing env-dependent anthropic/bedrock/credpool
tests); mypy delta vs base: 0 new errors; live smoke 6/6 PASS.
2026-07-09 14:08:44 +05:30
Kshitij Kapoor
20cb385328 fix(model_metadata): widen localhost->IPv4 rewrite to all sibling probe sites
#37595 fixed the Windows dual-stack IPv6 timeout only inside
detect_local_server_type. The same 2s-per-probe penalty existed at every
other helper that builds a probe URL from base_url. Extract the rewrite
into _localhost_to_ipv4() and apply it at:

- query_ollama_num_ctx
- query_ollama_supports_vision
- _query_ollama_api_show (server_url derivation)
- _query_local_context_length (server root + LM Studio native URL)

Tests cover the helper's URL forms, non-localhost passthrough, and that
the ollama probes actually POST to 127.0.0.1.
2026-07-09 14:08:44 +05:30
Kshitij Kapoor
040e30aa72 perf(model_metadata): cache ollama /api/show probe + normalize context-cache keys
Follow-up hunks completing the probe-cache cluster:

1. _query_ollama_api_show now goes through the existing
   _LOCAL_CTX_PROBE_CACHE (30s TTL, positive-only, namespaced key) —
   it was the one remaining per-resolution POST not covered by the
   #56431-era wrapper. Failures are never memoized so a server that
   comes up mid-startup is re-probed. Idea credit: #42081 (@Morad37),
   reworked to comply with the positive-only rule.

2. Persistent context-cache keys are normalized through
   _context_cache_key (trailing-slash strip) so http://host/v1 and
   http://host/v1/ share one entry; reads and invalidation honor
   legacy un-normalized rows. Idea credit: #37905 (@stevenau21).

Tests: TTL hit collapses to one POST, failure-not-memoized
(mutation-verified: unconditional caching makes it fail), namespace
no-collision vs the sibling probe, slash-variant dedup, legacy-row
read, dual-shape invalidation.
2026-07-09 14:08:44 +05:30
MiniMax-M3
c454d32feb fix(model_tools): honor model.context_length to skip OpenRouter probe on banner
_cli's show_banner() calls _resolve_active_context_length() at every
startup. For non-OpenRouter providers (e.g. minimax-cn, kimi-coding,
custom endpoints) the resolver falls through to step 6 (OpenRouter
live /models fetch), which blocks ~2-3s per CLI launch and adds up
to 7+ minutes when openrouter.ai is unreachable through a proxy that
403s CONNECT (#46620).

Two complementary changes:

1. model_tools.py: read model.context_length from config.yaml and pass
   it as config_context_length to get_model_context_length. The
   step-0 config override short-circuits the entire resolution chain
   including the OpenRouter fetch. No network call is made when the
   user has set the value explicitly.

2. agent/model_metadata.py: replace flat timeout=10 with (5, 10)
   tuple at all five sites (fetch_model_metadata + four endpoint
   probes). urllib3 can otherwise block for 10s per retry stage
   through proxies that 403 CONNECT. The tuple bounds connect at 5s
   while still allowing slow reads.

Complements the in-flight PR #46685 (which adds HERMES_DISABLE_MODEL_METADATA
env var + same timeout tuple change for fetch_model_metadata). This PR
extends the timeout fix to the other four endpoint probes and adds the
config-override path that addresses the slow-but-reachable scenario
where env-var disable is too heavy-handed.

Refs #46620, PR #46685.

(cherry picked from commit e7faa34199)
2026-07-09 14:08:44 +05:30
Rod Boev
9a18a2de12 fix(agent): probe localhost via IPv4 for LM Studio detection
Remaining hunk of the PR-branch fixup commit: the LM Studio first-probe
assertion now expects the IPv4-resolved URL (the code half — applying
the rewrite to `normalized` before deriving lmstudio_url — was folded
into the previous cherry-pick's conflict resolution).

(cherry picked from commit 7d324b0e47)
2026-07-09 14:08:44 +05:30
Rod Boev
91ece5c2fc perf(model-metadata): resolve localhost to IPv4 in detect_local_server_type
On Windows, `localhost` resolves to both ::1 (IPv6) and 127.0.0.1 (IPv4).
httpx tries IPv6 first, hanging 2 sec per probe when the server binds IPv4
only. detect_local_server_type() is called 3+ times during init, each with
a new httpx.Client, compounding to ~14s of dead time.

Replace localhost with 127.0.0.1 inside the function before connecting.
The function is only called for local endpoints (callers guard with
is_local_endpoint()), so IPv6 loopback adds no diagnostic value.

Measured: 19.9s → 4.0s on Windows with a local proxy on 127.0.0.1:8317.
(cherry picked from commit a075d3194b)
2026-07-09 14:08:44 +05:30
uzaylisak
c889941916 fix(model_metadata): cache detect_local_server_type result for process lifetime
Every 5 minutes fetch_endpoint_model_metadata() re-runs the full server-type
waterfall (LM Studio -> Ollama -> llama.cpp -> vLLM), spraying 404s at
endpoints the server never exposes (e.g. /api/v1/models and /api/tags on a
vllm backend).

Add _endpoint_probe_path_cache (base_url -> server type) so the first
successful probe's result is reused for the lifetime of the process.
Subsequent refreshes skip straight to the known-good path.

Fixes #29971.

(cherry picked from commit f3d7a8960a)
2026-07-09 14:08:44 +05:30
kshitijk4poor
6f42bf344c fix(dashboard): harden PTY reconnect race, wedged-connect recovery, IME guard
Follow-up hardening on the salvaged NS-591 mobile-chat reconnect fix, from
review findings:

- Guard the page-resume reconnect against the async socket-open window:
  a connectInFlightRef is set synchronously before the ticket-URL await so
  a visibilitychange/focus fired during that gap (wsRef still null) can't
  spawn a redundant second socket. Threaded through
  shouldReconnectPtyOnPageResume as connectInFlight.
- Recover a socket wedged in WS_CONNECTING (half-open mobile socket after a
  radio handoff — the NS-591 scenario) via a PTY_CONNECTING_TIMEOUT_MS
  force-close so onclose routes into scheduleReconnect. Cleared on
  open/close/effect-cleanup.
- Avoid collapsing legitimate single-letter reduplication ("a a") in the
  mobile duplicate-final-word heuristic (>=2-char guard).
- Extract the 350ms replacement window and 1000ms resume throttle to named
  exported consts; drop the dead WS_CONNECTING term from the resume
  predicate's final expression.

Adds tests for the in-flight guard and the single-letter reduplication case.
2026-07-09 13:50:17 +05:30
Shannon Sands
3e88cae243 fix(dashboard): harden PTY input tracker against escape sequences
Two review fixes on the mobile input normalization path:

- updatePtyInputLine appended the printable payload of escape sequences
  (the '[D' of a left-arrow) to the tracked line, and after any cursor
  movement the flat tracker no longer matched the visual line — the
  DELETE-repeat replacement could then be computed against a stale
  snapshot. Any chunk containing ESC now resets the tracker, disarming
  replacement normalization until a cleanly-tracked line starts.
- Move the SGR mouse-report filter ahead of the blocked-input check so
  scrolling a disconnected terminal doesn't print the reconnect notice.
2026-07-09 13:50:17 +05:30
Shannon Sands
0b2b08d54c fix(dashboard): recover mobile chat reconnect 2026-07-09 13:50:17 +05:30
kshitijk4poor
a4ba8c9640 chore: map poowis2011@hotmail.com → Umi4Life for PR #47377 salvage 2026-07-09 13:40:19 +05:30
Umi4Life
3fe7f6d27a fix: preserve fallback switch notice on successful fallback
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:40:19 +05:30
kshitijk4poor
d54a8f7079 refactor(gateway): funnel HERMES_HOME sync through a single chokepoint
Follow-up to HexLab98's fix. The sync-before-regenerate invariant was
enforced by convention across 6 callsites (~3 idempotent unit-file reads
per command). Consolidate it into the one function every compare/regenerate
path funnels through — systemd_unit_is_current — and drop the now-redundant
callsite pre-syncs in refresh_systemd_unit_if_needed / systemd_start /
systemd_restart / systemd_status.

Kept the systemd_install pre-sync: the --force path bypasses the
is_current gate and calls generate_systemd_unit() directly, so it needs
its own sync to avoid baking /root/.hermes under sudo.

Reworked the two callsite-ordering tests into a chokepoint-invariant guard
(test_is_current_syncs_before_reading_unit) + a delegation test proving
start/restart no longer pre-sync. Both fail if the chokepoint sync is
removed; the pre-existing behavior test still passes.
2026-07-09 13:04:47 +05:30
HexLab98
8f18f6c695 test(gateway): cover sudo system-unit refresh adopting HERMES_HOME 2026-07-09 13:04:47 +05:30
HexLab98
cbf685356d fix(gateway): sync HERMES_HOME before refreshing system systemd units
Under sudo, start/restart refreshed the unit from /root/.hermes before
adopting the unit's pinned home, so TimeoutStopSec and env drifted and
status stayed stuck on "service definition is outdated".
2026-07-09 13:04:47 +05:30
kshitijk4poor
55dbc3ffb5 fix(model_metadata): bound the tools-token estimate cache
Follow-up to the salvaged str(tools) fix. The id()-keyed
_TOOLS_TOKENS_CACHE had no eviction, so a long-lived gateway/desktop
backend could accumulate an unbounded number of stale entries as it
builds transient tool lists. Cap it at 256 with oldest-first eviction
(insertion-ordered dict) and add a regression test asserting the cache
never exceeds the cap.
2026-07-09 12:34:11 +05:30
infinitycrew39
f4d5cfd0fd test(model_metadata): cache tools schema token estimate
Adds a regression test that repeated request-token estimates do not re-serialize the same tool schema list.
2026-07-09 12:34:11 +05:30
infinitycrew39
32a0f9e17a fix(model_metadata): avoid str(tools) token estimate stalls
Estimate tool-schema size without repeatedly stringifying full tool lists, and cache the result per tool snapshot to reduce GIL-heavy work during preflight and compaction.
2026-07-09 12:34:11 +05:30
kshitijk4poor
473407174b test(dashboard): assert server still gates OAuth endpoints without cookie
PR #61281 removed the client-side X-Hermes-Session-Token requirement from the
dashboard OAuth mutation calls so cookie-authenticated hosted/mobile sessions
can start provider logins. That change is safe only because the server still
gates those endpoints (gated_auth_middleware cookie check + _require_token).
The PR's api.test.ts suite mocks fetch and only asserts client behavior, so a
re-break of the gated-mode cookie gate would pass CI unnoticed.

Add gated-mode TestClient tests asserting POST /api/env/reveal and the OAuth
mutation endpoints (disconnect/start/submit/cancel) return 401 with no session
cookie. Mutation-verified: neutering both the middleware gate and _require_token
flips all five to 200.
2026-07-09 12:21:16 +05:30
Shannon Sands
ad8f103048 i18n(dashboard): translate OAuth copy-code strings in all locales
The new oauth.copyCode/copyFailed keys existed only in en.ts, with
optional types and English literal fallbacks in OAuthLoginModal — so
non-English users got English strings on the device-code copy button.

Backfill translations in all 16 non-English locales, refresh the
updated oauth.description/notConnected copy (dashboard Login flow
mention) to match en.ts, make the two keys required in the
Translations interface, and drop the English fallbacks from the modal.
Verified with web tsc --noEmit (required keys enforce locale
completeness), vitest, and a web build.
2026-07-09 12:21:16 +05:30
Shannon Sands
3e24b16f56 fix(dashboard): support mobile OAuth login 2026-07-09 12:21:16 +05:30
brooklyn!
88a58ff135 Merge pull request #61277 from NousResearch/bb/fix-desktop-tsx-electron40
fix(desktop): stop using tsx to boot Electron main in dev
2026-07-08 22:43:59 -05:00
Brooklyn Nicholson
bf913abc2e fix(desktop): stop using tsx to boot Electron main in dev
Electron 40 ships Node 24.15, where tsx's ESM load hook returns null and
crashes with ERR_INVALID_RETURN_PROPERTY_VALUE. Bundle main+preload via
esbuild for `npm run dev` and always load the JS preload from dist/.
2026-07-08 22:40:51 -05:00
Teknium
513dba42e6 chore(models): drop x-ai/grok-4.3 from OpenRouter/Nous curated lists in favor of grok-4.5 (#61097)
grok-4.5 is GA and is now the single curated Grok entry on the
aggregator lists. grok-4.3 is NOT retired upstream — it remains fully
usable by typing the model name (validated against the live catalogs);
this only removes it from the short curated picker snapshots. The
xAI-direct list is models.dev-cache-driven and unaffected.
2026-07-08 20:04:29 -07:00
ethernet
56a8e81d33 cleanup(desktop): npm run fix for fmtting
we should run this as part of merges at some point :)
2026-07-08 16:24:16 -07:00
ethernet
7a65530fa5 fix(js): set @types/node to node 22, what's required in "engines" 2026-07-08 16:24:16 -07:00
ethernet
39d09453f9 feat(desktop): ts-ify everything 2026-07-08 16:24:16 -07:00
brooklyn!
fac85518fc Merge pull request #61147 from NousResearch/bb/desktop-tool-window-merge
feat(desktop): group tool calls across text-less assistant messages
2026-07-08 17:10:24 -05:00
Brooklyn Nicholson
6d65210252 feat(desktop): group tool calls across text-less assistant messages
The model often emits a follow-up batch of tool calls as its own
assistant message with no prose or reasoning. On screen those rows look
like one continuous run, but assistant-ui only groups tool calls within a
single message, so the auto-scrolling tool window never triggered on them
(e.g. two batches of two searches read as 2 + 2, never reaching the
threshold).

Coalesce each settled tool-only assistant message into the preceding
assistant message in the render pipeline so its calls join that message's
tool group. Render-only (never touches the $messages store) and
settle-only (pending messages are skipped) so a live turn is never
merged/un-merged mid-stream; merged results are cached by source identity
so a stable turn yields stable objects with no re-render churn.
2026-07-08 17:08:21 -05:00
Kshitij Kapoor
e0ed5dc9ed refactor: address Phase-2 review findings on /new boundary handoff
- Return the boundary snapshot from
  _launch_session_boundary_memory_flush as a local value instead of
  staging it on self._session_boundary_snapshot. The instance-attr
  handoff could leak (no memory manager configured) or mis-fire a
  stale snapshot on a later /new if an exception hit between staging
  and consumption. A local variable eliminates the class; the helper
  also returns None when no memory manager is configured so
  new_session takes the inline-switch path.
- Drop the now-dead session_id kwarg from commit_memory_session:
  after the redesign no production caller passes it (gateway, TUI,
  compression all use the default), and speculative params are
  rejected per AGENTS.md. The explicit-old-session need is served by
  cli.py's direct engine call + commit_session_boundary_async.
- Drop the dead providers snapshot in commit_session_boundary_async
  (only the emptiness check used it).
- Tests updated accordingly (dead-kwarg test removed, snapshot
  assertion now covered by return-value contract).

Phase-2 gates: 2a tests/cli 1048 passed + 6 memory files 137 passed;
2b programmatic live smoke 0.38ms non-blocking caller, end→switch→sync
ordering verified; 2c structured 4-angle review — no Criticals, these
warnings fixed.
2026-07-09 03:21:54 +05:30
Kshitij Kapoor
d8bc4f242f fix: serialize /new end→switch boundary on the memory manager worker
Deep review of the cherry-picked #16454 found the ad-hoc flush thread
raced new_session()'s inline on_session_switch(reset=True): memory
providers key off internal _session_id state (MemoryManager.on_session_end
takes no session id), so a late off-thread extraction ran against
post-rotation bindings — misattributing the old transcript to the new
session id, double-ingesting the old turn buffer (supermemory), or
double-committing (openviking already async-finalizes in
on_session_switch).

Redesign: new MemoryManager.commit_session_boundary_async queues
on_session_end + on_session_switch as ONE task on the manager's existing
single-worker background executor (the same worker sync_all already
uses). This preserves the strict end→switch ordering providers depend on,
serializes against per-turn syncs FIFO, keeps /new non-blocking, and
degrades to inline (pre-#16454 behavior) when the executor is
unavailable. No ad-hoc threads; no per-provider changes needed.

The context-engine on_session_end half stays synchronous in
_launch_session_boundary_memory_flush (cheap, must land before
reset_session_state rebinds the engine).

Exit durability: _run_cleanup calls the manager's existing
flush_pending(timeout=10) barrier before shutdown, so '/new then quit'
doesn't drop the queued extraction (shutdown_all's own drain is ~5s and
cancels queued tasks). Bounded well inside the 30s exit watchdog.

Tests: ordering invariant with slow (LLM-like) extraction, FIFO
serialization vs sync_all, switch-fires-even-if-end-raises, no-provider
no-op, CLI snapshot handoff + inline-switch fallback, sync engine
boundary, cleanup flush_pending.
2026-07-09 03:21:54 +05:30
Tosko4
2a293319b1 fix: run CLI new-session memory flush off-thread
(cherry picked from commit 3e82a861e6)
2026-07-09 03:21:54 +05:30
kshitijk4poor
449706cb52 chore: add dexhunter to AUTHOR_MAP (PR #60339 salvage) 2026-07-09 02:41:24 +05:30
Dixing Xu
e21ba91221 perf(skills): speed up snapshot prompt builds
Fixes #3356

Build the skills snapshot manifest in one directory walk, avoid importing gateway session context during CLI prompt startup, and reuse direct platform-list matching for snapshot entries.

(cherry picked from commit 1a64c2ed04)
2026-07-09 02:41:24 +05:30
kshitijk4poor
0a01b2087d fix(gateway): harden fallback-chain refresh from review findings
Follow-ups on the #60987 salvage (review pass):
- _refresh_fallback_model: keep last known-good chain on transient
  config.yaml read/parse failure (user mid-edit, torn write) — only a
  successful read that lacks the key clears the chain. Previously a
  refresh error wiped a cached agent's working fallback for the turn.
- Move the cached-agent refresh+apply OUTSIDE the agent-cache lock:
  config.yaml read is disk I/O and the idle-sweep watcher contends on
  that lock (same reasoning as #52197). Per-session turn serialization
  keeps the post-lock apply safe.
- _apply_fallback_chain_to_agent: clear _unavailable_fallback_keys when
  chain content actually changes, so an entry re-configured mid-uptime
  (e.g. credentials added) is retried instead of staying suppressed for
  the cached agent's lifetime; no-op refreshes keep the memo.
- Tests: cwd-independent source pin (Path(__file__) anchor), pin the
  reuse-path apply call, + regression tests for last-known-good, memo
  clear-on-change, memo keep-on-unchanged (mutation-verified).
2026-07-09 02:22:12 +05:30
HexLab98
e721ad89e3 test(gateway): cover fallback_providers reload for live sessions
Pin reload + cached-agent apply helpers for #60955 so a mid-uptime
fallback chain change reaches messaging sessions without a restart.

(cherry picked from commit fafb341035)
2026-07-09 02:22:12 +05:30
HexLab98
be1346cf2a fix(gateway): reload fallback_providers on live agent create/reuse
Gateway froze the fallback chain at process start while cron reloads it
per job, so a chain configured after hermes gateway was running never
reached messaging sessions. Refresh from disk on agent create and when
reusing a cached agent.

Fixes #60955.

(cherry picked from commit b64e7155b2)
2026-07-09 02:22:12 +05:30
kshitijk4poor
74e28f7d10 style(tests): merge import blocks in test_summarize_api_error 2026-07-09 02:01:56 +05:30
kshitijk4poor
1792a3aa72 chore: add gauravsaxena1997 to AUTHOR_MAP (PR #59868 salvage) 2026-07-09 02:01:56 +05:30
gauravsaxena1997
0569a637d0 fix(agent): guard response.text access in _summarize_api_error against httpx.ResponseNotRead
When an API error carries an httpx.Response whose body was consumed via
iter_bytes() during streaming error handling (e.g. GeminiAPIError from
agent/gemini_native_adapter.py), accessing .text raises
httpx.ResponseNotRead. The secondary exception replaced the real,
already-computed provider error (429 free-tier quota guidance) with the
generic 'Attempted to access streaming response content' message on
every turn.

Guard the .text access so it degrades to an empty snippet and falls
through to the str(error) fallback, which carries the full original
message. Mirrors the existing guards in
agent/error_classifier.py::_extract_error_body() and
agent/gemini_native_adapter.py::gemini_http_error().

Fixes #59769

Salvaged from PR #59868 (guard + regression test); the unrelated
desktop Ctrl-C fix bundled in that PR was intentionally dropped and is
triaged separately.
2026-07-09 02:01:56 +05:30
kshitijk4poor
31e39dec84 test: expect compact_rows in the read-only status-count fake
Rebase reconciliation with #60884: _count_status_active_sessions (from
#58238) now passes compact_rows=True (this branch's #47437 projection),
so the fake asserts both.
2026-07-09 01:36:46 +05:30
kshitijk4poor
7a25017a00 test: accept compact_rows kwarg in tui_gateway session fakes
tui_gateway session.list/most_recent now pass compact_rows=True
(#47437 salvage); the keyword-only fake signatures in
test_tui_gateway_server.py rejected the new kwarg and CI slice 6/8
failed with TypeError. Other list_sessions_rich fakes use **kwargs and
are unaffected.
2026-07-09 01:36:46 +05:30
kshitijk4poor
7570bb4ad4 fix: offset-without-limit was silently ignored in get_messages
Review finding: get_messages(offset=N) with no limit dropped the OFFSET
entirely. SQLite requires a LIMIT clause for OFFSET, so emit LIMIT -1
(unbounded) when only offset is given. Regression test added.
2026-07-09 01:36:46 +05:30
kshitijk4poor
4f220fc88b fix: follow-up for salvaged #60347/#43653/#47437
- derive the compact_rows projection from SCHEMA_SQL (parse once, cache)
  instead of a hardcoded column list: the original #47437 list was cut
  against a June schema and silently dropped session_key/chat_id/chat_type/
  thread_id/display_name/origin_json/expiry_finalized/git_branch/
  git_repo_root/compression_failure_* — including desktop sidebar fields.
  Schema-derived means declaratively reconciled new columns are included
  automatically; only system_prompt is excluded.
- guard test pinning the schema<->projection contract (mutation-verified:
  dropping a column from the projection fails it)
- wire compact_rows=(not full) into /api/sessions and /api/profiles/sessions
  so the SQL projection pairs with the API-level field strip (?full=1 still
  returns complete rows end-to-end)
- pass compact_rows at the remaining hot list callers: /api/status active
  count, _session_latest_descendant fallback, /api/sessions/stats by-source
- thread compact_rows through the compression-tip projection
  (_get_session_rich_row) so projected tips can't reintroduce the blob
- add pagination tests for get_messages (#60347 shipped none): paging order,
  offset-past-end, active-flag interaction; add tip-projection compact test
- AUTHOR_MAP entries for mahdiwafy + CodeForgeNet (plain emails)
2026-07-09 01:36:46 +05:30
CodeForgeNet
22eb1af23a perf(state): add compact_rows to skip system_prompt blob in session list queries
list_sessions_rich and _get_session_rich_row previously used SELECT s.*,
pulling the system_prompt TEXT blob on every row even for dashboard and
picker callers that never display it. On large databases this blob routinely
runs to tens of kilobytes per session, causing unnecessary B-tree I/O.

Add compact_rows=False param to both functions. When True, an explicit
column list omitting system_prompt is substituted for s.* in both the
simple and the recursive-CTE (order_by_last_active) query paths.
Default is False so all existing callers are unaffected.

Update dashboard and session-picker callers in web_server.py and
tui_gateway/server.py to pass compact_rows=True.

Add seven regression tests covering: omission of system_prompt, presence
of all metadata fields, both query paths, _get_session_rich_row, and
backward-compat default.

(cherry picked from commit c470cbd304)
2026-07-09 01:36:46 +05:30
Omar Baradei
4df16c429b Refresh on upstream/main: resolve conflicts (no behavior change)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 1e70eaa49a)
2026-07-09 01:36:46 +05:30
mahdiwafy
0d5549a945 fix(api): add pagination to GET /api/sessions/{id}/messages
The session messages endpoint returned ALL messages in a single
response with no limit/offset. Sessions with 500+ messages produced
1.2-1.6 MB JSON payloads, causing GIL starvation and WebSocket
timeouts on the Desktop client (#60155).

Add optional limit/offset query params to both the API endpoint and
SessionDB.get_messages(). Limit clamped to 500 max per page. Response
now includes a pagination object with limit/offset/returned count.

Backward compatible: callers that omit limit get the old behavior
(all messages).

Closes #60155

(cherry picked from commit d58396b154)
2026-07-09 01:36:46 +05:30
kshitijk4poor
c95cf313c9 test: patch the cached PID probe name in profile-unification status tests
get_status now probes via get_running_pid_cached() (#53511 salvage);
these tests were added on main after that PR was cut and still patched
web_server.get_running_pid, so their fakes were bypassed and CI slice
5/8 failed. Patch the name the handler actually calls.
2026-07-09 01:19:07 +05:30
kshitijk4poor
664878b0ba fix: guard /api/status active count against missing state.db
Review finding: SessionDB(read_only=True) requires the DB file to exist
(its documented contract says callers guard on db_path.exists()); on a
fresh install every /api/status poll paid an OperationalError until the
first session was written. Short-circuit to 0 when state.db is absent.
Tests: fresh-install guard + existing read_only test adjusted.
2026-07-09 01:19:07 +05:30
kshitijk4poor
1e2ad17afd fix: descendant CTE must dedup (UNION) to survive parent-chain cycles
The #39140 CTE used UNION ALL, which recurses forever if a corrupted
parent chain loops (a -> b -> a) — reproduced: query never returns. The
old Python walk was cycle-safe via a seen-set. UNION dedups the working
set and terminates. Regression test added and mutation-verified (UNION
ALL hangs the test, UNION passes).
2026-07-09 01:19:07 +05:30
kshitijk4poor
206c042392 chore: AUTHOR_MAP entries for salvaged contributors (#53511/#53966/#39140) 2026-07-09 01:19:07 +05:30
0-CYBERDYNE-SYSTEMS-0
24d5bda1ef fix(dashboard): run GET /api/sessions session-DB read off the event loop
Flip the handler from async def to sync def so FastAPI executes it in
its threadpool: the SessionDB open + list_sessions_rich query no longer
block the single uvicorn event loop.

Residual hunk from PR #53966 — that PR's get_profiles_sessions flip
already landed via #54523/1bb7b59c5, and its get_status offload is
superseded by #58238's read_only + timeout variant in this branch.

(cherry picked from commit 414c12a40d)
2026-07-09 01:19:07 +05:30
sebastianlutycz
d7e4d94e2f perf(dashboard): recursive-CTE descendant lookup in _session_latest_descendant
_session_latest_descendant fetched EVERY sessions row and built the
parent->children tree in Python on each call. Replace with a recursive
CTE that loads only the target session's descendant branch.

Hand-applied from PR #39140 (the schema-init cache and Rust PTY bridge
parts of that PR are intentionally NOT salvaged here); main's function
signature gained a db parameter since the PR was cut.

(cherry picked from commit 8ed5e54f65)
2026-07-09 01:19:07 +05:30
luyifan
492be28e50 fix(web): bound dashboard action log tails
(cherry picked from commit a4188a3e24)
2026-07-09 01:19:07 +05:30
0disoft
7d0ddbb2ff fix(dashboard): cache gateway PID status probes
(cherry picked from commit a2bbe564ad)
2026-07-09 01:19:07 +05:30
墨綠BG
49fa04a235 🐛 fix(dashboard): use managed cron threadpool
(cherry picked from commit 97fabc289c)
2026-07-09 01:19:07 +05:30
墨綠BG
346e5673de 🐛 fix(dashboard): offload cron profile scans
(cherry picked from commit 1fded709aa)
2026-07-09 01:19:07 +05:30
yoma
9a4341aa90 fix(dashboard): keep status responsive when session db locks
(cherry picked from commit a3454dd15d)
2026-07-09 01:19:07 +05:30
teknium1
4d611ba0c3 chore: AUTHOR_MAP entry for nullptr0807 (PR #60956 salvage) 2026-07-08 12:35:50 -07:00
nullptr0807
d6a275b735 fix(gateway): compact hygiene transcripts in place 2026-07-08 12:35:50 -07:00
Teknium
62ada5175c feat(xai): add grok-4.5 (GA) to model catalog, context lengths, and reasoning-effort allowlist (#60887)
* feat(xai): add grok-4.5 (early access) to catalog, context lengths, and reasoning-effort allowlist

- hermes_cli/models.py: grok-4.5 in _XAI_CURATED_EXTRAS (callable but absent
  from models.dev) and _XAI_STATIC_FALLBACK, so the /model picker and
  validation surface it on both xai and xai-oauth.
- agent/model_metadata.py: context lengths grok-4.5 -> 500K (per model card)
  and grok-build-latest -> 500K (alias); grok-4.5 added to
  _GROK_EFFORT_CAPABLE_PREFIXES.

Verified live against api.x.ai /v1/responses (2026-07-08): effort
low/medium/high accepted (server default: high), "none" rejected,
function calling works, full agent turn with terminal tool succeeded.

* feat(xai): grok-4.5 GA — add aggregator catalog entries, refresh comments

grok-4.5 is now GA: models.dev lists it (500K context, effort
low/medium/high) and both OpenRouter and Nous serve x-ai/grok-4.5.
Add it to the OpenRouter fallback snapshot and the Nous static list,
and update the early-access comments.

* chore: regenerate model-catalog.json for x-ai/grok-4.5
2026-07-08 12:30:47 -07:00
Teknium
aabfedcac0 docs(webhook): complete filters + route-scripts coverage across doc surfaces (#60983)
Follow-up to #60944 (webhook payload filters and route scripts):
- reference/cli-commands.md (en+zh): document the new --script option on
  'hermes webhook subscribe'
- zh-Hans user-guide webhooks.md: mirror the Payload Filters and Script
  Filters/Transforms sections plus the filters/script route properties
  (the salvage shipped English-only docs)
- hermes-agent skill webhooks reference: teach the agent the filters/
  script surface so agent-driven subscriptions can use them
2026-07-08 11:56:24 -07:00
Teknium
76381e2a8e fix(compression): stop compaction thrash — 75% trigger floor under 512K, no summary output cap, reasoning-trace exclusion (#60989)
Sessions on sub-512K-context models were spending most of their wall-clock
re-summarizing: the 50% trigger left too little post-compaction headroom
(the incompressible floor — system prompt, tool schemas, protected tail,
rolling summary — ate most of the reclaimed space), so compaction re-fired
every 1-2 turns. Three compounding defects fixed:

- Threshold floor: models with context windows below 512K now trigger at
  >=75% of the window (raise-only — a higher configured value or per-model
  autoraise like Codex gpt-5.5's 85% always wins). Re-derived on
  update_model() in both directions.
- No max_tokens on the summary call: the summary budget is prompt guidance
  only ("Target ~N tokens"). The wire cap truncated summaries mid-section
  on the Anthropic Messages / NVIDIA NIM paths (thinking models burn the
  cap on reasoning first), yielding truncated or thinking-only summaries
  and compaction loops. Summary token ceiling lowered 12K -> 10K to keep
  the guidance within the intended 1K-10K envelope.
- Reasoning traces excluded end-to-end: inline <think>/<reasoning> blocks
  are now stripped from assistant content before serialization to the
  summarizer, and from the summarizer's own output before the summary is
  stored (previously a thinking summarizer model's trace was persisted in
  _previous_summary and re-fed into every iterative update, compounding
  bloat). Native reasoning fields were already excluded.

Verified E2E with real imports against a temp HERMES_HOME: threshold table
across 64K-1M windows, override interactions (user 0.85 wins, spark 0.70
raised, gpt-5.5 0.85 kept), full compress() round-trip with a thinking
summarizer, and wire-kwargs capture proving no max_tokens is sent.
2026-07-08 11:56:17 -07:00
Teknium
8e734810df fix(desktop): continue the selected stored session instead of minting a new one (#55578) (#60874)
Two client-side halves of the #55578 session split:

1. Submit with a null activeSessionId but a SELECTED stored session now
   resumes that stored session instead of falling straight through to
   createBackendSessionForSend - which silently forked the user's
   conversation into a brand-new session that then got orphan-reaped.
   New-chat drafts (no stored selection) still create sessions as before.

2. prompt.submit recovery now also fires on gateway request timeouts,
   not only 'session not found'. A starved backend loop (the async-
   delegation poller spin) rejects the submit with 'request timed out'
   even though the stored session is fine; previously that surfaced an
   error, left the binding cleared, and set up the split on the next
   send.

Fail-then-pass: 2 new tests fail with production code reverted.
2026-07-08 08:14:31 -07:00
teknium1
ae5e39005b fix(gateway): run webhook route scripts off the event loop + AUTHOR_MAP entry
- run_route_script shells out with subprocess.run (up to 30s timeout); wrap
  the call in asyncio.to_thread so a slow script can't stall every other
  webhook and gateway task on the loop.
- scripts/release.py: map grace@weeb.onl -> evelynburger for the salvaged
  contributor commit.
2026-07-08 08:10:55 -07:00
Grace
0cf2e39c41 feat(gateway): add webhook payload filters 2026-07-08 08:10:55 -07:00
teknium1
75efd73961 fix(gateway): never resurrect ended sessions for delegation completions; /new severs in-flight delegations
Completes the session-binding class on the gateway surface (#55578),
matching the TUI rules:

1. Fail-closed pinning: switch_session() re-opens ended sessions, so
   pinning a completion to a spawning session that has since ENDED
   (user /new, closed rotation) would resurrect a conversation the user
   explicitly ended and inject into it. The injection path now checks
   the pinned row's ended_at first and drops the injection with a
   WARNING when the spawning session is dead or unknown - the result
   stays in the delegation records.

2. /new ends the old conversation's delegations: _handle_reset_command
   calls interrupt_for_session() with the expiring durable session id
   (matching the parent_session_id pin stamped at dispatch) plus the
   routing key as fallback, so a reset can't leave dangling subagents
   whose completions have no live owner.

interrupt_for_session() gains the parent_session_id selector because a
gateway chat's session_key (the platform conversation key) survives a
reset while the session id rotates - key-based matching alone could
never sever a gateway conversation's delegations.
2026-07-08 08:10:28 -07:00
nankingjing
d39c62409b fix(delegate): pin async completion to spawning parent session (#57498)
Background delegate_task completions only carried session_key. When multiple
active sessions shared a routing peer, get_or_create_session could recover the
latest ended_at IS NULL row and inject the subagent result into the wrong
session.

Capture parent_agent.session_id at dispatch time, include it on async-delegation
completion events, and pin gateway routing via switch_session when the
synthetic completion message is handled.

Fixes #57498
2026-07-08 08:10:28 -07:00
loongfay
b848fcbf11 feat(Yuanbao) optimizes media resource processing speed: parallel download 2026-07-08 08:05:45 -07:00
loongfay
63c4100fe6 perf(yuanbao): bounded-concurrency inbound media resolve 2026-07-08 08:05:45 -07:00
teknium1
58e1647b49 test(cli): update FakeCLI._print_exit_summary for new clear_screen kwarg 2026-07-08 07:59:24 -07:00
Tranquil-Flow
efb226b586 fix(cli): preserve chat -q answer by gating exit-summary screen clear (#53009)
In single-query (-q) mode, the assistant's final answer was printed and
then immediately erased by _print_exit_summary() — which unconditionally
called _clear_terminal_on_exit() (ESC[3J ESC[2J ESC[H]). The answer was
present in the session store but invisible in the terminal.

The clear is only needed for interactive TUI teardown (#38928) where
prompt_toolkit chrome must be cleaned up. Add a clear_screen parameter
to _print_exit_summary() (default True, preserving interactive behavior)
and pass False from the single-query call site so the answer stays
visible above the exit summary.

Regression tests cover:
- clear_screen=True (default) calls _clear_terminal_on_exit()
- clear_screen=False skips the clear
- Single-query -q path passes False end-to-end
- Interactive path still clears (preserving #38928)
2026-07-08 07:59:24 -07:00
kyssta-exe
e10c8eba00 fix(whatsapp): use windows_detach_popen_kwargs to prevent console window flash on Windows 2026-07-08 07:49:22 -07:00
teknium1
7e3986ae68 fix(tui): route /compress and /compact past the slash worker to command.dispatch
Ported from #60834 (same author) — pending-input routing so clients that
fail the slash.exec->dispatch fallback still reach the new compress handler.
2026-07-08 07:46:08 -07:00
kyssta-exe
c0fbee990e fix(desktop): register /compress command in TUI gateway dispatch so Desktop can invoke it 2026-07-08 07:46:08 -07:00
teknium1
65372395eb fix(delegation): positive-proof ownership for the post-turn drain
Extends the salvaged session_key filter with the same fail-closed,
compression-chain-aware ownership gate the poller uses (#55578):

- drain_notifications() accepts an owns_event callback; when provided,
  an async-delegation event is consumed ONLY on positive proof of
  ownership, and a broken callback re-queues (never leaks). Bare key
  equality remains for single-session callers (CLI); no filter remains
  legacy behavior.
- The TUI post-turn drain passes _session_owns_notification_event, so
  it can't adopt another session's (or an orphan's) delegation payload,
  while a post-compression session still claims its own pre-compression
  dispatches - the gap bare key equality left open.
2026-07-08 07:39:52 -07:00
Tony Simons
f75f3cd713 fix(delegation): route async delegate_task results back to originating session
The completion event already carries the dispatching session's session_key
(captured at dispatch time in delegate_tool.py:2798), but the delivery
router ignored it — results landed in whatever session was active at
completion time instead of the session that dispatched the subagent.

Changes:
- drain_notifications() in process_registry.py: optional session_key
  filter. Non-matching async_delegation events are re-queued instead of
  consumed, so they remain available for the correct session's drain.
- cli.py process_loop: passes active session_key to drain_notifications()
- tui_gateway/server.py post-turn drain: passes session_key from the
  TUI session dict
- gateway/run.py _build_process_event_source: logs warning when routing
  metadata is unresolvable (previously silent drop)
- Regression tests verifying session-scoped drain filtering

Fixes #58684
2026-07-08 07:39:52 -07:00
waroffchange
5057f03bfd docs(i18n): align translated CONTRIBUTING files with pyproject Python range (3.11-3.13) 2026-07-08 07:09:23 -07:00
waroffchange
ee0b54e16c docs(i18n): align translated CONTRIBUTING files with pyproject Python range (3.11-3.13) 2026-07-08 07:09:23 -07:00
Teknium
b64b802131 feat(models): swap curated Tencent Hy3 Preview for GA tencent/hy3, drop owl-alpha (#60943)
- OPENROUTER_MODELS: remove openrouter/owl-alpha (free) and
  tencent/hy3-preview{,:free}; add tencent/hy3 and tencent/hy3:free
- _PROVIDER_MODELS[nous]: tencent/hy3-preview -> tencent/hy3
- run_agent.py reasoning-prefix list: tencent/hy3-preview -> tencent/hy3
  (prefix match still covers -preview if pinned)
- model_metadata: register hy3 context length (262144) alongside hy3-preview
- regenerate website/static/api/model-catalog.json
- update tokenhub curated-list tests to the new IDs

The tencent-tokenhub direct provider still serves hy3-preview and is
intentionally unchanged.
2026-07-08 07:09:08 -07:00
teknium1
4b27be1114 fix(delegation): fail-closed orphan handling + session-scoped delegation lifecycle
Two invariants layered on the origin-routing commit (#55578):

1. Fail closed on orphaned async-delegation payloads. The poller's
   belongs-elsewhere check handles events owned by another LIVE session,
   but an event whose owner is gone previously fell through and was
   adopted by whichever poller saw it - injecting one chat's delegation
   output into another chat. Delegation completions are now injected
   only into a session that PROVABLY owns them (origin UI id, or
   session-key/lineage match via the compression chain); unowned
   payloads are dropped from injection with a WARNING (the subagent's
   output is already persisted in the delegation records, so nothing is
   lost). The shutdown drain applies the same rule. Non-delegation
   events keep the historical adopt-orphans behavior.

2. A session's in-flight async delegations end with the session.
   _finalize_session now calls interrupt_for_session(): delegations
   commissioned by the closing UI session are interrupted always;
   key-matched delegations only when the TUI owns the session lifecycle,
   so closing a viewer tab on a live gateway session never kills the
   gateway's own background work.
2026-07-08 07:06:15 -07:00
Dan Schnurbusch
aab351bfa6 fix(delegation): route async results to origin session
Carry the live TUI session id with async delegation completion events and prefer the commissioning UI session when desktop pollers share the completion queue. Resolve compressed session keys to their continuation before treating events as orphaned, and capture the live parent agent session id for TUI/ACP dispatch.
2026-07-08 07:06:15 -07:00
fyzanshaik
ac6dd598a4 fix(agent): tag desktop chat sessions as desktop
The desktop app's chat panel reuses tui_gateway as its backend, so every chat session was stamped platform="tui". That made the agent read terminal-specific platform guidance while running in the graphical desktop chat surface.

Resolve the misclassification at its source: tui_gateway now picks platform="desktop" when HERMES_DESKTOP=1 and HERMES_DESKTOP_TERMINAL is unset, and keeps platform="tui" for the embedded terminal pane and standalone TUI. Add a PLATFORM_HINTS["desktop"] entry describing the actual chat surface (full GFM markdown, MEDIA: intercept, inline images). Move the embedded-pane clarifier to the platform-hint resolution site so it appends only to the tui hint under HERMES_DESKTOP_TERMINAL=1. Delete the now-dead desktop-hint block from build_environment_hints() that competed with the platform hint.

Standalone TUI sessions produce byte-identical prompts as before; the new desktop hint and clarifier are assembled once per session in the stable tier, so prompt caching is preserved.
2026-07-08 06:18:18 -07:00
waroffchange
465cbe8bb5 test(tools): add unit tests for skill_gist 2026-07-08 06:17:56 -07:00
teknium1
5413c42f2c chore: add SiteupAgencia to AUTHOR_MAP for #57435 salvage 2026-07-08 06:15:43 -07:00
Lucas Oliveira
98804dbeef fix(tui_gateway): back off notification poller when session is busy
The busy-session branch of _notification_poller_loop re-queued the
completion event and immediately re-polled it with no sleep, spinning
at full speed (100% CPU, ~1100 futex/s of GIL churn) for as long as
the session stayed running. This starved the dashboard asyncio loop:
/api/status went from 0.14s to 3-6s with 10s timeouts.

Sleep 0.25s outside history_lock before re-polling, mirroring the
0.1s back-off already used for foreign-session events.
2026-07-08 06:15:43 -07:00
Teknium
7ecc822e11 fix(cron): stop the ticker from stalling forever on a wedged jobs lock (#60703) (#60855)
Three fixes for the silent post-restart ticker stall:

1. _jobs_lock() bounds its cross-process flock: LOCK_NB polled against a
   30s deadline instead of an unbounded LOCK_EX taken while holding the
   process-wide RLock. On timeout it logs at ERROR and degrades to
   in-process-only locking (the existing fallback path), so a sibling
   process wedged while holding .jobs.lock can no longer freeze every
   cron function - including the ticker's get_due_jobs() and thus the
   heartbeat - forever with zero logging.

2. fire_claim/run_claim freshness checks are bounded on both sides
   (0 <= age < ttl): a claim stamped in the future (clock/TZ skew across
   a restart) was previously fresh forever, making the job permanently
   unfireable and every manual run report 'already being fired'.

3. _execute_job_now distinguishes paused/disabled/missing jobs from a
   genuinely held claim instead of mislabeling them all as 'already
   being fired'.
2026-07-08 05:13:18 -07:00
kshitijk4poor
1192f29450 fix: Z.AI endpoint persist failure must not break URL resolution
Review findings (hermes-pr-review Phase 2, 3-angle):
- _save_auth_store() does real filesystem I/O (mkdir, O_EXCL create, fsync,
  atomic replace) and can raise on disk-full/permissions/lock-timeout. The
  persist ran bare in the success path, so a persist failure aborted
  _resolve_zai_base_url() after detection had already succeeded. Wrap the
  persist in try/except: log a warning and still return the detected URL
  (worst case: next start re-probes).
- Readability: stage the payload in a local detected_endpoint instead of
  writing through the stale pre-lock 'state' dict, which is no longer what
  gets persisted.
2026-07-08 17:33:40 +05:30
kshitijk4poor
6eeed3f1e8 fix: don't flip active_provider when caching Z.AI probe result
_save_provider_state() sets auth_store['active_provider'] as a side effect.
The Z.AI endpoint probe runs from credential-pool env seeding for any user
with a Z.AI key in env — persisting the probe cache must not silently make
zai the active provider. Use _store_provider_state(set_active=False).

Follow-up to PR #41201 salvage.
2026-07-08 17:33:40 +05:30
kshitijk4poor
c75e1d1b87 chore: add veradim to AUTHOR_MAP for PR #41201 salvage 2026-07-08 17:33:40 +05:30
veradim
832c5f9bc9 Fix slow Z.AI startup by caching auto-detected endpoint to disk
(cherry picked from commit 6ed884933a)
2026-07-08 17:33:40 +05:30
giggling-ginger
8eac52054b fix(desktop): keep configured MoA presets in model picker 2026-07-08 07:34:00 +00:00
Ben Barclay
f64e4f4f57 feat(gateway): generic OIDC client-credentials relay provisioning (NAS-free) (#60730)
For air-gapped / self-hosted-IdP deploys with NO Nous Portal, let the gateway
obtain its caller-identity bearer from a generic OAuth2 client_credentials grant
against the operator's own IdP (e.g. Microsoft Entra ID) instead of only
resolve_nous_access_token(). The connector's OIDC tenant resolver reads a claim
(default tid) off that token as the tenant.

- gateway/relay: new canonical _resolve_relay_identity_token() — client_credentials
  when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is set, else Nous Portal
  (unchanged default). Wired into self_provision_relay().
- hermes_cli/gateway_enroll: _resolve_identity_token() delegates to the canonical
  resolver so the enroll CLI and the runtime self-provision path share ONE impl.

Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml
(env override GATEWAY_RELAY_IDP_*). No behaviour change when unset.

Tests: tests/gateway/relay/test_identity_token_resolver.py (6 — mode selection,
request shape, config/env precedence, fail-closed). Relay suite 162 pass.

Validated via the cross-repo gateway<->connector live E2E (provision, managed
self-provision, inbound round-trip, /link) against a connector running the OIDC
tenant resolver with zero NAS config.
2026-07-08 16:55:32 +10:00
teknium1
48788032da fix(tui): derive gateway-owned sources from the Platform enum, not a hardcoded list
The salvaged guard used a hand-maintained frozenset of 14 platform names —
several of which (line, wechat, facebook, imessage, googlechat) aren't
actual Hermes Platform values, while real ones (whatsapp_cloud, feishu,
wecom, dingtalk, qqbot, yuanbao, plugin platforms like irc) were missing.
Resolve the source through gateway.config.Platform instead (built-ins +
registered plugin platforms via _missing_), with an explicit exclusion set
for self-owned/local sources. Adds tests for the guard and both reap paths.
2026-07-07 22:15:36 -07:00
AIalliAI
f5ef7ee9da fix(tui): prevent ws_orphan_reap from ending gateway-originated sessions
Guard _finalize_session's db.end_session() call against gateway-owned
sessions (telegram, bluebubbles, discord, etc.).  The TUI is a viewer
for these sessions, not the lifecycle owner.  Unconditionally ending
them in state.db creates a Groundhog Day routing loop: the gateway's
#54878 self-heal detects the stale entry, recovers to the parent
session, context compression splits back to the reaped child, and the
cycle repeats on every inbound message — causing complete conversational
context amnesia.

Fixes #60609
2026-07-07 22:15:36 -07:00
teknium1
ecc6725855 fix(gateway,cron): reconcile #60612 + #60631 onto one drain surface
Keep #60631's get_running_job_ids() snapshot + _active_cron_job_count()
(import-guarded for minimal test doubles) as the single read path, and
retarget #60612's drain tests at it. Drops the redundant
cron_jobs_in_flight() helper so there is one surface, not two.
2026-07-07 22:15:04 -07:00
joaomarcos
8a573bb6e7 fix(cron): stop interrupted jobs from delivering their pre-kill output
Follow-up to the previous commit on #60432. The status-write guard
(_consume_interrupted_flag, checked right before mark_job_run) closes
the false-success bookkeeping gap, but run_one_job delivers its result
BEFORE that check: delivery happens right after run_job() returns,
mark_job_run happens at the very end. A job whose tool subprocess was
killed mid-flight can still produce a plausible-looking final_response
from the truncated output, and that response would reach the user via
_deliver_result before the interrupted flag was ever consulted --
correct status in jobs.json, wrong message already sent.

Adds _is_interrupted(), a non-destructive peek at the same
_interrupted_job_ids set (_consume_interrupted_flag stays as the
consuming, authoritative check right before the status write -- this
needed a peek instead since the flag has to still be visible there).
Checked right after save_job_output, before the deliver_content
decision: if the run looked successful but was flagged interrupted,
force success=False with an explicit interruption message. This
routes delivery through the existing _summarize_cron_failure_for_delivery
path (the same one a real failure already uses) instead of the raw
final_response, so the user gets an honest "this run was interrupted"
instead of a truncated/misleading result.

Testing: 4 new tests in tests/cron/test_shutdown_interrupt.py --
_is_interrupted peek semantics (false/true/does-not-clear, as opposed
to the consuming _consume_interrupted_flag), and the delivery-gate
test itself, which mocks run_job to return a normal-looking success
with a "plausible final response" while the job is pre-marked
interrupted, and asserts _deliver_result receives the failure summary
("This run was interrupted.") instead, with the summarizer's error
argument confirmed to mention the interruption.

Fail-then-pass: reverted cron/scheduler.py only, the 4 new tests fail
(3 on the missing _is_interrupted attribute, 1 -- the delivery-gate
test -- on _summarize_cron_failure_for_delivery never being called,
i.e. the raw response would have gone out); restored, all 16 tests in
the file pass.

Regression: tests/cron/ (683 tests) + test_cron_active_work_drain.py +
test_gateway_shutdown.py + test_shutdown_cache_cleanup.py -- 11
pre-existing failures (Unix file-permission-bit and path-tilde
assertions that don't apply on this Windows dev box), matching the
same set already established as pre-existing in the prior commit's
regression check. Zero new failures.

Continues #60432
2026-07-07 22:15:04 -07:00
joaomarcos
24e9ed73c2 fix(gateway,cron): make shutdown drain visible to in-flight cron work
Cron jobs run through cron/scheduler.py's own ThreadPoolExecutor via a
standalone AIAgent (run_job/run_one_job), entirely outside
GatewayRunner._running_agents -- the dict _drain_active_agents() and
every other active-work check on that class reads. A gateway shutdown
(/update, /restart, and SIGUSR1 all funnel through the same stop())
could log active_at_start=0 and immediately kill tool subprocesses
while a cron job's terminal command was still running, with no wait
and no indication anything was interrupted.

Real-world impact (from the issue): a scheduled daily briefing cron
job was in flight during /update, its tool subprocess got killed
by the unconditional shutdown cleanup, and the job was never marked
failed -- it simply never completed or delivered, with no error
surfaced anywhere. A repro with a 30-minute `sleep` cron job in flight
during /update reproduced the same pattern: subprocess killed at
+0.22s of drain (active_at_start=0), the job's agent thread continued
in-process and produced a plausible-looking final response from the
truncated tool output, and the scheduler marked the run successful.

Root cause is layered, not a single line:

1. GatewayRunner._drain_active_agents() only waits on _running_agents.
   Cron work was invisible to it, so drain returned instantly whenever
   the only active work was a cron job.
2. Even with visibility, the shutdown's final tool-subprocess kill
   (process_registry.kill_all()) is a global, unconditional sweep with
   no per-job targeting -- a long-running cron job that outlives the
   drain timeout still gets its subprocess killed.
3. cron/scheduler.py had no way to detect that a job's tool subprocess
   was killed out from under it mid-run; the agent thread kept going
   and its eventual (often degraded but plausible-looking) response
   got reported as a normal successful completion.

Fix, three parts:

- cron/scheduler.py: expose get_running_job_ids() (thread-safe
  snapshot of the existing _running_job_ids set, already used to
  prevent double-dispatch) so the gateway can read cron's in-flight
  state without reaching into private module internals.

- gateway/run.py: GatewayRunner._active_cron_job_count() reads that
  snapshot. _drain_active_agents() now waits on
  (_running_agents OR active cron jobs), so a cron-only workload gets
  the same bounded wait chat sessions already get instead of an
  instant active_at_start=0. Shutdown drain logging gains
  cron_active_at_start/cron_active_now fields alongside the existing
  ones (unchanged, for compat).

- cron/scheduler.py: mark_running_jobs_interrupted(reason), called by
  gateway/run.py's _kill_tool_subprocesses() right after
  process_registry.kill_all(), marks every job still in
  _running_job_ids at that instant as failed/interrupted via the
  existing mark_job_run() -- and records the job IDs in
  _interrupted_job_ids BEFORE writing, so run_one_job()'s own
  eventual completion for the same run (racing in its own thread)
  checks that flag and skips its normal write instead of clobbering
  the interrupted status with a false "ok" produced from the
  now-truncated tool output. This does not attempt to correlate a
  killed PID to a specific job ID (process_registry tracks PIDs, not
  job IDs) -- any job still dispatched at the moment of a forced kill
  is treated as interrupted, matching the existing coarser precedent
  set by _interrupt_running_agents(), which interrupts every entry in
  _running_agents on a drain timeout without per-agent correlation
  either.

Deliberately out of scope (flagged in the issue as a separate,
lower-priority concern): startup-time reconciliation of cron runs that
started but never reached a terminal status.

Testing:

- tests/cron/test_shutdown_interrupt.py (12 tests): get_running_job_ids
  snapshot semantics, mark_running_jobs_interrupted marking/no-op/
  partial-failure behavior, and -- the core race guard -- run_one_job
  skipping its own last_status write (both the success path and the
  exception path) when the shutdown path already marked the run
  interrupted, with a control test proving ordinary un-interrupted
  completions are unaffected.

- tests/gateway/test_cron_active_work_drain.py (9 tests):
  _active_cron_job_count reading cron state and failing closed (0) if
  the cron module is unavailable; _drain_active_agents waiting for an
  in-flight cron job the same way it waits for chat sessions, timing
  out if the job outruns the window, and leaving existing chat-session
  drain behavior unchanged; a full runner.stop() integration test
  (drain-timeout path) proving mark_running_jobs_interrupted actually
  fires with the right job ID when a tool subprocess is force-killed,
  plus a no-op control when nothing cron-related is in flight.

- tests/gateway/test_shutdown_cache_cleanup.py: added
  _active_cron_job_count() to that file's hand-rolled _FakeGateway test
  double, which stop() now calls -- without it those 8 pre-existing
  tests AttributeError (caught by fail-then-pass below, not a
  production bug).

Fail-then-pass: reverted gateway/run.py + cron/scheduler.py, all 21
new tests fail (fixture/attribute errors -- the feature doesn't exist
yet); restored, all 21 pass.

Regression check: ran the full plausibly-affected surface --
tests/gateway/{test_gateway_shutdown,test_restart_drain,
test_restart_notification,test_restart_redelivery_dedup,
test_restart_resume_pending,test_restart_service_detection,
test_shutdown_cache_cleanup,test_stuck_loop,test_clean_shutdown_marker,
test_external_drain_control,test_session_state_cleanup,
test_update_command,test_update_streaming}.py plus tests/cron/ (944
tests) -- against a clean upstream/main checkout and against this
branch. Diffed the two FAILED lists: identical, 20 pre-existing
failures on both sides (Windows-locale/cp1252 file-encoding issues and
Unix-permission-bit assertions that don't apply on this Windows dev
box), zero new failures, zero fixed-by-accident. The 8
test_shutdown_cache_cleanup.py failures found mid-development were
from the _FakeGateway gap above, fixed in the same commit and
confirmed clean on the final rerun (diff against baseline: exit 0).

Fixes #60432
2026-07-07 22:15:04 -07:00
HexLab98
e6077af279 test(gateway): cover cron drain during gateway shutdown (#60432) 2026-07-07 22:15:04 -07:00
HexLab98
862aee4956 fix(gateway): drain in-flight cron jobs before shutdown tool kill
/update and other shutdown paths only waited on gateway session agents,
so active cron tool work was killed immediately in final-cleanup while
the scheduler could still mark the job successful (#60432).
2026-07-07 22:15:04 -07:00
teknium1
a208b7eeb4 chore: add AUTHOR_MAP entry for neoguyverx (PR #60526 salvage) 2026-07-07 22:14:33 -07:00
teknium1
6695640c1d fix(tools): make the YAML write gate syntax-only so multi-doc/tagged YAML isn't refused
safe_load() raises ComposerError on multi-document streams (k8s manifests)
and ConstructorError on application-defined tags (CloudFormation !Sub,
Ansible !vault) — both valid YAML syntax. Now that the linter's verdict is
a fail-closed write gate, those false positives would refuse legitimate
writes outright. Switch to yaml.parse() (scanner+parser only), which still
catches real syntax failures.
2026-07-07 22:14:33 -07:00
Neo Guyver
2e1982f83d Fail closed on invalid JSON/YAML/TOML writes instead of writing then reporting
write_file() previously called _atomic_write() first and only ran the
JSON/YAML/TOML/Python syntax check afterward as an informational lint
delta -- a parse failure never set the top-level `error` key, so a
corrupt structured-data write still landed on disk (and file_tools.py's
files_modified gating, which keys off `error`, silently reported it as
a successful modification).

Move the in-process syntax check for JSON/YAML/TOML ahead of
_atomic_write() and refuse the write outright on a parse failure: no
temp file, no rename, nothing touches disk, and the result carries a
top-level `error` so callers correctly see it as unmodified.

Deliberately scoped to _FAIL_CLOSED_INPROC_EXTS (JSON/YAML/TOML), not
all of LINTERS_INPROC -- .py is excluded because this codebase's own
test fixtures (TestPatchReplacePostWriteVerification et al.) write
arbitrary non-Python text through *.py paths purely to exercise
write-mechanics; a hard block there broke 3 previously-passing tests
during development. Python keeps its pre-existing non-blocking
lint-delta report.

Adds tests/tools/test_write_file_syntax_gate.py: invalid JSON/YAML/YML/
TOML refused with nothing written (new file) and nothing modified
(existing file); valid JSON/YAML still written byte-for-byte; a
non-linted extension with garbage content is unaffected; invalid Python
is confirmed NOT hard-refused (still just reported).
2026-07-07 22:14:33 -07:00
ethernet
4d7f8ade3e feat(install): warn pip/Homebrew installs are unsupported (CLI, TUI, desktop) (#57225)
* feat(install): warn pip/Homebrew installs are unsupported (CLI, TUI, desktop)

pip and Homebrew are now Unsupported install methods per
website/docs/getting-started/platform-support.md. Surface a
warn-don't-block deprecation notice everywhere the install method is
already shown, pointing at the platform-support docs and noting these
installs will not receive further updates. NixOS (Tier 2) is untouched.

- hermes_cli/config.py: shared is_unsupported_install_method() /
  format_unsupported_install_warning() helpers so the wording and docs
  link stay consistent across every surface.
- hermes_cli/banner.py: generalize the existing pip-only banner
  warning to also cover Homebrew.
- hermes_cli/main.py: hermes update and hermes update --check print
  the warning before proceeding (still update; warn, don't block).
- tui_gateway/server.py: session.info gains install_warning.
- ui-tui: SessionPanel renders install_warning alongside the existing
  'N commits behind' notice.
- apps/desktop: SessionRuntimeInfo/GatewayEventPayload gain
  install_warning; applyRuntimeInfo + the live session.info event fire
  a snoozable warning toast via a new reportInstallMethodWarning(),
  mirroring the existing backend-contract-skew toast pattern. i18n
  strings added for en/zh/zh-hant/ja.
- Tests: updated pip banner assertions for the new wording, added a
  Homebrew banner test, and two tui_gateway session_info tests
  (install_warning present for pip, absent for git).

* fix(nix): make `hermes` in developement environment actually work

install modules as editable overlay with uv

* feat: print install method when running --version

* fix: correct detect install method when running from a subtree
2026-07-07 21:13:19 -07:00
Teknium
9de9c25f62 chore: release v0.18.2 (2026.7.7.2) (#60651) 2026-07-07 20:11:08 -07:00
Teknium
c30c9753b6 fix(whatsapp): unpin Baileys from git commit, use published 7.0.0-rc13 (#60643)
The April 2026 pin to WhiskeySockets/Baileys#01047deb existed only to
pick up the abprops bad-request fix (Baileys PR #2473) before it was
released. That fix shipped in v7.0.0-rc11 (May 2026); our pinned commit
is now 48 commits behind rc13.

The git pin forced npm to clone the repo and compile Baileys from
TypeScript source on every fresh install (~3 min), which blew past the
dashboard pairing flow's timeout. Registry install takes ~3s.

Validation: all 9 bridge.js imports present in rc13, bridge.native.test.mjs
passes (13/13), live bridge boot renders pairing QR against real WA servers.
2026-07-07 19:49:26 -07:00
476 changed files with 27546 additions and 5209 deletions

2
.envrc
View File

@@ -1,4 +1,4 @@
watch_file pyproject.toml uv.lock
watch_file pyproject.toml uv.lock hermes
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix

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.
@@ -27,6 +27,9 @@ outputs:
mcp_catalog:
description: Require MCP catalog security review label.
value: ${{ steps.classify.outputs.mcp_catalog }}
ci_review:
description: Require CI-sensitive file review label.
value: ${{ steps.classify.outputs.ci_review }}
runs:
using: composite

View File

@@ -43,6 +43,7 @@ jobs:
deps: ${{ steps.classify.outputs.deps }}
docker_meta: ${{ steps.classify.outputs.docker_meta }}
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
ci_review: ${{ steps.classify.outputs.ci_review }}
event_name: ${{ github.event_name }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -65,16 +66,17 @@ jobs:
lint:
name: Python lints
needs: detect
if: needs.detect.outputs.python == 'true'
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.ci_review == 'true'
uses: ./.github/workflows/lint.yml
with:
event_name: ${{ needs.detect.outputs.event_name }}
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
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 +141,7 @@ jobs:
needs:
- tests
- lint
- typecheck
- js-tests
- docs-site
- history-check
- contributor-check
@@ -154,14 +156,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)')
"

145
.github/workflows/desktop-autofix.yml vendored Normal file
View File

@@ -0,0 +1,145 @@
name: auto-fix lint issues & fmtting
# On push to main, run `npm run fix` on each workspace package and commit any changes.
# Fixable lint issues (import sorting, unused imports, curly braces, etc.) are
# auto-corrected on merge so PRs aren't blocked by them. The PR-time eslint
# check in typecheck.yml fails only when un-fixable errors remain.
#
# Pushes made by GITHUB_TOKEN don't trigger further workflow runs, so there's
# no infinite loop.
#
# ── Security model: two-job split ───────────────────────────────────────────
#
# The eslint process executes repo code (eslint.config.mjs, package.json
# scripts, installed plugins). To prevent a malicious PR from getting arbitrary
# code execution on a runner with push access, the work is split:
#
# 1. generate-patch (unprivileged, contents: read only)
# Checks out, installs deps, runs eslint --fix, produces a .patch artifact.
# Worst case: malicious code runs here on an ephemeral runner with zero
# push permissions.
#
# 2. apply-patch (privileged, contents: write)
# Checks out, downloads the patch artifact, applies it, validates that
# only files under apps/desktop/src/ and apps/desktop/electron/ changed,
# then pushes with --force-with-lease. This job never runs npm, never
# installs anything, never executes any repo code. The only input it
# trusts is the patch artifact, and the path guard ensures the patch can
# only touch linted source files.
on:
push:
branches: [main]
paths:
- '**/*.js'
- '**/*.cjs'
- '**/*.mjs'
- '**/*.ts'
- '**/*.tsx'
- 'package.json'
- 'package-lock.json'
permissions:
contents: read # default; apply-patch job overrides to write
concurrency:
group: desktop-autofix-${{ github.ref }}
cancel-in-progress: true
jobs:
generate-patch:
name: Generate eslint --fix patch
runs-on: ubuntu-latest
timeout-minutes: 5
# No permissions override → inherits workflow-level contents: read.
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
# --ignore-scripts: eslint only needs TS sources + eslint packages.
- uses: ./.github/actions/retry
with:
command: npm ci --ignore-scripts
- name: npm run fix in all workspaces
# continue-on-error: if un-fixable errors exist on main, we still want
# to commit whatever fixes were applied. The PR-time check in
# typecheck.yml is what blocks un-fixable errors from landing.
continue-on-error: true
run: npm run fix
- name: Produce patch
run: |
if git diff --quiet; then
echo "No fixes needed."
# Empty patch signals "nothing to do" to apply-patch.
: > js-fix.patch
else
git diff > js-fix.patch
echo "Patch size: $(wc -c < js-fix.patch) bytes"
fi
- name: Upload patch artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: js-fix-patch
path: js-fix.patch
retention-days: 1
include-hidden-files: true
apply-patch:
name: Apply patch to main
needs: generate-patch
runs-on: ubuntu-latest
timeout-minutes: 3
permissions:
contents: write # needed to push to main
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download patch
uses: actions/download-artifact@v4
with:
name: js-fix-patch
- name: Apply patch and push
env:
START_SHA: ${{ github.sha }}
run: |
set -euo pipefail
# Empty patch = nothing to do.
if [ ! -s js-fix.patch ]; then
echo "Patch is empty. No fixes to apply."
exit 0
fi
# Apply the patch produced by the unprivileged job.
git apply --check js-fix.patch || {
echo "::error::Patch does not apply cleanly. Main may have moved???"
exit 0
}
git apply js-fix.patch
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "fmt(js): `npm run fix` on merge"
# --force-with-lease=<ref>:<sha> makes the precondition check atomic
# on the server side: the push only succeeds if origin/main still
# points at START_SHA when the server processes it. If main moved
# since checkout (another push landed), the server rejects it and
# the next run will re-apply on the current state.
if git push --force-with-lease=main:"$START_SHA" origin HEAD:main; then
echo "Pushed fixes."
else
echo "Main moved since checkout ($START_SHA). Push rejected."
echo "Next run will re-apply on the current state."
exit 0
fi

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

@@ -0,0 +1,45 @@
# .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
needs: workspaces
runs-on: ubuntu-latest
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
- run: npm run --prefix ${{ matrix.package }} fix

View File

@@ -15,6 +15,10 @@ on:
description: The event name from the calling orchestrator (pull_request or push).
type: string
required: true
ci_review:
description: Whether CI-sensitive files (eslint config, workflows, actions) changed and require a review label.
type: boolean
default: false
permissions:
contents: read
@@ -158,3 +162,46 @@ jobs:
- name: Run footgun checker
run: python scripts/check-windows-footguns.py --all
ci-review:
# Require explicit maintainer review when CI-sensitive files change:
# eslint config, workflow YAMLs, or composite actions. These files
# influence what code the desktop-autofix job executes and pushes to
# main, so a malicious PR could inject arbitrary code via a custom eslint
# rule's `fix` function. The label gate ensures a human reviews before
# merge. Mirrors the mcp-catalog-reviewed pattern in supply-chain-audit.yml.
name: CI-sensitive file review
if: inputs.event_name == 'pull_request' && inputs.ci_review
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Require ci-reviewed label
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
echo "ci-reviewed label present."
exit 0
fi
BODY="## ⚠️ CI-sensitive file review required
This PR changes CI-sensitive files (eslint config, workflow YAMLs,
or composite actions). These files influence what code the
desktop-autofix job executes and pushes to main.
A maintainer should verify:
- no new eslint rules with custom \`fix\` functions that write outside linted paths,
- no workflow changes that widen permissions or remove guards,
- no composite action changes that alter what gets executed.
After review, add the \`ci-reviewed\` label and re-run this check."
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
echo "::error::CI-sensitive changes require the ci-reviewed label."
exit 1

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

@@ -74,7 +74,7 @@ Esto no es una barra de calidad — es una decisión de acoplamiento y mantenimi
| Requisito | Notas |
|-----------|-------|
| **Git** | Con la extensión `git-lfs` instalada |
| **Python 3.11+** | uv lo instalará si falta |
| **Python 3.113.13** | uv lo instalará si falta |
| **uv** | Gestor de paquetes Python rápido ([instalar](https://docs.astral.sh/uv/)) |
| **Node.js 20+** | Opcional — necesario para herramientas de navegador y puente WhatsApp (coincide con los engines de `package.json` raíz) |

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

@@ -1,7 +1,7 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.18.1",
"version": "0.18.2",
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
"repository": "https://github.com/NousResearch/hermes-agent",
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
@@ -9,7 +9,7 @@
"license": "MIT",
"distribution": {
"uvx": {
"package": "hermes-agent[acp]==0.18.1",
"package": "hermes-agent[acp]==0.18.2",
"args": ["hermes-acp"]
}
}

View File

@@ -77,8 +77,8 @@ def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str:
include the exact opt-back-out command.
"""
model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1]
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5 family is
# capped at 272K by the Codex OAuth backend.
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6 family
# is capped at 272K by the Codex OAuth backend.
cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
from_pct = int(round(autoraise["from"] * 100))
to_pct = int(round(autoraise["to"] * 100))

View File

@@ -1564,6 +1564,17 @@ def anthropic_prompt_cache_policy(
model_lower = eff_model.lower()
provider_lower = eff_provider.lower()
is_claude = "claude" in model_lower
# Kimi / Moonshot family via OpenRouter: same cache_control wire format
# as Claude on OpenRouter (envelope layout). Without this branch
# moonshotai/kimi-k2.6 falls through to (False, False), serving ~1%
# cache hits on 64K-token prompts and re-billing the full prompt on
# every turn. Observed within-turn progression with cache enabled:
# 1% → 67% → 84% → 97% (#25970). Reuses the canonical family matcher
# (covers bare k1./k2./k25 release slugs the substring check missed).
from agent.anthropic_adapter import _model_name_is_kimi_family
is_kimi = (
_model_name_is_kimi_family(eff_model) or "moonshot" in model_lower
)
is_openrouter = base_url_host_matches(eff_base_url, "openrouter.ai")
# Nous Portal proxies to OpenRouter behind the scenes — identical
# OpenAI-wire envelope cache_control semantics. Treat it as an
@@ -1577,7 +1588,7 @@ def anthropic_prompt_cache_policy(
if is_native_anthropic:
return True, True
if (is_openrouter or is_nous_portal) and is_claude:
if (is_openrouter or is_nous_portal) and (is_claude or is_kimi):
return True, False
# Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout
# cache_control path as Portal Claude. Portal proxies to OpenRouter
@@ -1805,13 +1816,30 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Swap core runtime fields ──
agent.model = new_model
agent.provider = new_provider
# Use new base_url when provided; only fall back to current when the
# new provider genuinely has no endpoint (e.g. native SDK providers).
# Without this guard the old provider's URL (e.g. Ollama's localhost
# address) would persist silently after switching to a cloud provider
# that returns an empty base_url string.
# Use the new base_url when provided. When it's empty AND the
# provider is actually changing, do NOT fall back to the current
# (old provider's) URL — that silently pairs the new provider label
# with the previous provider's endpoint (e.g. new_provider=minimax
# paired with the leftover api.githubcopilot.com URL), and every
# request after the switch 400s at the wrong host. This mismatched
# pair also gets snapshotted into _primary_runtime below, so it
# keeps re-applying on every subsequent turn until a full restart.
# Fail loud instead: the caller (model_switch.switch_model())
# already resolves base_url for every real provider, so an empty
# value here means resolution failed upstream, not that the
# provider genuinely has none. Re-selecting the SAME provider with
# an empty base_url (e.g. a credential-only refresh) is still fine
# to keep the current URL. See #47828.
old_norm_provider = (old_provider or "").strip().lower()
new_norm_provider = (new_provider or "").strip().lower()
if base_url:
agent.base_url = base_url
elif old_norm_provider != new_norm_provider:
raise ValueError(
f"switch_model: no base_url resolved for provider "
f"'{new_provider}' (switching from '{old_provider}'); "
"refusing to keep the previous provider's endpoint"
)
agent.api_mode = api_mode
# Invalidate transport cache — new api_mode may need a different transport
if hasattr(agent, "_transport_cache"):
@@ -1925,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",
@@ -3128,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``)
@@ -3154,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
@@ -3174,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

@@ -314,15 +314,18 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool:
return bare == "trinity-large-thinking"
# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.4/5.5.
# The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the
# Codex backend hard-caps at 272K (verified live: a ~330K-token request to
# Context window enforced by ChatGPT's Codex OAuth backend for the
# gpt-5.4 / gpt-5.5 / gpt-5.6 families. The raw OpenAI API and OpenRouter
# expose 1.05M for the same slugs, but the Codex backend hard-caps at 272K
# (verified live for 5.4/5.5: a ~330K-token request to
# chatgpt.com/backend-api/codex/responses is rejected with
# ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the
# default 50% compaction trigger fires at ~136K — wasteful, since the model
# can hold far more raw context before summarization actually buys anything.
# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.4/
# gpt-5.5 sessions use the window they actually have.
# ``context_length_exceeded`` while ~250K succeeds; gpt-5.6 shares the same
# 272K Codex cap — see _CODEX_OAUTH_CONTEXT_FALLBACK in model_metadata.py).
# With a 272K ceiling the default 50% compaction trigger fires at ~136K —
# wasteful, since the model can hold far more raw context before
# summarization actually buys anything. We raise the trigger to 85% (~231K)
# on this exact route so Codex gpt-5.4 / gpt-5.5 / gpt-5.6 sessions use the
# window they actually have.
_CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85
# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a
@@ -336,14 +339,16 @@ _CODEX_SPARK_COMPACTION_THRESHOLD = 0.70
def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
"""True for gpt-5.4 / gpt-5.5 on the ChatGPT Codex OAuth backend.
"""True for gpt-5.4 / gpt-5.5 / gpt-5.6 on the ChatGPT Codex OAuth backend.
Matches only the Codex OAuth route (provider ``openai-codex``), not the
direct OpenAI API, OpenRouter, or GitHub Copilot paths — those expose a
larger context window for the same slug and must keep the user's default
compaction threshold. ``gpt-5.4-pro`` / ``gpt-5.5-pro`` and dated snapshots
are matched via prefix so the override tracks both 272K-capped families
without re-listing every variant.
compaction threshold. ``-pro`` variants and dated snapshots are matched
via prefix so the override tracks every 272K-capped family (5.4, 5.5,
5.6 sol/terra/luna incl. their ``-pro`` modes) without re-listing every
variant. (Name kept for backward compatibility with the
``compression.codex_gpt55_autoraise`` config key.)
"""
prov = (provider or "").strip().lower()
if prov != "openai-codex":
@@ -356,6 +361,9 @@ def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = Non
or bare == "gpt-5.5"
or bare.startswith("gpt-5.5-")
or bare.startswith("gpt-5.5.")
or bare == "gpt-5.6"
or bare.startswith("gpt-5.6-")
or bare.startswith("gpt-5.6.")
)
@@ -410,11 +418,12 @@ def _compression_threshold_for_model(
Per-model/route overrides:
- Arcee Trinity Large Thinking → 0.75 (preserve reasoning context).
- gpt-5.4 / gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps
both families at 272K and the default 50% trigger would compact at
~136K. Gated by ``allow_codex_gpt55_autoraise`` (historical config-key
name kept for backward compatibility) so the user can opt back down to
the global default (the caller passes the config flag through here).
- gpt-5.4 / gpt-5.5 / gpt-5.6 on the Codex OAuth route → 0.85, because
Codex caps all three families at 272K and the default 50% trigger
would compact at ~136K. Gated by ``allow_codex_gpt55_autoraise``
(historical config-key name kept for backward compatibility) so the
user can opt back down to the global default (the caller passes the
config flag through here).
- gpt-5.3-codex-spark on the Codex OAuth route → 0.70, because the model
has a native 128K window and the default 50% trigger would compact at
~64K — wasting half the usable context. Not gated by the gpt-5.5
@@ -4707,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
@@ -1399,6 +1405,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
fb_api_mode = "bedrock_converse"
old_model = agent.model
old_provider = agent.provider
# Clear the per-config context_length override so the fallback
# model's actual context window is resolved instead of inheriting
@@ -1544,6 +1551,16 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
f"🔄 Primary model failed — switching to fallback: "
f"{fb_model} via {fb_provider}"
)
# The buffered line above is dropped on successful recovery, but a
# provider/model switch is a durable state change operators must see
# even when the fallback succeeds. Record a one-shot notice that the
# success path surfaces exactly once via _emit_pending_fallback_notice
# (see run_agent.py); it is discarded on terminal failure since the
# buffered line is flushed instead. See fallback-observability fix.
agent._pending_fallback_notice = (
f"🔄 Switched to fallback model: {old_model} via {old_provider} "
f"{fb_model} via {fb_provider}"
)
logger.info(
"Fallback activated: %s%s (%s)",
old_model, fb_model, fb_provider,
@@ -1679,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

@@ -193,8 +193,10 @@ _HISTORICAL_SUMMARY_PREFIXES = (
_MIN_SUMMARY_TOKENS = 2000
# Proportion of compressed content to allocate for summary
_SUMMARY_RATIO = 0.20
# Absolute ceiling for summary tokens (even on very large context windows)
_SUMMARY_TOKENS_CEILING = 12_000
# Absolute ceiling for summary tokens (even on very large context windows).
# Summaries must stay within a 1K-10K token envelope — anything larger is
# itself a context-pressure source and slows every compaction.
_SUMMARY_TOKENS_CEILING = 10_000
# Placeholder used when pruning old tool results
_PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]"
@@ -227,6 +229,16 @@ _AUTO_FOCUS_MAX_CHARS = 700
# back the old large-tool-output case where nothing can be compacted.
_MAX_TAIL_MESSAGE_FLOOR = 8
# Models with context windows below this get their compression threshold
# floored at ``_SMALL_CTX_THRESHOLD_PERCENT`` (raise-only — an explicitly
# higher user/model threshold always wins). At the default 50% trigger a
# 128K-262K model compacts with only ~64-131K consumed; the incompressible
# floor (system prompt + tool schemas + protected tail + rolling summary)
# eats most of the reclaimed headroom, so compaction re-fires every 1-2
# turns and the session spends most of its wall-clock summarizing.
_SMALL_CTX_WINDOW_LIMIT = 512_000
_SMALL_CTX_THRESHOLD_PERCENT = 0.75
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
@@ -883,6 +895,18 @@ class ContextCompressor(ContextEngine):
self.provider = provider
self.api_mode = api_mode
self.context_length = context_length
# Re-apply the small-context threshold floor for the NEW window,
# starting from the originally-configured percent (not the possibly
# floored live value) so a small -> large switch drops back to the
# configured threshold and a large -> small switch gains the floor.
# Guard with getattr: compressors unpickled/constructed before this
# attribute existed fall back to the live value.
_configured_pct = getattr(
self, "_configured_threshold_percent", self.threshold_percent,
)
self.threshold_percent = self._effective_threshold_percent(
context_length, _configured_pct,
)
# max_tokens=None here means "caller didn't specify" → keep the existing
# output reservation. A switch that genuinely changes the output budget
# passes the new value explicitly. (#43547)
@@ -945,6 +969,23 @@ class ContextCompressor(ContextEngine):
return None
return ivalue if ivalue > 0 else None
@staticmethod
def _effective_threshold_percent(
context_length: int, threshold_percent: float,
) -> float:
"""Apply the small-context threshold floor (raise-only).
Models under ``_SMALL_CTX_WINDOW_LIMIT`` (512K) trigger at no less
than ``_SMALL_CTX_THRESHOLD_PERCENT`` (75%) of the window. An
explicitly higher threshold (user config or per-model autoraise,
e.g. Codex gpt-5.5's 85%) always wins; only lower values are raised.
Large-context models keep the configured value — at 512K+ the default
50% trigger already leaves ample post-compaction headroom.
"""
if context_length and context_length < _SMALL_CTX_WINDOW_LIMIT:
return max(threshold_percent, _SMALL_CTX_THRESHOLD_PERCENT)
return threshold_percent
@staticmethod
def _compute_threshold_tokens(
context_length: int, threshold_percent: float, max_tokens: int | None = None,
@@ -1032,6 +1073,18 @@ class ContextCompressor(ContextEngine):
config_context_length=config_context_length,
provider=provider,
)
# Small-context threshold floor: models under 512K trigger at >=75%
# so compaction doesn't fire with half the window still free (the
# incompressible floor makes 50%-triggered compaction thrash on
# 128K-262K models). Raise-only; must run AFTER context_length is
# resolved and BEFORE threshold_tokens is derived. The pre-floor
# value is kept so update_model() can re-derive for a new window
# (switching small -> large must drop back to the configured value).
self._configured_threshold_percent = self.threshold_percent
self.threshold_percent = self._effective_threshold_percent(
self.context_length, self.threshold_percent,
)
threshold_percent = self.threshold_percent
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if
# the percentage would suggest a lower value. This prevents premature
# compression on large-context models at 50% while keeping the % sane
@@ -1410,11 +1463,26 @@ class ContextCompressor(ContextEngine):
(API keys, tokens, passwords) from leaking into the summary that
gets sent to the auxiliary model and persisted across compactions.
"""
# Lazy import (matches title_generator.py) — agent_runtime_helpers
# pulls in heavy transitive imports we don't want at module load.
from agent.agent_runtime_helpers import strip_think_blocks
parts = []
for msg in turns:
role = msg.get("role", "unknown")
content = redact_sensitive_text(msg.get("content") or "")
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
# Strip inline reasoning blocks (<think>, <reasoning>, etc.) from
# assistant content before it reaches the summarizer. Reasoning
# traces are transient scratch work — feeding them to the aux
# model wastes summarizer context and risks scratch-work
# conclusions being preserved as facts in the summary. The native
# ``reasoning`` message field is already excluded (only
# ``content`` is serialized); this closes the inline-tag path
# used when native thinking is disabled or the provider inlines
# traces into content.
if role == "assistant" and content:
content = strip_think_blocks(None, content)
# Tool results: keep enough content for the summarizer
if role == "tool":
@@ -1880,7 +1948,15 @@ This compaction should PRIORITISE preserving all information related to the focu
"api_mode": self.api_mode,
},
"messages": [{"role": "user", "content": prompt}],
"max_tokens": int(summary_budget * 1.3),
# NO max_tokens: the output cap must never truncate a summary.
# ``summary_budget`` is prompt-level guidance only ("Target ~N
# tokens" above). Most OpenAI-compatible wires already omit the
# param (see _build_call_kwargs), but the Anthropic Messages
# wire and NVIDIA NIM forward it — a hard cap there cut
# summaries mid-section (thinking models burn the cap on
# reasoning first), producing truncated/thinking-only
# summaries and compaction loops. Omitting lets the adapter
# fall back to the model's native output ceiling.
# timeout resolved from auxiliary.compression.timeout config by call_llm
}
if self.summary_model:
@@ -1920,6 +1996,16 @@ This compaction should PRIORITISE preserving all information related to the focu
f"(provider={self.provider or 'auto'} "
f"model={self.summary_model or self.model})"
)
# Strip reasoning blocks the summarizer model may have emitted
# (<think>...</think> etc. from thinking models like MiniMax,
# DeepSeek, QwQ). Without this the trace is stored in
# _previous_summary, injected into the conversation, AND fed back
# into every subsequent iterative-update prompt — compounding
# token bloat across compactions. Mirrors title_generator.py.
from agent.agent_runtime_helpers import strip_think_blocks
stripped = strip_think_blocks(None, content).strip()
if stripped:
content = stripped
# Redact the summary output as well — the summarizer LLM may
# ignore prompt instructions and echo back secrets verbatim.
summary = redact_sensitive_text(content.strip())

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
@@ -5062,8 +5067,11 @@ def run_conversation(
# Reset retry counter/signature on successful content
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
# Successful content reached — drop any buffered retry
# status from earlier failed attempts in this turn.
# Successful content reached — surface the one-shot fallback
# switch notice (if a fallback activated this turn) before
# dropping the noisy retry buffer, so a provider/model switch
# stays visible even when the fallback succeeds.
agent._emit_pending_fallback_notice()
agent._clear_status_buffer()
from agent.agent_runtime_helpers import (
@@ -5096,6 +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
@@ -5170,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
@@ -5221,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)
@@ -5305,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

@@ -783,6 +783,55 @@ class MemoryManager:
exc_info=True,
)
def commit_session_boundary_async(
self,
messages: List[Dict[str, Any]],
*,
new_session_id: str,
parent_session_id: str = "",
reason: str = "new_session",
) -> None:
"""Queue old-session extraction + provider rebinding as ONE serialized task.
Session rotation (/new) must deliver ``on_session_end`` (end-of-session
extraction — an LLM-bound call that can take seconds) strictly BEFORE
``on_session_switch`` (which rebinds provider-internal ``_session_id`` /
turn buffers to the new session). Running extraction inline blocked the
/new command for the whole LLM round-trip (#16454); running it on an
ad-hoc thread raced the inline switch — providers key off internal
state, so a late ``on_session_end`` ran against post-switch bindings
(transcript misattributed to the new session id, double-ingest of the
old turn buffer, new-session buffers cleared).
Submitting BOTH hooks as one task on the manager's single background
worker gives both properties at a single chokepoint: the caller returns
immediately, and the worker's FIFO order serializes end→switch against
every other provider write (per-turn ``sync_all``, prefetches), which
already share the same worker. If the executor is unavailable,
``_submit_background`` degrades to inline execution — the pre-#16454
synchronous behavior, slow but correct.
"""
if not self._providers:
return
snapshot = list(messages or [])
def _run() -> None:
try:
self.on_session_end(snapshot)
except Exception as e: # pragma: no cover - on_session_end guards per-provider
logger.warning("Session-boundary extraction failed: %s", e)
try:
self.on_session_switch(
new_session_id,
parent_session_id=parent_session_id,
reset=True,
reason=reason,
)
except Exception as e: # pragma: no cover - on_session_switch guards per-provider
logger.warning("Session-boundary switch failed: %s", e)
self._submit_background(_run)
def on_session_switch(
self,
new_session_id: str,

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

@@ -111,6 +111,15 @@ _MODEL_CACHE_TTL = 3600
_endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
_endpoint_model_metadata_cache_time: Dict[str, float] = {}
_ENDPOINT_MODEL_CACHE_TTL = 300
# Bounded-lifetime cache: after the first successful probe we remember the
# server type so subsequent refreshes skip the full waterfall (no more 404
# spam every 5 minutes on non-matching endpoints like /api/v1/models on vllm).
# Entries expire after _ENDPOINT_PROBE_TTL_SECONDS so a server swap on the
# same port (stop Ollama, start LM Studio) is eventually re-detected instead
# of being pinned to the stale type for the whole process lifetime.
# Values are (server_type, monotonic_timestamp).
_ENDPOINT_PROBE_TTL_SECONDS = 3600.0
_endpoint_probe_path_cache: Dict[str, tuple] = {}
def _get_model_metadata_cache_path() -> Path:
@@ -220,6 +229,12 @@ DEFAULT_CONTEXT_LENGTHS = {
# ChatGPT Codex OAuth caps it at 272K; both paths resolve via their own
# provider-aware branches (_resolve_codex_oauth_context_length + models.dev).
# This hardcoded value is only reached when every probe misses.
# GPT-5.6 series (Sol/Terra/Luna, GA 2026-07-09) — 1.05M on the direct
# OpenAI API (same as gpt-5.5). Codex OAuth caps these at 272K.
# (Lookups length-sort keys at match time, so dict order is cosmetic.)
"gpt-5.6-luna": 1050000,
"gpt-5.6-terra": 1050000,
"gpt-5.6-sol": 1050000,
"gpt-5.5": 1050000,
"gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4)
"gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4)
@@ -289,11 +304,13 @@ DEFAULT_CONTEXT_LENGTHS = {
# Premium+); /v1/responses additionally enforces a ~262144 input+output
# budget, but the usable context (what we track here) is 200k.
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
"grok-build-latest": 500000, # alias of grok-4.5 (early access)
"grok-build": 256000, # grok-build-0.1
"grok-code-fast": 256000, # grok-code-fast-1
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
"grok-4-fast": 2000000, # grok-4-fast-(non-)reasoning, also matches -reasoning
"grok-4.20": 2000000, # grok-4.20-0309-(non-)reasoning, -multi-agent-0309
"grok-4.5": 500000, # grok-4.5, grok-4.5-latest — 500K context per docs.x.ai
"grok-4.3": 1000000, # grok-4.3, grok-4.3-latest — 1M context per docs.x.ai
"grok-4": 256000, # grok-4, grok-4-0709
"grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast
@@ -305,6 +322,8 @@ DEFAULT_CONTEXT_LENGTHS = {
# OpenRouter live metadata reports 262144 (256 × 1024); align the
# static fallback so cache and offline both agree (issue #22268).
"hy3-preview": 262144,
# Tencent — Hy3 (GA successor to Hy3 Preview), same 256K window.
"hy3": 262144,
# Nemotron — NVIDIA's open-weights series (128K context across all sizes)
"nemotron": 131072,
# Arcee
@@ -345,6 +364,11 @@ _GROK_EFFORT_CAPABLE_PREFIXES = (
"grok-3-mini",
"grok-4.20-multi-agent",
"grok-4.3",
# grok-4.5: verified live against /v1/responses 2026-07-08 — accepts
# effort low/medium/high (default: high when omitted) but REJECTS
# "none" ("This model does not support `reasoning_effort` value `none`"),
# unlike grok-4.3. models.dev agrees: effort values [low, medium, high].
"grok-4.5",
)
@@ -623,66 +647,109 @@ def is_local_endpoint(base_url: str) -> bool:
return False
def _localhost_to_ipv4(url: str) -> str:
"""Rewrite a ``localhost`` HOST to ``127.0.0.1`` in a probe URL.
On Windows dual-stack machines, httpx resolves ``localhost`` to ``::1``
first and pays a ~2s IPv6 connect timeout before falling back to IPv4
when the local server only listens on IPv4 (LM Studio, Ollama defaults).
Probing the IPv4 loopback directly skips that penalty.
Only the URL's own host component is rewritten (anchored at the scheme),
so a non-localhost URL whose path or query merely embeds the substring
``http://localhost...`` (e.g. ``?upstream=http://localhost:11434``)
passes through untouched.
"""
if not url:
return url
return re.sub(
r"^(https?://)localhost(?=[:/]|$)",
r"\g<1>127.0.0.1",
url,
count=1,
)
def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
"""Detect which local server is running at base_url by probing known endpoints.
Returns one of: "ollama", "lm-studio", "vllm", "llamacpp", or None.
The result is cached for the lifetime of the process so that repeated
calls (e.g. every 5-minute metadata refresh) never re-run the waterfall
and never spray 404s at endpoints the server does not expose.
"""
import httpx
normalized = _normalize_base_url(base_url)
# Resolve localhost to IPv4 to avoid 2s IPv6 timeout on Windows dual-stack.
# Applied to ``normalized`` before deriving server/LM Studio URLs AND
# before the cache lookup, so localhost and 127.0.0.1 share a cache entry.
normalized = _localhost_to_ipv4(normalized)
server_url = normalized
if server_url.endswith("/v1"):
server_url = server_url[:-3]
lmstudio_url = _lmstudio_server_root(base_url)
lmstudio_url = _lmstudio_server_root(normalized)
cached = _endpoint_probe_path_cache.get(server_url)
if cached is not None and (time.monotonic() - cached[1]) < _ENDPOINT_PROBE_TTL_SECONDS:
return cached[0]
headers = _auth_headers(api_key)
result: Optional[str] = None
try:
with httpx.Client(timeout=2.0, headers=headers) as client:
# LM Studio exposes /api/v1/models — check first (most specific)
try:
r = client.get(f"{lmstudio_url}/api/v1/models")
if r.status_code == 200:
return "lm-studio"
result = "lm-studio"
except Exception:
pass
# Ollama exposes /api/tags and responds with {"models": [...]}
# LM Studio returns {"error": "Unexpected endpoint"} with status 200
# on this path, so we must verify the response contains "models".
try:
r = client.get(f"{server_url}/api/tags")
if r.status_code == 200:
try:
if result is None:
# Ollama exposes /api/tags and responds with {"models": [...]}
# LM Studio returns {"error": "Unexpected endpoint"} with status 200
# on this path, so we must verify the response contains "models".
try:
r = client.get(f"{server_url}/api/tags")
if r.status_code == 200:
try:
data = r.json()
if "models" in data:
result = "ollama"
except Exception:
pass
except Exception:
pass
if result is None:
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
try:
r = client.get(f"{server_url}/v1/props")
if r.status_code != 200:
r = client.get(f"{server_url}/props") # fallback for older builds
if r.status_code == 200 and "default_generation_settings" in r.text:
result = "llamacpp"
except Exception:
pass
if result is None:
# vLLM: /version
try:
r = client.get(f"{server_url}/version")
if r.status_code == 200:
data = r.json()
if "models" in data:
return "ollama"
except Exception:
pass
except Exception:
pass
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
try:
r = client.get(f"{server_url}/v1/props")
if r.status_code != 200:
r = client.get(f"{server_url}/props") # fallback for older builds
if r.status_code == 200 and "default_generation_settings" in r.text:
return "llamacpp"
except Exception:
pass
# vLLM: /version
try:
r = client.get(f"{server_url}/version")
if r.status_code == 200:
data = r.json()
if "version" in data:
return "vllm"
except Exception:
pass
if "version" in data:
result = "vllm"
except Exception:
pass
except Exception:
pass
return None
if result is not None:
_endpoint_probe_path_cache[server_url] = (result, time.monotonic())
return result
def _iter_nested_dicts(value: Any):
@@ -786,7 +853,10 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
return _model_metadata_cache
try:
response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify())
# Tuple (connect, read) — flat timeout=10 means urllib3 can block 10s per
# retry stage through proxies that 403 CONNECT, ballooning to minutes
# (#46620). 5s connect / 10s read fails fast on unreachable hosts.
response = requests.get(OPENROUTER_MODELS_URL, timeout=(5, 10), verify=_resolve_requests_verify())
response.raise_for_status()
data = response.json()
@@ -864,7 +934,7 @@ def fetch_endpoint_model_metadata(
response = requests.get(
server_url.rstrip("/") + "/api/v1/models",
headers=headers,
timeout=10,
timeout=(5, 10),
verify=_resolve_requests_verify(),
)
response.raise_for_status()
@@ -912,7 +982,7 @@ def fetch_endpoint_model_metadata(
for candidate in candidates:
url = candidate.rstrip("/") + "/models"
try:
response = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify())
response = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify())
response.raise_for_status()
payload = response.json()
cache: Dict[str, Dict[str, Any]] = {}
@@ -1007,19 +1077,29 @@ def _load_context_cache() -> Dict[str, int]:
try:
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return data.get("context_lengths", {})
return data.get("context_lengths") or {}
except Exception as e:
logger.debug("Failed to load context length cache: %s", e)
return {}
def _context_cache_key(model: str, base_url: str) -> str:
"""Canonical ``model@base_url`` key for the persistent context cache.
Trailing slashes are stripped so ``http://host/v1`` and
``http://host/v1/`` share one entry instead of creating duplicates
that can go stale independently.
"""
return f"{model}@{(base_url or '').rstrip('/')}"
def save_context_length(model: str, base_url: str, length: int) -> None:
"""Persist a discovered context length for a model+provider combo.
Cache key is ``model@base_url`` so the same model name served from
different providers can have different limits.
"""
key = f"{model}@{base_url}"
key = _context_cache_key(model, base_url)
cache = _load_context_cache()
if cache.get(key) == length:
return # already stored
@@ -1036,18 +1116,43 @@ def save_context_length(model: str, base_url: str, length: int) -> None:
def get_cached_context_length(model: str, base_url: str) -> Optional[int]:
"""Look up a previously discovered context length for model+provider."""
key = f"{model}@{base_url}"
key = _context_cache_key(model, base_url)
cache = _load_context_cache()
return cache.get(key)
hit = cache.get(key)
if hit is not None:
return hit
# Legacy rows written before key normalization may carry a trailing
# slash — honor them rather than re-probing. Checked regardless of the
# caller's slash form: the row's shape and the caller's shape can differ
# in either direction (old slashed row + new normalized config, or the
# reverse), so probe the literal form and the slashed canonical form.
for legacy_key in (f"{model}@{base_url}", f"{key}/"):
if legacy_key != key:
hit = cache.get(legacy_key)
if hit is not None:
return hit
return None
def _invalidate_cached_context_length(model: str, base_url: str) -> None:
"""Drop a stale cache entry so it gets re-resolved on the next lookup."""
key = f"{model}@{base_url}"
key = _context_cache_key(model, base_url)
cache = _load_context_cache()
if key not in cache:
# Invalidation must also drop the in-memory TTL probe entries for this
# pair — otherwise the next resolution inside the TTL window reuses the
# very value we just declared stale and re-persists it.
bare = _strip_provider_prefix(model)
stripped = (base_url or "").rstrip("/")
_LOCAL_CTX_PROBE_CACHE.pop((bare, stripped), None)
_LOCAL_CTX_PROBE_CACHE.pop(("ollama_show", bare, stripped), None)
# Clear every key shape for this pair: canonical, the caller's literal
# form, and the slashed legacy form — same set get_cached_context_length
# consults, so a lookup can never resurrect a row invalidation missed.
stale_keys = {key, f"{model}@{base_url}", f"{key}/"}
if not any(k in cache for k in stale_keys):
return
del cache[key]
for k in stale_keys:
cache.pop(k, None)
path = _get_context_cache_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -1334,7 +1439,7 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option
import httpx
bare_model = _strip_provider_prefix(model)
server_url = base_url.rstrip("/")
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
if server_url.endswith("/v1"):
server_url = server_url[:-3]
@@ -1395,7 +1500,7 @@ def query_ollama_supports_vision(model: str, base_url: str, api_key: str = "") -
except Exception:
return None
server_url = base_url.rstrip("/")
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
if server_url.endswith("/v1"):
server_url = server_url[:-3]
@@ -1434,6 +1539,12 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti
hosting behind a reverse proxy, etc. For non-Ollama servers the POST
returns 404/405 quickly; the function handles errors gracefully.
Results are cached in ``_LOCAL_CTX_PROBE_CACHE`` (same 30s TTL,
positive-only — see ``_query_local_context_length``) so back-to-back
resolutions during one startup issue a single POST instead of one per
call site. Failures are never memoized: a server that isn't up yet must
be re-probed once it comes up.
For hosted servers the GGUF ``model_info.*.context_length`` is the
authoritative source: the user can't set their own ``num_ctx``, and the
OpenAI-compat ``/v1/models`` endpoint correctly omits ``context_length``
@@ -1445,9 +1556,28 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti
The order is flipped vs ``query_ollama_num_ctx()`` because local users
control ``num_ctx`` themselves; hosted users can't.
"""
import time as _time
# Namespaced cache key: shares the TTL store with
# _query_local_context_length but never collides with its (model, url)
# keys — the two probes can return different values for the same pair.
cache_key = ("ollama_show", _strip_provider_prefix(model), base_url.rstrip("/"))
now = _time.monotonic()
cached = _LOCAL_CTX_PROBE_CACHE.get(cache_key)
if cached is not None and (now - cached[1]) < _LOCAL_CTX_PROBE_TTL_SECONDS:
return cached[0]
result = _query_ollama_api_show_uncached(model, base_url, api_key=api_key)
if result: # positive-only — never memoize a failed probe
_LOCAL_CTX_PROBE_CACHE[cache_key] = (result, now)
return result
def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Uncached body of ``_query_ollama_api_show`` — one POST to ``/api/show``."""
import httpx
server_url = base_url.rstrip("/")
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
if server_url.endswith("/v1"):
server_url = server_url[:-3]
@@ -1566,10 +1696,10 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str
model = _strip_provider_prefix(model)
# Strip /v1 suffix to get the server root
server_url = base_url.rstrip("/")
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
if server_url.endswith("/v1"):
server_url = server_url[:-3]
lmstudio_url = _lmstudio_server_root(base_url)
lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url))
headers = _auth_headers(api_key)
@@ -1679,7 +1809,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) ->
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
resp = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify())
resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify())
if resp.status_code != 200:
return None
data = resp.json()
@@ -1713,6 +1843,9 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
"gpt-5.3-codex-spark": 128_000,
"gpt-5.2-codex": 272_000,
"gpt-5.4-mini": 272_000,
"gpt-5.6-sol": 272_000,
"gpt-5.6-terra": 272_000,
"gpt-5.6-luna": 272_000,
"gpt-5.5": 272_000,
"gpt-5.4": 272_000,
"gpt-5.2": 272_000,
@@ -1746,7 +1879,7 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
resp = requests.get(
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
timeout=(5, 10),
verify=_resolve_requests_verify(),
)
if resp.status_code != 200:
@@ -2430,5 +2563,82 @@ def estimate_request_tokens_rough(
if messages:
total += estimate_messages_tokens_rough(messages)
if tools:
total += (len(str(tools)) + 3) // 4
total += _estimate_tools_tokens_rough(tools)
return total
# NOTE: tool schemas can be large. Avoid repeated `str(tools)` conversions,
# which are CPU-heavy and can stall GUI event loops under GIL pressure.
#
# Keyed by ``id(tools)``. A long-lived gateway/desktop backend builds many
# transient tool lists over its lifetime, so the cache is bounded and evicts
# oldest-first (insertion-ordered dict) once it exceeds the cap. The cap is
# generous relative to how rarely toolsets are rebuilt within a process.
_TOOLS_TOKENS_CACHE: dict[int, Tuple[int, str, str, int]] = {}
_TOOLS_TOKENS_CACHE_MAX = 256
def _tool_name_for_cache(tool: Any) -> str:
if not isinstance(tool, dict):
return ""
fn = tool.get("function")
if isinstance(fn, dict):
name = fn.get("name")
if isinstance(name, str):
return name
name = tool.get("name")
return name if isinstance(name, str) else ""
def _estimate_tools_tokens_rough(tools: List[Dict[str, Any]]) -> int:
if not tools:
return 0
# Cache by list identity. Tools are rebuilt rarely (toolset changes),
# but token estimates are requested frequently (preflight, compaction).
key = id(tools)
n = len(tools)
first = _tool_name_for_cache(tools[0]) if n else ""
last = _tool_name_for_cache(tools[-1]) if n else ""
cached = _TOOLS_TOKENS_CACHE.get(key)
if cached is not None:
cached_n, cached_first, cached_last, cached_tokens = cached
if cached_n == n and cached_first == first and cached_last == last:
return cached_tokens
# Fast, stable rough estimate: sum lengths of the major schema fields.
# This avoids the pathological `str(tools)` path while still scaling with
# schema size (descriptions + parameters dominate).
total_chars = 0
for tool in tools:
if not isinstance(tool, dict):
continue
fn = tool.get("function")
if isinstance(fn, dict):
name = fn.get("name") or ""
desc = fn.get("description") or ""
params = fn.get("parameters") or {}
else:
name = tool.get("name") or ""
desc = tool.get("description") or ""
params = tool.get("parameters") or {}
if isinstance(name, str):
total_chars += len(name)
if isinstance(desc, str):
total_chars += len(desc)
# Parameters can be nested; JSON is closer to over-the-wire size than repr().
try:
total_chars += len(json.dumps(params, ensure_ascii=False, separators=(",", ":")))
except Exception:
total_chars += len(str(params))
tokens = (total_chars + 3) // 4
# Bound the cache: drop the oldest entry when the cap is exceeded so a
# long-running process can't accumulate an unbounded number of stale
# ``id(tools)`` entries (id values are recycled after GC anyway).
if len(_TOOLS_TOKENS_CACHE) >= _TOOLS_TOKENS_CACHE_MAX:
_TOOLS_TOKENS_CACHE.pop(next(iter(_TOOLS_TOKENS_CACHE)), None)
_TOOLS_TOKENS_CACHE[key] = (n, first, last, tokens)
return tokens

View File

@@ -7,6 +7,7 @@ assemble pieces, then combines them with memory and ephemeral prompts.
import json
import logging
import os
import sys
import threading
import contextvars
from collections import OrderedDict
@@ -17,6 +18,8 @@ from typing import Optional
from agent.runtime_cwd import resolve_agent_cwd
from agent.skill_utils import (
EXCLUDED_SKILL_DIRS,
SKILL_SUPPORT_DIRS,
extract_skill_conditions,
extract_skill_description,
get_all_skills_dirs,
@@ -25,6 +28,7 @@ from agent.skill_utils import (
parse_frontmatter,
skill_matches_environment,
skill_matches_platform,
skill_matches_platform_list,
)
from utils import atomic_json_write
@@ -743,6 +747,17 @@ PLATFORM_HINTS = {
"or 'all'). Do not promise the user that a deliver='origin' or "
"default-deliver cron job will message them in this session."
),
"desktop": (
"You are chatting inside the Hermes desktop app — a graphical chat "
"surface, not a terminal. Use markdown freely: it renders with full "
"GitHub flavor (tables, code blocks with syntax highlighting, math "
"via $...$, task lists, blockquote callouts). "
"You can deliver files natively — include MEDIA:/absolute/path/to/file "
"in your response. Images (.png, .jpg, .webp) appear inline, audio and "
"video play inline, and other files arrive as download links. You can "
"also include image URLs in markdown format ![alt](url) and they "
"render inline as photos."
),
"sms": (
"You are communicating via SMS. Keep responses concise and use plain text "
"only — no markdown, no formatting. SMS messages are limited to ~1600 "
@@ -1127,22 +1142,6 @@ def build_environment_hints() -> str:
f"`uname -a && whoami && pwd`."
)
# Hermes desktop GUI — any agent running under the desktop app should know
# it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL
# marks a hermes launched in the embedded terminal pane. Both set by main.cjs.
_truthy = ("1", "true", "yes")
_in_desktop = (os.getenv("HERMES_DESKTOP") or "").strip().lower() in _truthy
_in_desktop_term = (os.getenv("HERMES_DESKTOP_TERMINAL") or "").strip().lower() in _truthy
if _in_desktop or _in_desktop_term:
_desktop_hint = "Runtime surface: you're running inside the Hermes desktop GUI app."
if _in_desktop_term:
_desktop_hint += (
" You're in its embedded terminal pane, beside the GUI chat — the user can "
"select your output (⌥-drag on macOS, Shift-drag elsewhere) and press "
"⌘/Ctrl+L to send it to the chat composer."
)
hints.append(_desktop_hint)
if is_wsl():
hints.append(WSL_ENVIRONMENT_HINT)
@@ -1276,13 +1275,26 @@ def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None:
def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]:
"""Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files."""
manifest: dict[str, list[int]] = {}
for filename in ("SKILL.md", "DESCRIPTION.md"):
for path in iter_skill_index_files(skills_dir, filename):
skills_dir_str = str(skills_dir)
base = os.path.join(skills_dir_str, "")
prefix_len = len(base)
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
has_skill_md = "SKILL.md" in files
dirs[:] = [
d
for d in dirs
if d not in EXCLUDED_SKILL_DIRS
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
]
for filename in ("SKILL.md", "DESCRIPTION.md"):
if filename not in files:
continue
path = os.path.join(root, filename)
try:
st = path.stat()
st = os.stat(path)
except OSError:
continue
manifest[str(path.relative_to(skills_dir))] = [st.st_mtime_ns, st.st_size]
manifest[path[prefix_len:]] = [st.st_mtime_ns, st.st_size]
return manifest
@@ -1414,6 +1426,22 @@ def _skill_should_show(
return True
def _current_session_platform_hint() -> str:
"""Return the active platform without importing the gateway package on CLI startup."""
platform = os.environ.get("HERMES_PLATFORM") or os.environ.get("HERMES_SESSION_PLATFORM")
if platform:
return platform
session_context = sys.modules.get("gateway.session_context")
get_session_env = getattr(session_context, "get_session_env", None) if session_context else None
if get_session_env is None:
return ""
try:
return get_session_env("HERMES_SESSION_PLATFORM") or ""
except Exception:
return ""
def build_skills_system_prompt(
available_tools: "set[str] | None" = None,
available_toolsets: "set[str] | None" = None,
@@ -1448,15 +1476,10 @@ def build_skills_system_prompt(
# ── Layer 1: in-process LRU cache ─────────────────────────────────
# Include the resolved platform so per-platform disabled-skill lists
# produce distinct cache entries (gateway serves multiple platforms).
from gateway.session_context import get_session_env
_platform_hint = (
os.environ.get("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM")
or ""
)
_platform_hint = _current_session_platform_hint()
disabled = get_disabled_skill_names(_platform_hint or None)
cache_key = (
str(skills_dir.resolve()),
str(skills_dir),
tuple(str(d) for d in external_dirs),
tuple(sorted(str(t) for t in (available_tools or set()))),
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
@@ -1485,7 +1508,7 @@ def build_skills_system_prompt(
category = entry.get("category") or "general"
frontmatter_name = entry.get("frontmatter_name") or skill_name
platforms = entry.get("platforms") or []
if not skill_matches_platform({"platforms": platforms}):
if not skill_matches_platform_list(platforms):
continue
if frontmatter_name in disabled or skill_name in disabled:
continue

View File

@@ -66,9 +66,13 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
("nemotron-3-ultra", 600),
("nemotron-3-super", 600),
("nemotron-3-nano", 300),
# DeepSeek — R1 reasoning model on hosted NIM / DeepSeek direct.
# DeepSeek — R1 and V4 reasoning models on hosted NIM / DeepSeek direct.
# V4 series emits reasoning_content in a separate delta field before
# final content, requiring the same extended stale timeout floor.
("deepseek-r1", 600),
("deepseek-reasoner", 600),
("deepseek-v4-flash", 600),
("deepseek-v4-pro", 600),
# Qwen — QwQ reasoning + Qwen3 thinking variants. QwQ-32B
# preview is the stable slug; ``qwen3`` covers the family of
# thinking-mode Qwen3 models (qwen3-235b-a22b, qwen3-32b, etc.)
@@ -190,6 +194,10 @@ def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]:
300.0
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-r1")
600.0
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-flash")
600.0
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-pro")
600.0
>>> get_reasoning_stale_timeout_floor("qwen/qwen3-235b-a22b-thinking")
180.0
>>> get_reasoning_stale_timeout_floor("x-ai/grok-4-fast-reasoning")

View File

@@ -160,27 +160,8 @@ def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
# ── Platform matching ─────────────────────────────────────────────────────
def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
"""Return True when the skill is compatible with the current OS.
Skills declare platform requirements via a top-level ``platforms`` list
in their YAML frontmatter::
platforms: [macos] # macOS only
platforms: [macos, linux] # macOS and Linux
If the field is absent or empty the skill is compatible with **all**
platforms (backward-compatible default).
Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
older Pythons but became ``"android"`` on Python 3.13+. Termux is a
Linux userland riding on the Android kernel, so skills tagged
``linux`` are treated as compatible in Termux regardless of which
``sys.platform`` value Python reports. Individual Linux commands
inside a skill may still misbehave (no systemd, BusyBox utils, no
apt/dnf, etc.) but that is on the skill, not on platform gating.
"""
platforms = frontmatter.get("platforms")
def skill_matches_platform_list(platforms: Any) -> bool:
"""Return True when *platforms* is compatible with the current OS."""
if not platforms:
return True
if not isinstance(platforms, list):
@@ -204,6 +185,29 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
return False
def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
"""Return True when the skill is compatible with the current OS.
Skills declare platform requirements via a top-level ``platforms`` list
in their YAML frontmatter::
platforms: [macos] # macOS only
platforms: [macos, linux] # macOS and Linux
If the field is absent or empty the skill is compatible with **all**
platforms (backward-compatible default).
Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
older Pythons but became ``"android"`` on Python 3.13+. Termux is a
Linux userland riding on the Android kernel, so skills tagged
``linux`` are treated as compatible in Termux regardless of which
``sys.platform`` value Python reports. Individual Linux commands
inside a skill may still misbehave (no systemd, BusyBox utils, no
apt/dnf, etc.) but that is on the skill, not on platform gating.
"""
return skill_matches_platform_list(frontmatter.get("platforms"))
# ── Environment matching ──────────────────────────────────────────────────
# Recognized environment tags and how each is detected. An environment tag is
@@ -787,8 +791,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
``SKILL.md`` files, but they are progressive-disclosure data loaded through
``skill_view(..., file_path=...)`` rather than active skill roots.
"""
matches = []
for root, dirs, files in os.walk(skills_dir, followlinks=True):
skills_dir_str = str(skills_dir)
matches: list[str] = []
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
has_skill_md = "SKILL.md" in files
dirs[:] = [
d
@@ -797,9 +802,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
]
if filename in files:
matches.append(Path(root) / filename)
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
yield path
matches.append(os.path.join(root, filename))
for path in sorted(matches):
yield Path(path)
# ── Namespace helpers for plugin-provided skills ───────────────────────────

View File

@@ -24,6 +24,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
from __future__ import annotations
import json
import os
from typing import Any, Dict, List, Optional
from agent.prompt_builder import (
@@ -44,6 +45,7 @@ from agent.prompt_builder import (
drain_truncation_warnings,
)
from agent.runtime_cwd import resolve_context_cwd
from utils import is_truthy_value
def _ra():
@@ -110,6 +112,36 @@ def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) ->
return base
_TUI_EMBEDDED_PANE_CLARIFIER = (
" You're in its embedded terminal pane, beside the GUI chat — the user can "
"select your output (Option-drag on macOS, Shift-drag elsewhere) and press "
"Cmd/Ctrl+L to send it to the chat composer."
)
def _tui_embedded_pane_clarifier(hint: str) -> str:
"""Append the desktop-embedded-terminal-pane clarifier to a tui hint.
Triggered by ``HERMES_DESKTOP_TERMINAL=1`` (set by ``main.cjs`` only on the
shell env of the desktop's embedded TUI PTY — never on the chat backend).
This is a runtime-surface qualifier, not a config override, so it lives at
the resolution site rather than inside ``_resolve_platform_hint`` (which
is purely the config-platform_hints override applier). Byte-stable for the
cache: called once per session build, deterministically from env state.
Idempotent and empty-safe: re-applying on an already-augmented hint is a
no-op, and an empty input returns empty (we never synthesize the
clarifier without its tui framing).
"""
if not hint:
return hint
if _TUI_EMBEDDED_PANE_CLARIFIER in hint:
return hint
if not is_truthy_value(os.getenv("HERMES_DESKTOP_TERMINAL")):
return hint
return hint + _TUI_EMBEDDED_PANE_CLARIFIER
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
"""Assemble the system prompt as three ordered parts.
@@ -398,6 +430,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
pass
_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
if platform_key == "tui" and _effective_hint:
_effective_hint = _tui_embedded_pane_clarifier(_effective_hint)
if _effective_hint:
stable_parts.append(_effective_hint)

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

@@ -9,7 +9,6 @@ which has provider-specific conditionals for max_tokens defaults,
reasoning configuration, temperature handling, and extra_body assembly.
"""
import copy
from typing import Any, Dict
from agent.lmstudio_reasoning import resolve_lmstudio_effort
@@ -195,27 +194,63 @@ class ChatCompletionsTransport(ProviderTransport):
if not needs_sanitize:
return messages
sanitized = copy.deepcopy(messages)
for msg in sanitized:
sanitized = list(messages)
for msg_idx, msg in enumerate(messages):
if not isinstance(msg, dict):
continue
msg.pop("codex_reasoning_items", None)
msg.pop("codex_message_items", None)
msg.pop("tool_name", None)
msg.pop("timestamp", None) # #47868 — leak into strict providers
copied_msg: dict[str, Any] | None = None
def mutable_msg() -> dict[str, Any]:
nonlocal copied_msg
if copied_msg is None:
copied_msg = dict(msg)
sanitized[msg_idx] = copied_msg
return copied_msg
if (
"codex_reasoning_items" in msg
or "codex_message_items" in msg
or "tool_name" in msg
or "timestamp" in msg # #47868 — leak into strict providers
):
out_msg = mutable_msg()
out_msg.pop("codex_reasoning_items", None)
out_msg.pop("codex_message_items", None)
out_msg.pop("tool_name", None)
out_msg.pop("timestamp", None) # #47868 — leak into strict providers
# Drop all Hermes-internal scaffolding markers (``_``-prefixed).
# OpenAI's message schema has no ``_``-prefixed fields, so this
# is safe and future-proofs against new markers being added.
for key in [k for k in msg if isinstance(k, str) and k.startswith("_")]:
msg.pop(key, None)
internal_keys = [k for k in msg if isinstance(k, str) and k.startswith("_")]
if internal_keys:
out_msg = mutable_msg()
for key in internal_keys:
out_msg.pop(key, None)
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list):
for tc in tool_calls:
copied_tool_calls: list[Any] | None = None
for tc_idx, tc in enumerate(tool_calls):
if isinstance(tc, dict):
tc.pop("call_id", None)
tc.pop("response_item_id", None)
if strip_extra_content:
tc.pop("extra_content", None)
should_copy_tc = (
"call_id" in tc
or "response_item_id" in tc
or (strip_extra_content and "extra_content" in tc)
)
if should_copy_tc:
if copied_tool_calls is None:
copied_tool_calls = list(tool_calls)
copied_tc = dict(tc)
copied_tc.pop("call_id", None)
copied_tc.pop("response_item_id", None)
if strip_extra_content:
copied_tc.pop("extra_content", None)
copied_tool_calls[tc_idx] = copied_tc
if copied_tool_calls is not None:
mutable_msg()["tool_calls"] = copied_tool_calls
return sanitized
def convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:

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

@@ -103,6 +103,54 @@ _UTC_NOW = lambda: datetime.now(timezone.utc)
# Official docs snapshot entries. Models whose published pricing and cache
# semantics are stable enough to encode exactly.
_OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
# ── OpenAI GPT-5.6 series (Sol/Terra/Luna) ───────────────────────────
# Announced in limited preview 2026-06-26; GA 2026-07-09 at the same
# rates (Sol $5/$30, Terra $2.50/$15, Luna $1/$6 per 1M in/out). Cache
# writes are billed at 1.25x the uncached input rate; cache reads get the
# standard 90% discount (0.10x input, confirmed: Sol $0.50/M cached).
# Note: "Sol Fast mode" ($12.5/$75, up to 750 tok/s via Cerebras) is a
# separate serving tier, not covered by these entries. The "-pro"
# variants (high-effort modes, GA alongside base tiers) bill at the
# SAME per-token rates and are aliased onto these entries below the
# dict (they cost more per task by consuming more tokens, not by a
# higher rate — verified against OpenRouter's live pricing 2026-07-09).
# Source: https://openai.com/index/previewing-gpt-5-6-sol/
(
"openai",
"gpt-5.6-sol",
): PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("30.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
pricing_version="openai-gpt-5.6-2026-07",
),
(
"openai",
"gpt-5.6-terra",
): PricingEntry(
input_cost_per_million=Decimal("2.50"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.25"),
cache_write_cost_per_million=Decimal("3.125"),
source="official_docs_snapshot",
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
pricing_version="openai-gpt-5.6-2026-07",
),
(
"openai",
"gpt-5.6-luna",
): PricingEntry(
input_cost_per_million=Decimal("1.00"),
output_cost_per_million=Decimal("6.00"),
cache_read_cost_per_million=Decimal("0.10"),
cache_write_cost_per_million=Decimal("1.25"),
source="official_docs_snapshot",
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
pricing_version="openai-gpt-5.6-2026-07",
),
# ── Anthropic Claude 4.8 ─────────────────────────────────────────────
# Same $5/$25 base pricing as 4.6/4.7. Fast-mode variant is a separate
# model ID with 2x premium (vs the 6x premium on older Opus generations).
@@ -563,6 +611,15 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
),
}
# GPT-5.6 "-pro" high-effort variants bill at the same per-token rates as
# their base tiers (more tokens per task, not a higher rate). Alias them
# onto the base entries so the snapshot stays single-source.
for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"):
_OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-pro")] = _OFFICIAL_DOCS_PRICING[
("openai", _base_56)
]
del _base_56
def _to_decimal(value: Any) -> Optional[Decimal]:
if value is None:
@@ -602,7 +659,11 @@ def resolve_billing_route(
return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
if provider_name == "anthropic":
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name == "openai":
# "openai-api" is the picker/registry slug for direct api.openai.com; it
# bills identically to bare "openai", so normalize it here — otherwise the
# ("openai", <model>) _OFFICIAL_DOCS_PRICING keys are unreachable from the
# openai-api provider path.
if provider_name in {"openai", "openai-api"}:
return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name in {"minimax", "minimax-cn"}:
return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")

View File

@@ -0,0 +1,5 @@
import shared from '../../eslint.config.shared.mjs'
export default [
...shared
]

View File

@@ -12,7 +12,11 @@
"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",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"fix": "npm run lint:fix"
},
"dependencies": {
"@nous-research/ui": "0.16.0",
@@ -37,11 +41,19 @@
"tw-shimmer": "^0.4.11"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.4.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.16"
}
}

View File

@@ -1,6 +1,6 @@
//! Bootstrap orchestration.
//!
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`.
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.ts`.
//! Drives install.ps1 / install.sh stage-by-stage, emits progress events
//! over the Tauri `bootstrap` channel, writes a forensic log to
//! HERMES_HOME/logs/bootstrap-<timestamp>.log.

View File

@@ -1,6 +1,6 @@
//! Event types streamed from Rust → React.
//!
//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape
//! These mirror `apps/desktop/electron/bootstrap-runner.ts`'s event shape
//! 1:1 so the React installer code can be roughly identical to the Electron
//! install-overlay we'll replace.
//!

View File

@@ -8,7 +8,7 @@
//! 3. Network: download from GitHub raw at a pinned commit or branch.
//! Commit pins are immutable; branch pins are HEAD-tracking.
//!
//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`,
//! Mirrors `apps/desktop/electron/bootstrap-runner.ts`'s `resolveInstallScript`,
//! but the dev-checkout resolution is driven by an env var rather than the
//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant
//! to live OUTSIDE any repo checkout.
@@ -64,7 +64,7 @@ impl ScriptKind {
}
/// Validates a string looks like a git SHA (7+ hex chars). Mirrors
/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs.
/// `STAMP_COMMIT_RE` from bootstrap-runner.ts.
fn is_valid_commit(s: &str) -> bool {
let len = s.len();
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())

View File

@@ -150,7 +150,7 @@ fn repair_macos_installer_helper(path: &Path) {
fn repair_macos_installer_helper(_path: &Path) {}
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
/// the Electron app also checks). Per main.cjs:
/// the Electron app also checks). Per main.ts:
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
/// this is a probe helper, not a definitive path.

View File

@@ -1,6 +1,6 @@
//! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh.
//!
//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same
//! Port of `spawnPowerShell` from bootstrap-runner.ts, with the same
//! line-buffered stdout/stderr streaming + cancellation semantics.
//!
//! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File <script>`.
@@ -19,7 +19,7 @@ pub struct StreamSink {
pub on_stderr_line: Box<dyn Fn(&str) + Send + Sync>,
}
/// Outcome of a script invocation. Mirrors bootstrap-runner.cjs's
/// Outcome of a script invocation. Mirrors bootstrap-runner.ts's
/// `{stdout, stderr, code, signal, killed}` shape.
#[derive(Debug)]
pub struct ScriptResult {
@@ -258,7 +258,7 @@ fn interpreter_label() -> String {
/// Parses the LAST line of stdout that looks like a JSON object matching
/// the install.ps1 stage-result contract: `{ok: bool, stage: string, ...}`.
///
/// Mirrors `parseStageResult` from bootstrap-runner.cjs. install.ps1 may
/// Mirrors `parseStageResult` from bootstrap-runner.ts. install.ps1 may
/// print info/banner lines before the result frame; we scan from the end.
pub fn parse_stage_result(stdout: &str) -> Option<crate::events::StageResultPayload> {
for line in stdout.lines().rev() {

View File

@@ -85,7 +85,7 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig
### How it works
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.cjs` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.ts` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.ts`.
### Verification

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,9 +1,8 @@
'use strict'
import assert from 'node:assert/strict'
const test = require('node:test')
const assert = require('node:assert/strict')
import { test } from 'vitest'
const { serveBackendArgs, dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs')
import { dashboardFallbackArgs, serveBackendArgs, sourceDeclaresServe } from './backend-command'
test('serveBackendArgs builds a headless serve invocation', () => {
assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0'])
@@ -61,5 +60,6 @@ test('sourceDeclaresServe does not false-positive on the substring "server"', ()
dashboard_parser = subparsers.add_parser("dashboard", help="Start the web UI dashboard")
from hermes_cli.web_server import start_server # web server
`
assert.equal(sourceDeclaresServe(oldSource), false)
})

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
@@ -17,8 +15,9 @@
* Build the canonical headless backend argv (always `serve`).
* @param {string} [profile] optional Hermes profile to pin via `--profile`.
*/
function serveBackendArgs(profile) {
export function serveBackendArgs(profile?: string) {
const head = profile ? ['--profile', profile] : []
return [...head, 'serve', '--host', '127.0.0.1', '--port', '0']
}
@@ -28,9 +27,13 @@ function serveBackendArgs(profile) {
* `-m hermes_cli.main` and any `--profile <name>`). Returns a copy; if there is
* no `serve` token the argv is returned unchanged.
*/
function dashboardFallbackArgs(args) {
export function dashboardFallbackArgs(args) {
const i = args.indexOf('serve')
if (i === -1) return args.slice()
if (i === -1) {
return args.slice()
}
return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)]
}
@@ -40,12 +43,6 @@ function dashboardFallbackArgs(args) {
* specifically so the substring "server" (e.g. "start_server", "web server")
* never produces a false positive.
*/
function sourceDeclaresServe(dashboardPySource) {
export function sourceDeclaresServe(dashboardPySource) {
return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || ''))
}
module.exports = {
serveBackendArgs,
dashboardFallbackArgs,
sourceDeclaresServe
}

View File

@@ -1,15 +1,16 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const path = require('node:path')
import assert from 'node:assert/strict'
import path from 'node:path'
const {
POSIX_SANE_PATH_ENTRIES,
import { test } from 'vitest'
import {
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
normalizeHermesHomeRoot,
pathEnvKey
} = require('./backend-env.cjs')
pathEnvKey,
POSIX_SANE_PATH_ENTRIES
} from './backend-env'
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
const result = buildDesktopBackendPath({

View File

@@ -1,4 +1,4 @@
const path = require('node:path')
import path from 'node:path'
// Match the POSIX fallback surface used by the Python terminal environment.
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
@@ -23,12 +23,16 @@ function pathModuleForPlatform(platform = process.platform) {
}
function pathEnvKey(env = process.env, platform = process.platform) {
if (platform !== 'win32') return 'PATH'
if (platform !== 'win32') {
return 'PATH'
}
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
}
function currentPathValue(env = process.env, platform = process.platform) {
const key = pathEnvKey(env, platform)
return env?.[key] || ''
}
@@ -37,10 +41,17 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
const ordered = []
for (const entry of entries) {
if (!entry) continue
if (!entry) {
continue
}
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
for (const part of parts) {
if (!part || seen.has(part)) continue
if (!part || seen.has(part)) {
continue
}
seen.add(part)
ordered.push(part)
}
@@ -55,7 +66,7 @@ function buildDesktopBackendPath({
currentPath = '',
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
} = {}) {
}: any = {}) {
const delimiter = delimiterForPlatform(platform)
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
@@ -64,13 +75,18 @@ function buildDesktopBackendPath({
return appendUniquePathEntries([hermesNodeBin, venvBin, currentPath, saneEntries], { delimiter })
}
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {
if (!hermesHome) return hermesHome
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) }: any = {}) {
if (!hermesHome) {
return hermesHome
}
const resolved = pathModule.resolve(String(hermesHome))
const parent = pathModule.dirname(resolved)
if (pathModule.basename(parent).toLowerCase() === 'profiles') {
return pathModule.dirname(parent)
}
return resolved
}
@@ -81,7 +97,7 @@ function buildDesktopBackendEnv({
currentEnv = process.env,
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
} = {}) {
}: any = {}) {
const delimiter = delimiterForPlatform(platform)
const currentPythonPath = currentEnv?.PYTHONPATH || ''
const key = pathEnvKey(currentEnv, platform)
@@ -98,12 +114,12 @@ function buildDesktopBackendEnv({
}
}
module.exports = {
POSIX_SANE_PATH_ENTRIES,
export {
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
delimiterForPlatform,
normalizeHermesHomeRoot,
pathEnvKey
pathEnvKey,
POSIX_SANE_PATH_ENTRIES
}

View File

@@ -1,17 +1,18 @@
/**
* Tests for electron/backend-probes.cjs.
* Tests for electron/backend-probes.ts.
*
* Run with: node --test electron/backend-probes.test.cjs
* Run with: node --test electron/backend-probes.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } = require('./backend-probes.cjs')
import { test } from 'vitest'
import { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } from './backend-probes'
// Resolve the host's own Node binary -- guaranteed to be on disk and
// runnable. We use it as both a stand-in for "a python that doesn't
@@ -67,6 +68,7 @@ test('verifyHermesCli returns true when --version exits 0', () => {
// verifyHermesCli only cares about the exit code.
const scriptPath = path.join(os.tmpdir(), `hermes-probes-ok-${Date.now()}-${process.pid}.cjs`)
fs.writeFileSync(scriptPath, 'process.exit(0)\n')
try {
// Use node as the launcher and our script as the "command". Pass
// shell:false (default) -- node is a real binary, no shim.

View File

@@ -1,8 +1,8 @@
/**
* backend-probes.cjs
* backend-probes.ts
*
* Cheap "does this candidate backend actually work" checks used by
* resolveHermesBackend (main.cjs). The resolver walks a ladder of
* resolveHermesBackend (main.ts). The resolver walks a ladder of
* candidates -- bootstrap marker, `hermes` on PATH, system Python with
* hermes_cli installed -- and historically returned the first candidate
* whose binary existed on disk. That assumption breaks when a user has
@@ -27,12 +27,12 @@
* via the caller's catch block if it chooses)
* - any throw -> false (never propagate -- resolver wants a boolean)
*
* Kept in a standalone cjs module so it can be unit-tested with
* Kept in a standalone ts module so it can be unit-tested with
* `node --test` without dragging in the electron runtime (same pattern
* as bootstrap-platform.cjs and hardening.cjs).
* as bootstrap-platform.ts and hardening.ts).
*/
const { execFileSync } = require('node:child_process')
import { execFileSync } from 'node:child_process'
const PROBE_TIMEOUT_MS = 5000
@@ -62,12 +62,14 @@ function hermesRuntimeImportProbe() {
* through PYTHONPATH but lack PyYAML, then die on the first real CLI import.
*
* @param {string} pythonPath - Absolute path to a python.exe / python.
* @param {object} [opts]
* @param {object} [opts.env] - Additional environment for the probe.
* @returns {boolean}
*/
function canImportHermesCli(pythonPath, opts = {}) {
if (!pythonPath) return false
function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, string> } = {}) {
if (!pythonPath) {
return false
}
try {
execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
env: { ...process.env, ...(opts.env || {}) },
@@ -75,6 +77,7 @@ function canImportHermesCli(pythonPath, opts = {}) {
timeout: PROBE_TIMEOUT_MS,
windowsHide: true
})
return true
} catch {
return false
@@ -95,31 +98,29 @@ function canImportHermesCli(pythonPath, opts = {}) {
*
* @param {string} hermesCommand - Resolved absolute path to a hermes
* executable (or an interpreter+script wrapper).
* @param {object} [opts]
* @param {boolean} [opts.shell] - Whether to run through a shell. For
* .cmd/.bat shims on Windows execFileSync needs shell:true to find
* the cmd interpreter; mirrors the same flag isCommandScript() drives
* in resolveHermesBackend.
* @returns {boolean}
*/
function verifyHermesCli(hermesCommand, opts = {}) {
if (!hermesCommand) return false
function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
if (!hermesCommand) {
return false
}
try {
execFileSync(hermesCommand, ['--version'], {
stdio: 'ignore',
timeout: PROBE_TIMEOUT_MS,
shell: Boolean(opts.shell),
shell: Boolean(opts?.shell),
windowsHide: true
})
return true
} catch {
return false
}
}
module.exports = {
canImportHermesCli,
hermesRuntimeImportProbe,
verifyHermesCli,
PROBE_TIMEOUT_MS
}
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, verifyHermesCli }

View File

@@ -1,7 +1,7 @@
/**
* Tests for electron/backend-ready.cjs.
* Tests for electron/backend-ready.ts.
*
* Run with: node --test electron/backend-ready.test.cjs
* Run with: node --test electron/backend-ready.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*
* Covers the cold-start port-announcement deadline (issue #50209): the clock
@@ -11,29 +11,35 @@
* HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS, clamped to a 45s floor.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { EventEmitter } = require('node:events')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const {
import { test } from 'vitest'
import {
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS
} = require('./backend-ready.cjs')
waitForDashboardReadyFile
} from './backend-ready'
type FakeChildProcess = EventEmitter & {
stdout: EventEmitter
}
// A minimal stand-in for a spawned child process: an EventEmitter with a
// stdout EventEmitter, matching the surface waitForDashboardPort consumes
// (child.stdout.on('data'), child.on('exit'|'error') + the .off() teardown).
function makeFakeChild() {
const child = new EventEmitter()
function makeFakeChild(): FakeChildProcess {
const child = new EventEmitter() as FakeChildProcess
child.stdout = new EventEmitter()
return child
}
@@ -139,6 +145,7 @@ test('a late announcement after timeout does not throw (listeners torn down)', a
function mkTmpReadyFile() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-'))
return {
dir,
file: path.join(dir, 'ready.json'),
@@ -148,6 +155,7 @@ function mkTmpReadyFile() {
test('readDashboardReadyFile returns a valid port from JSON', () => {
const tmp = mkTmpReadyFile()
try {
fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 }))
assert.equal(readDashboardReadyFile(tmp.file), 4567)
@@ -158,6 +166,7 @@ test('readDashboardReadyFile returns a valid port from JSON', () => {
test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => {
const tmp = mkTmpReadyFile()
try {
assert.equal(readDashboardReadyFile(tmp.file), null)
fs.writeFileSync(tmp.file, '{')
@@ -172,6 +181,7 @@ test('readDashboardReadyFile ignores missing, malformed, or invalid files', () =
test('waitForDashboardReadyFile resolves when the ready file appears', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20)
@@ -184,6 +194,7 @@ test('waitForDashboardReadyFile resolves when the ready file appears', async ()
test('waitForDashboardPortAnnouncement uses ready file when provided', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 })
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20)
@@ -196,6 +207,7 @@ test('waitForDashboardPortAnnouncement uses ready file when provided', async ()
test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
child.emit('exit', 1, null)

View File

@@ -1,4 +1,4 @@
const fs = require('node:fs')
import fs from 'node:fs'
// `hermes serve` announces HERMES_BACKEND_READY; the legacy `hermes dashboard`
// backend announces HERMES_DASHBOARD_READY. Accept either so the desktop spawn
@@ -26,9 +26,11 @@ const MIN_PORT_ANNOUNCE_TIMEOUT_MS = 45_000
*/
function resolvePortAnnounceTimeoutMs(env = process.env) {
const parsed = Number(env.HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS)
if (Number.isFinite(parsed) && parsed > 0) {
return Math.max(MIN_PORT_ANNOUNCE_TIMEOUT_MS, Math.round(parsed))
}
return DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS
}
@@ -55,7 +57,10 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
let done = false
function cleanup() {
if (done) return
if (done) {
return
}
done = true
clearTimeout(timer)
child.stdout.off('data', onData)
@@ -66,13 +71,16 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
function onData(chunk) {
buf += chunk.toString()
let nl
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl)
buf = buf.slice(nl + 1)
const m = line.match(_READY_RE)
if (m) {
cleanup()
resolve(parseInt(m[1], 10))
return
}
}
@@ -99,11 +107,15 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
})
}
function readDashboardReadyFile(readyFile) {
if (!readyFile) return null
function readDashboardReadyFile(readyFile: fs.PathOrFileDescriptor) {
if (!readyFile) {
return null
}
try {
const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8'))
const port = Number(parsed?.port)
return Number.isInteger(port) && port > 0 ? port : null
} catch {
return null
@@ -116,16 +128,24 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
let interval = null
function cleanup() {
if (done) return
if (done) {
return
}
done = true
clearTimeout(timer)
if (interval) clearInterval(interval)
if (interval) {
clearInterval(interval)
}
child.off('exit', onExit)
child.off('error', onError)
}
function check() {
const port = readDashboardReadyFile(readyFile)
if (port) {
cleanup()
resolve(port)
@@ -150,25 +170,37 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
child.on('exit', onExit)
child.on('error', onError)
interval = setInterval(check, 50)
if (typeof interval.unref === 'function') interval.unref()
if (typeof interval.unref === 'function') {
interval.unref()
}
check()
})
}
function waitForDashboardPortAnnouncement(child, options = {}) {
function waitForDashboardPortAnnouncement(
child,
options: {
readyFile?: fs.PathOrFileDescriptor
timeoutMs?: number
} = {}
) {
const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs()
if (options.readyFile) {
return waitForDashboardReadyFile(options.readyFile, child, timeoutMs)
}
return waitForDashboardPort(child, timeoutMs)
}
module.exports = {
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile,
export {
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile
}

View File

@@ -1,14 +1,13 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
import assert from 'node:assert/strict'
const {
import { test } from 'vitest'
import {
bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment
} = require('./bootstrap-platform.cjs')
} from './bootstrap-platform'
test('isWslEnvironment detects WSL2 env vars on linux', () => {
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
@@ -85,27 +84,3 @@ test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both wa
null
)
})
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
const electronDir = __dirname
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
// - electron: provided by the electron runtime, always resolvable in packaged builds.
// - node-pty: hoisted by workspace dedup AND shipped via extraResources to
// resources/native-deps/node-pty (see scripts/stage-native-deps.cjs). main.cjs
// has a try/catch fallback at line ~38 that resolves the staged copy when the
// bare require fails in the packaged asar, so the bare require itself is by
// design rather than an oversight.
const allowedBareRequires = new Set(['electron', 'node-pty'])
const requirePattern = /require\(['"]([^'"]+)['"]\)/g
for (const entrypoint of entrypoints) {
const source = fs.readFileSync(path.join(electronDir, entrypoint), 'utf8')
const bareRequires = Array.from(source.matchAll(requirePattern))
.map(match => match[1])
.filter(specifier => !specifier.startsWith('node:'))
.filter(specifier => !specifier.startsWith('.'))
.filter(specifier => !allowedBareRequires.has(specifier))
assert.deepEqual(bareRequires, [], `${entrypoint} has unpackaged runtime requires`)
}
})

View File

@@ -1,20 +1,32 @@
const fs = require('node:fs')
import fs from 'node:fs'
function isWslEnvironment(env = process.env, platform = process.platform, kernelRelease = null) {
if (platform !== 'linux') return false
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true
if (platform !== 'linux') {
return false
}
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {
return true
}
try {
const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8')
return /microsoft|wsl/i.test(release)
} catch {
return false
}
}
function isWindowsBinaryPathInWsl(filePath, options = {}) {
function isWindowsBinaryPathInWsl(
filePath,
options: { isWsl?: boolean; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}
) {
const isWsl = options.isWsl ?? isWslEnvironment(options.env, options.platform)
if (!isWsl) return false
if (!isWsl) {
return false
}
const normalized = String(filePath || '')
.replace(/\\/g, '/')
@@ -48,19 +60,27 @@ const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off'])
*
* Pure + dependency-free so it can be unit-tested and called before app ready.
*/
function detectRemoteDisplay(options = {}) {
function detectRemoteDisplay(options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}) {
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
.trim()
.toLowerCase()
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
if (GPU_OVERRIDE_OFF.has(override)) return null
if (GPU_OVERRIDE_ON.has(override)) {
return 'override (HERMES_DESKTOP_DISABLE_GPU)'
}
if (GPU_OVERRIDE_OFF.has(override)) {
return null
}
// Launched from an SSH session → the display is X11-forwarded or otherwise
// remote. Covers the common `ssh user@box` + GUI-forwarding case.
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'ssh-session'
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) {
return 'ssh-session'
}
if (platform === 'linux') {
// X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a
@@ -68,6 +88,7 @@ function detectRemoteDisplay(options = {}) {
// NB: WSLg deliberately isn't treated as remote — it reports
// GPU-accelerated vGPU surfaces locally and doesn't show the flicker.
const display = String(env.DISPLAY || '')
if (display.includes(':') && display.split(':')[0]) {
return `x11-forwarding (DISPLAY=${display})`
}
@@ -77,15 +98,13 @@ function detectRemoteDisplay(options = {}) {
// RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is
// "Console".
const sessionName = String(env.SESSIONNAME || '')
if (/^rdp-/i.test(sessionName)) return `rdp (SESSIONNAME=${sessionName})`
if (/^rdp-/i.test(sessionName)) {
return `rdp (SESSIONNAME=${sessionName})`
}
}
return null
}
module.exports = {
bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment
}
export { bundledRuntimeImportCheck, detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment }

View File

@@ -1,15 +1,19 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const {
runBootstrap,
resolveInstallScript,
import { test } from 'vitest'
import {
buildPinArgs,
buildPosixPinArgs,
cachedScriptPath,
hasExistingGitCheckout,
installedAgentInstallScript,
cachedScriptPath
} = require('./bootstrap-runner.cjs')
resolveInstallScript,
runBootstrap
} from './bootstrap-runner'
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
@@ -22,6 +26,7 @@ test('runBootstrap bails immediately when the signal is already aborted', async
controller.abort()
const events = []
const result = await runBootstrap({
installStamp: null,
activeRoot: '/tmp/hermes-runner-test',
@@ -42,6 +47,7 @@ test('runBootstrap bails immediately when the signal is already aborted', async
test('installedAgentInstallScript resolves the installer in the agent checkout', () => {
const home = mkTmpHome()
try {
assert.equal(installedAgentInstallScript(home), null, 'absent before the checkout exists')
@@ -57,8 +63,61 @@ 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()
try {
const commit = 'a'.repeat(40)
const cached = cachedScriptPath(home, commit)
@@ -66,6 +125,7 @@ test('resolveInstallScript prefers a cached script without touching the network'
fs.writeFileSync(cached, '#!/bin/sh\necho cached\n')
const logs = []
const result = await resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
@@ -82,6 +142,7 @@ test('resolveInstallScript prefers a cached script without touching the network'
test('resolveInstallScript falls back to the installed agent checkout on a 404', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
// Seed the installed agent checkout so the fallback has something to resolve.
@@ -91,6 +152,7 @@ test('resolveInstallScript falls back to the installed agent checkout on a 404',
fs.writeFileSync(installed, '#!/bin/sh\necho fallback\n')
const logs = []
const result = await resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
@@ -117,6 +179,7 @@ test('resolveInstallScript falls back to the installed agent checkout on a 404',
test('resolveInstallScript rethrows when the 404 fallback is unavailable', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
// No installed agent checkout seeded -> nothing to fall back to.

View File

@@ -1,16 +1,14 @@
'use strict'
/**
* bootstrap-runner.cjs
* bootstrap-runner.ts
*
* Drives apps/desktop's first-launch install of Hermes Agent by spawning
* scripts/install.ps1 stage-by-stage and streaming progress events back to
* the renderer.
*
* Wired from electron/main.cjs:
* const { runBootstrap } = require('./bootstrap-runner.cjs')
* Wired from electron/main.ts:
* import { runBootstrap }from './bootstrap-runner'
* const result = await runBootstrap({
* installStamp, // INSTALL_STAMP from main.cjs (may be null in dev)
* installStamp, // INSTALL_STAMP from main.ts (may be null in dev)
* activeRoot, // ACTIVE_HERMES_ROOT
* sourceRepoRoot, // SOURCE_REPO_ROOT (for dev install.ps1 lookup)
* hermesHome, // HERMES_HOME
@@ -34,21 +32,16 @@
* no UI consumes them yet)
*/
const fs = require('node:fs')
const fsp = require('node:fs/promises')
const path = require('node:path')
const https = require('node:https')
const { spawn } = require('node:child_process')
import { spawn } from 'node:child_process'
import fs from 'node:fs'
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
@@ -71,10 +64,15 @@ function installScriptKind() {
}
function resolveLocalInstallScript(sourceRepoRoot) {
if (!sourceRepoRoot) return null
if (!sourceRepoRoot) {
return null
}
const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName())
try {
fs.accessSync(candidate, fs.constants.R_OK)
return candidate
} catch {
return null
@@ -90,16 +88,33 @@ function bootstrapCacheDir(hermesHome) {
// the pinned commit can't be fetched from GitHub (e.g. a locally-built desktop
// app stamped to an unpushed HEAD).
function installedAgentInstallScript(hermesHome) {
if (!hermesHome) return null
if (!hermesHome) {
return null
}
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
try {
fs.accessSync(candidate, fs.constants.R_OK)
return candidate
} catch {
return null
}
}
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'}`)
}
@@ -110,6 +125,7 @@ function downloadInstallScript(commit, destPath) {
// verification beyond "did the file we wrote pass a syntax probe."
const scriptName = installScriptName()
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/${scriptName}`
return new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(destPath), { recursive: true })
const tmpPath = destPath + '.tmp'
@@ -129,8 +145,10 @@ function downloadInstallScript(commit, destPath) {
`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`
)
)
return
}
const out2 = fs.createWriteStream(tmpPath)
res2.pipe(out2)
out2.on('finish', () => {
@@ -141,18 +159,24 @@ function downloadInstallScript(commit, destPath) {
out2.on('error', reject)
})
.on('error', reject)
return
}
if (res.statusCode !== 200) {
out.close()
try {
fs.unlinkSync(tmpPath)
} catch {
void 0
}
reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`))
return
}
res.pipe(out)
out.on('finish', () => {
out.close()
@@ -165,6 +189,7 @@ function downloadInstallScript(commit, destPath) {
} catch {
void 0
}
reject(err)
})
})
@@ -174,6 +199,7 @@ function downloadInstallScript(commit, destPath) {
} catch {
void 0
}
reject(err)
})
})
@@ -187,11 +213,13 @@ async function resolveInstallScript({
_download = downloadInstallScript
}) {
// 1. Dev shortcut: prefer a local checkout's installer so we can iterate
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
// without pushing. SOURCE_REPO_ROOT comes from main.ts (path.resolve
// of APP_ROOT/../..).
const localScript = resolveLocalInstallScript(sourceRepoRoot)
if (localScript) {
emit({ type: 'log', line: `[bootstrap] using local ${installScriptName()} at ${localScript}` })
return { path: localScript, source: 'local', kind: installScriptKind() }
}
@@ -204,12 +232,14 @@ async function resolveInstallScript({
}
const cached = cachedScriptPath(hermesHome, installStamp.commit)
try {
await fsp.access(cached, fs.constants.R_OK)
emit({
type: 'log',
line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}`
})
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
} catch {
// not cached; download
@@ -219,17 +249,20 @@ async function resolveInstallScript({
type: 'log',
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
})
try {
await _download(installStamp.commit, cached)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
} catch (err) {
// The pinned commit may not be fetchable from GitHub -- most commonly a
// locally-built desktop app stamped to an unpushed HEAD (see
// write-build-stamp.cjs fromLocalGit). Fall back to the installer that
// write-build-stamp.mjs fromLocalGit). Fall back to the installer that
// ships inside the already-installed agent checkout so dev/self-builds can
// still bootstrap instead of dying with a fatal 404.
const installed = installedAgentInstallScript(hermesHome)
if (installed) {
emit({
type: 'log',
@@ -237,15 +270,18 @@ async function resolveInstallScript({
`[bootstrap] GitHub fetch failed (${err.message}); ` +
`falling back to installed agent ${installScriptName()} at ${installed}`
})
try {
fs.mkdirSync(path.dirname(cached), { recursive: true })
fs.copyFileSync(installed, cached)
return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
} catch {
// Cache copy failed (read-only FS, etc.) -- use the source path directly.
return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
}
}
throw err
}
}
@@ -271,31 +307,41 @@ function powershellUnderRoot(root) {
function resolveWindowsPowerShell() {
for (const v of ['SystemRoot', 'windir']) {
const root = process.env[v]
if (root) {
const candidate = powershellUnderRoot(root)
try {
if (fs.statSync(candidate).isFile()) return candidate
if (fs.statSync(candidate).isFile()) {
return candidate
}
} catch {
void 0
}
}
}
const pathDirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean)
for (const exe of ['powershell.exe', 'pwsh.exe']) {
for (const dir of pathDirs) {
const candidate = path.join(dir, exe)
try {
if (fs.statSync(candidate).isFile()) return candidate
if (fs.statSync(candidate).isFile()) {
return candidate
}
} catch {
void 0
}
}
}
return 'powershell.exe'
}
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
return new Promise((resolve, reject) => {
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
return new Promise<any>((resolve, reject) => {
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
@@ -319,12 +365,14 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const onAbort = () => {
killed = true
try {
child.kill('SIGTERM')
} catch {
void 0
}
}
if (abortSignal) {
if (abortSignal.aborted) {
onAbort()
@@ -342,10 +390,14 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
stdout += chunk
stdoutBuf += chunk
let nl
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
if (line) {
emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
}
}
})
@@ -354,30 +406,46 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
stderr += chunk
stderrBuf += chunk
let nl
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
stderrBuf = stderrBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
if (line) {
emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
}
}
})
child.on('error', err => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
reject(err)
})
child.on('close', (code, signal) => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
// Flush any trailing bytes
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
resolve({ stdout, stderr, code, signal, killed })
if (stdoutBuf) {
emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' } as any)
}
if (stderrBuf) {
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' } as any)
}
resolve({ stdout, stderr, code, signal, killed } as any)
})
})
}
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
return new Promise((resolve, reject) => {
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
return new Promise<any>((resolve, reject) => {
const child = spawn('bash', [scriptPath, ...args], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
@@ -392,12 +460,14 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
const onAbort = () => {
killed = true
try {
child.kill('SIGTERM')
} catch {
void 0
}
}
if (abortSignal) {
if (abortSignal.aborted) {
onAbort()
@@ -414,10 +484,14 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
stdout += chunk
stdoutBuf += chunk
let nl
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
if (line) {
emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
}
}
})
@@ -426,22 +500,38 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
stderr += chunk
stderrBuf += chunk
let nl
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
stderrBuf = stderrBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
if (line) {
emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
}
}
})
child.on('error', err => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
reject(err)
})
child.on('close', (code, signal) => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
if (stdoutBuf) {
emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
}
if (stderrBuf) {
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
}
resolve({ stdout, stderr, code, signal, killed })
})
})
@@ -451,53 +541,74 @@ 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)
}
if (installStamp && installStamp.branch) {
args.push('-Branch', installStamp.branch)
}
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,
stageName: '__manifest__',
hermesHome
})
if (result.code !== 0) {
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`
)
}
// The manifest is the LAST JSON line on stdout (install.ps1 may print
// banner / info lines first depending on Console.OutputEncoding effects).
// Find the last line that parses as JSON with a `stages` field.
const lines = result.stdout.split(/\r?\n/).filter(Boolean)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i])
if (parsed && Array.isArray(parsed.stages)) {
return parsed
}
@@ -505,6 +616,7 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
void 0
}
}
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`
)
@@ -515,9 +627,11 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
// for the double-emit bug we addressed in the install.ps1 PR).
function parseStageResult(stdout) {
const lines = stdout.split(/\r?\n/).filter(Boolean)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i])
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
return parsed
}
@@ -525,23 +639,36 @@ function parseStageResult(stdout) {
void 0
}
}
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' })
const isPosix = installerKind === 'posix'
const args = isPosix
? [
'--stage',
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,
stageName: stage.name,
@@ -554,6 +681,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
if (result.killed) {
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, error: 'cancelled by user' }
emit(ev)
return ev
}
@@ -568,20 +696,26 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
error: `${isPosix ? 'install.sh --stage' : 'install.ps1 -Stage'} ${stage.name} produced no JSON result frame (exit=${result.code})`,
json: null
}
emit(ev)
return ev
}
if (json.ok && json.skipped) {
const ev = { type: 'stage', name: stage.name, state: 'skipped', durationMs, json }
emit(ev)
return ev
}
if (json.ok) {
const ev = { type: 'stage', name: stage.name, state: 'succeeded', durationMs, json }
emit(ev)
return ev
}
const ev = {
type: 'stage',
name: stage.name,
@@ -590,7 +724,9 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
json,
error: json.reason || `exit code ${result.code}`
}
emit(ev)
return ev
}
@@ -603,6 +739,7 @@ function openRunLog(logRoot) {
const ts = new Date().toISOString().replace(/[:.]/g, '-')
const logPath = path.join(logRoot, `bootstrap-${ts}.log`)
const stream = fs.createWriteStream(logPath, { flags: 'a' })
return { path: logPath, stream }
}
@@ -619,7 +756,7 @@ async function runBootstrap(opts) {
logRoot,
onEvent,
abortSignal,
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
writeMarker // callback to write the bootstrap-complete marker; main.ts provides
} = opts
// Bail before spawning anything if the user already cancelled — otherwise an
@@ -633,6 +770,7 @@ async function runBootstrap(opts) {
void 0
}
}
return { ok: false, cancelled: true }
}
@@ -646,8 +784,11 @@ async function runBootstrap(opts) {
} catch {
void 0
}
try {
if (typeof onEvent === 'function') onEvent(ev)
if (typeof onEvent === 'function') {
onEvent(ev)
}
} catch (err) {
// Don't let a subscriber bug crash the bootstrap
runLog.stream.write(`emit error: ${err && err.message}\n`)
@@ -664,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'
@@ -675,8 +828,10 @@ async function runBootstrap(opts) {
emit,
hermesHome,
activeRoot,
installStamp
installStamp,
pinCommit
})
emit({
type: 'manifest',
stages: manifest.stages,
@@ -690,8 +845,10 @@ async function runBootstrap(opts) {
for (const stage of manifest.stages) {
if (abortSignal && abortSignal.aborted) {
emit({ type: 'failed', error: 'bootstrap cancelled by user' })
return { ok: false, cancelled: true }
}
const ev = await runStage({
scriptPath: scriptInfo.path,
installerKind,
@@ -700,11 +857,14 @@ async function runBootstrap(opts) {
hermesHome,
activeRoot,
abortSignal,
installStamp
installStamp,
pinCommit
})
if (ev.state === 'failed') {
emit({ type: 'failed', stage: stage.name, error: ev.error || 'stage failed' })
return { ok: false, failedStage: stage.name, error: ev.error }
emit({ type: 'failed', stage: stage.name, error: (ev as any).error || 'stage failed' })
return { ok: false, failedStage: stage.name, error: (ev as any).error }
}
}
@@ -713,11 +873,14 @@ async function runBootstrap(opts) {
pinnedCommit: installStamp ? installStamp.commit : null,
pinnedBranch: installStamp ? installStamp.branch : null
}
const marker = typeof writeMarker === 'function' ? writeMarker(markerPayload) : markerPayload
emit({ type: 'complete', marker })
return { ok: true, marker }
} catch (err) {
emit({ type: 'failed', error: err.message || String(err) })
return { ok: false, error: err.message || String(err) }
} finally {
try {
@@ -728,12 +891,15 @@ async function runBootstrap(opts) {
}
}
module.exports = {
runBootstrap,
export {
buildPinArgs,
buildPosixPinArgs,
cachedScriptPath,
hasExistingGitCheckout,
installedAgentInstallScript,
// Exposed for testability
parseStageResult,
resolveLocalInstallScript,
resolveInstallScript,
installedAgentInstallScript,
cachedScriptPath
resolveLocalInstallScript,
runBootstrap
}

View File

@@ -1,7 +1,7 @@
/**
* Tests for electron/connection-config.cjs.
* Tests for electron/connection-config.ts.
*
* Run with: node --test electron/connection-config.test.cjs
* Run with: node --test electron/connection-config.test.ts
* (Wire into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the remote-gateway connection settings:
@@ -10,26 +10,29 @@
* and the OAuth session-cookie detector.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
const {
import { test } from 'vitest'
import {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
cookiesHavePrivySession,
cookiesHaveSession,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normAuthMode,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
tokenPreview
} = require('./connection-config.cjs')
} from './connection-config'
// --- connectionScopeKey / normAuthMode ---
@@ -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', () => {
@@ -73,6 +89,7 @@ test('profileRemoteOverride returns the per-profile remote with defaulted auth m
coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } }
}
}
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
url: 'https://coder.example.com/hermes',
authMode: 'token',
@@ -85,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)
@@ -331,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', () => {
@@ -365,6 +426,7 @@ test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
const url = await resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
mintTicket: async () => 'tkt-9'
})
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
})
@@ -376,13 +438,14 @@ test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validatio
throw new Error('401 ticket mint failed')
}
}),
err => {
(err: any) => {
// Actionable, points the user at re-auth, and preserves the cause + flag
// the boot overlay uses to offer a sign-in prompt.
assert.match(err.message, /WebSocket ticket/i)
assert.match(err.message, /sign in again/i)
assert.equal(err.needsOauthLogin, true)
assert.ok(err.cause instanceof Error)
return true
}
)

View File

@@ -1,13 +1,13 @@
/**
* connection-config.cjs
* connection-config.ts
*
* Pure, electron-free helpers for the desktop's remote-gateway connection
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
* auth-mode classification, and the auth-mode coercion rules.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` same pattern as backend-probes.cjs / bootstrap-platform.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
* Kept standalone (no `import 'electron'`) so it can be unit-tested with
* `node --test` same pattern as backend-probes.ts / bootstrap-platform.ts.
* main.ts requires these and wires them into the electron-coupled IPC layer.
*
* Background on the two auth models a remote gateway can use:
* - 'token': legacy static dashboard session token. REST uses an
@@ -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()
@@ -45,6 +54,7 @@ function normalizeRemoteBaseUrl(rawUrl) {
}
let parsed
try {
parsed = new URL(value)
} catch (error) {
@@ -83,7 +93,7 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* exercise the same transport the app actually uses.
*
* The OAuth ticket-minter is injected (`mintTicket(baseUrl) -> Promise<ticket>`)
* so this stays electron-free and unit-testable; main.cjs passes the real
* so this stays electron-free and unit-testable; main.ts passes the real
* `mintGatewayWsTicket`.
*
* Return semantics:
@@ -93,7 +103,7 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* - oauth, mint fails THROWS (NOT a skip)
*
* The oauth-mint-failure throw is the important case: the real boot path
* (resolveRemoteBackend in main.cjs) treats a mint failure as a hard
* (resolveRemoteBackend in main.ts) treats a mint failure as a hard
* "session expired" auth error and refuses to connect. Swallowing it here
* would re-introduce the exact false-positive this test exists to catch
* HTTP /api/status passes, the test reports "reachable", then the renderer
@@ -105,13 +115,16 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* @param {{ mintTicket: (baseUrl: string) => Promise<string> }} deps
* @returns {Promise<string|null>}
*/
async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) {
if (authMode === 'oauth') {
const mintTicket = deps.mintTicket
if (typeof mintTicket !== 'function') {
throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.')
}
let ticket
try {
ticket = await mintTicket(baseUrl)
} catch (error) {
@@ -119,15 +132,19 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
'(it may have expired). Open Settings → Gateway and sign in again.'
)
err.needsOauthLogin = true
;(err as any).needsOauthLogin = true
err.cause = error
throw err
}
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
}
if (!token) {
return null
}
return buildGatewayWsUrl(baseUrl, token)
}
@@ -142,23 +159,36 @@ 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.cjs 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
}
const url = String(entry.url || '').trim()
if (!url) {
return null
}
@@ -172,18 +202,21 @@ function profileRemoteOverride(config, profile) {
* query parameter. Local pooled backends and per-profile remote overrides do not
* need this: they already run against a backend scoped to the target profile.
*/
function pathWithGlobalRemoteProfile(path, profile, opts = {}) {
function pathWithGlobalRemoteProfile(path, profile, opts: any = {}) {
const scopedProfile = connectionScopeKey(profile)
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
return path
}
const rawPath = String(path || '')
if (!rawPath) {
return path
}
let parsed
try {
parsed = new URL(rawPath, 'http://hermes.local')
} catch {
@@ -224,9 +257,18 @@ function authModeFromStatus(statusBody) {
* Returns 'oauth' | 'token'.
*/
function resolveAuthMode(inputAuthMode, existingAuthMode) {
if (inputAuthMode === 'oauth') return 'oauth'
if (inputAuthMode === 'token') return 'token'
if (existingAuthMode === 'oauth') return 'oauth'
if (inputAuthMode === 'oauth') {
return 'oauth'
}
if (inputAuthMode === 'token') {
return 'token'
}
if (existingAuthMode === 'oauth') {
return 'oauth'
}
return 'token'
}
@@ -242,7 +284,10 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
* need to know whether an unexpired access token is present right now.
*/
function cookiesHaveSession(cookies) {
if (!Array.isArray(cookies)) return false
if (!Array.isArray(cookies)) {
return false
}
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
}
@@ -260,24 +305,46 @@ function cookiesHaveSession(cookies) {
* the RT is also dead/revoked).
*/
function cookiesHaveLiveSession(cookies) {
if (!Array.isArray(cookies)) return false
if (!Array.isArray(cookies)) {
return false
}
return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
}
module.exports = {
/**
* 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,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
cookiesHavePrivySession,
cookiesHaveSession,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normAuthMode,
pathWithGlobalRemoteProfile,
PRIVY_SESSION_COOKIE_VARIANTS,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
tokenPreview
}

View File

@@ -1,21 +1,22 @@
/**
* Tests for electron/dashboard-token.cjs.
* Tests for electron/dashboard-token.ts.
*
* Run with: node --test electron/dashboard-token.test.cjs
* Run with: node --test electron/dashboard-token.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
const {
import { test } from 'vitest'
import {
adoptServedDashboardToken,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,
resolveServedDashboardToken
} = require('./dashboard-token.cjs')
} from './dashboard-token'
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
@@ -39,9 +40,11 @@ test('dashboardIndexUrl preserves dashboard path prefixes', () => {
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
const logs = []
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async url => {
assert.equal(url, 'http://127.0.0.1:9120/')
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
},
rememberLog: line => logs.push(line)
@@ -100,8 +103,9 @@ test('isForeignBackendToken only flags a mismatched token from a dead child', ()
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
]
for (const [input, expected] of cases) {
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
assert.equal(isForeignBackendToken(input as any), expected, JSON.stringify(input))
}
})
@@ -128,6 +132,7 @@ test('adoptServedDashboardToken refuses a foreign token when our child is dead',
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
const logs = []
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true,
fetchText: async () => {

View File

@@ -9,29 +9,39 @@
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
async function fetchPublicText(url, options = {}) {
async function fetchPublicText(url, options: any = {}) {
const { protocol } = new URL(url)
if (protocol !== 'http:' && protocol !== 'https:') {
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
if (error.name === 'TimeoutError') {
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
}
throw error
})
const text = await res.text()
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
if (!res.ok) {
throw new Error(`${res.status}: ${text || res.statusText}`)
}
return text
}
function extractInjectedDashboardToken(html) {
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
if (!match) return null
if (!match) {
return null
}
try {
return JSON.parse(match[1])
} catch {
@@ -43,11 +53,13 @@ function dashboardIndexUrl(baseUrl) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
}
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
async function resolveServedDashboardToken(baseUrl, fallbackToken, options: any = {}) {
const fetchText = options.fetchText || fetchPublicText
const html = await fetchText(dashboardIndexUrl(baseUrl), {
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
})
const servedToken = extractInjectedDashboardToken(html)
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
@@ -76,6 +88,7 @@ function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
return spawnToken
})
@@ -88,10 +101,10 @@ async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, labe
return servedToken
}
module.exports = {
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
export {
adoptServedDashboardToken,
dashboardIndexUrl,
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,

View File

@@ -1,7 +1,7 @@
/**
* Tests for electron/desktop-uninstall.cjs.
* Tests for electron/desktop-uninstall.ts.
*
* Run with: node --test electron/desktop-uninstall.test.cjs
* Run with: node --test electron/desktop-uninstall.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the desktop Chat GUI uninstaller: the
@@ -9,19 +9,20 @@
* cleanup-script builders (POSIX + Windows).
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
const {
UNINSTALL_MODES,
import { test } from 'vitest'
import {
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
UNINSTALL_MODES,
uninstallArgsForMode
} = require('./desktop-uninstall.cjs')
} from './desktop-uninstall'
// --- uninstallArgsForMode ---
@@ -132,6 +133,7 @@ test('buildPosixCleanupScript waits for the PID, runs the uninstall module, remo
appPath: '/opt/hermes/linux-unpacked',
hermesHome: '/home/x/.hermes'
})
assert.match(script, /^#!\/bin\/bash/)
assert.match(script, /pid=4321/)
assert.match(script, /kill -0 "\$pid"/)
@@ -152,6 +154,7 @@ test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/fu
appPath: null,
hermesHome: '/home/x/.hermes'
})
// System python + source on PYTHONPATH so import hermes_cli works while the
// venv is torn down.
assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/)
@@ -168,6 +171,7 @@ test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', (
appPath: null,
hermesHome: '/h'
})
assert.doesNotMatch(script, /export PYTHONPATH/)
})
@@ -181,6 +185,7 @@ test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => {
appPath: null,
hermesHome: '/h'
})
assert.doesNotMatch(script, /rm -rf '\//)
// Still runs the uninstall.
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/)
@@ -196,6 +201,7 @@ test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () =
appPath: null,
hermesHome: '/h'
})
// The apostrophe is closed-escaped-reopened so the shell sees the literal.
assert.match(script, /'\/home\/o'\\''brien\/python'/)
})
@@ -212,6 +218,7 @@ test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir b
appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes',
hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes'
})
assert.match(script, /@echo off/)
assert.match(script, /set "PID=9988"/)
// PYTHONPATH set so a system python can import hermes_cli from source.
@@ -238,6 +245,7 @@ test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, n
appPath: null,
hermesHome: 'C:\\h'
})
assert.doesNotMatch(script, /rmdir/)
assert.doesNotMatch(script, /set "PYTHONPATH=/)
})

View File

@@ -1,14 +1,14 @@
/**
* desktop-uninstall.cjs
* desktop-uninstall.ts
*
* Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map
* the three user-facing uninstall modes to the `hermes uninstall` CLI flags,
* resolve the running app bundle/exe so a detached cleanup script can remove
* it after the app quits, and build that cleanup script for each OS.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` same pattern as connection-config.cjs / backend-probes.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
* Kept standalone (no ` import 'electron'`) so it can be unit-tested with
* `node --test` same pattern as connection-config.ts / backend-probes.ts.
* main.ts requires these and wires them into the electron-coupled IPC layer.
*
* The three modes mirror the CLI's options exactly:
* - 'gui' remove ONLY the Chat GUI, keep the agent + all user data.
@@ -23,10 +23,10 @@
* app bundle (locked on macOS/Windows while the process is alive). So we hand
* the work to a detached child that waits for this app's PID to exit, runs the
* Python uninstall, then removes the app bundle then the app quits. Same
* shape as the self-update swap-and-relaunch flow already in main.cjs.
* shape as the self-update swap-and-relaunch flow already in main.ts.
*/
const path = require('node:path')
import path from 'node:path'
const UNINSTALL_MODES = ['gui', 'lite', 'full']
@@ -41,6 +41,7 @@ function uninstallArgsForMode(mode) {
if (!UNINSTALL_MODES.includes(mode)) {
throw new Error(`Unknown uninstall mode: ${mode}`)
}
return ['-m', 'hermes_cli.uninstall', '--mode', mode]
}
@@ -65,9 +66,12 @@ function modeRemovesUserData(mode) {
* Returns null when we can't confidently identify a removable bundle (e.g.
* running from a dev checkout, or a system-package install we must not rmtree).
*/
function resolveRemovableAppPath(execPath, platform, env = {}) {
function resolveRemovableAppPath(execPath, platform, env: any = {}) {
const exe = String(execPath || '')
if (!exe) return null
if (!exe) {
return null
}
// Use the path flavor that matches the TARGET platform, not the host running
// this code — so the Windows branch parses backslash paths correctly even
@@ -79,22 +83,37 @@ function resolveRemovableAppPath(execPath, platform, env = {}) {
const macOsDir = p.dirname(exe) // …/Contents/MacOS
const contents = p.dirname(macOsDir) // …/Contents
const appBundle = p.dirname(contents) // …/Hermes.app
if (appBundle.endsWith('.app')) return appBundle
if (appBundle.endsWith('.app')) {
return appBundle
}
return null
}
if (platform === 'win32') {
// NSIS per-user installs Hermes.exe directly in the install dir.
const dir = p.dirname(exe)
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) {
return dir
}
return null
}
// Linux: an AppImage exposes its own path via the APPIMAGE env var.
if (env.APPIMAGE) return env.APPIMAGE
if (env.APPIMAGE) {
return env.APPIMAGE
}
// Unpacked electron-builder tree: …/linux-unpacked/hermes
const dir = p.dirname(exe)
if (/-unpacked$/.test(dir)) return dir
if (/-unpacked$/.test(dir)) {
return dir
}
return null
}
@@ -121,6 +140,7 @@ function shouldRemoveAppBundle(isPackaged, appPath) {
*/
function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
const lines = [
'#!/bin/bash',
'set -u',
@@ -135,16 +155,21 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
'fi',
`export HERMES_HOME=${q(hermesHome)}`
]
if (pythonPath) {
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
}
lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`)
if (appPath) {
lines.push(`rm -rf ${q(appPath)} || true`)
}
// Self-delete the script.
lines.push('rm -f "$0" 2>/dev/null || true')
lines.push('')
return lines.join('\n')
}
@@ -180,15 +205,18 @@ function buildWindowsCleanupScript({
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
// a problem, but Hermes install paths don't use them.
const q = s => `"${String(s).replace(/"/g, '')}"`
const lines = [
'@echo off',
'setlocal enableextensions',
`set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`,
`set "PID=${pid}"`
]
if (pythonPath) {
lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`)
}
lines.push(
'set /a waited=0',
':waitloop',
@@ -206,6 +234,7 @@ function buildWindowsCleanupScript({
`cd /d ${q(agentRoot)}`,
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}`
)
if (appPath) {
lines.push(
'set /a tries=0',
@@ -220,18 +249,20 @@ function buildWindowsCleanupScript({
':rmdone'
)
}
lines.push('del "%~f0"')
lines.push('')
return lines.join('\r\n')
}
module.exports = {
UNINSTALL_MODES,
export {
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
UNINSTALL_MODES,
uninstallArgsForMode
}

View File

@@ -1,9 +1,8 @@
'use strict'
const { session } = require('electron')
import { session } from 'electron'
const EMBED_SESSION_PARTITION = 'persist:hermes-embed'
const EMBED_REFERER = 'https://www.youtube.com/'
const YOUTUBE_REFERER_HOST_RE =
/(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i
@@ -23,6 +22,7 @@ function installEmbedRefererForSession(embedSession) {
if (!YOUTUBE_REFERER_HOST_RE.test(host)) {
callback({ requestHeaders: details.requestHeaders })
return
}
@@ -45,4 +45,4 @@ function installEmbedReferer() {
}
}
module.exports = { installEmbedReferer }
export { installEmbedReferer }

View File

@@ -1,19 +1,18 @@
'use strict'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { pathToFileURL } = require('node:url')
import { test } from 'vitest'
const { readDirForIpc } = require('./fs-read-dir.cjs')
import { readDirForIpc } from './fs-read-dir'
function mkTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
}
function fakeDirent(name, flags = {}) {
function fakeDirent(name, flags: any = {}) {
return {
name,
isDirectory: () => Boolean(flags.directory),
@@ -109,10 +108,12 @@ test('readDirForIpc accepts file URLs for directories', async () => {
test('readDirForIpc returns invalid-path for blank or non-string input', async () => {
let readdirCalls = 0
const fsImpl = {
promises: {
readdir: async () => {
readdirCalls += 1
return []
}
}
@@ -126,10 +127,12 @@ test('readDirForIpc returns invalid-path for blank or non-string input', async (
test('readDirForIpc rejects Windows device paths before readdir', async () => {
let readdirCalls = 0
const fsImpl = {
promises: {
readdir: async () => {
readdirCalls += 1
return []
}
}
@@ -224,6 +227,7 @@ test('readDirForIpc allows expanding symlink or junction directories outside the
fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
const linkPath = path.join(root, 'outside-link')
try {
fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
} catch (error) {
@@ -252,6 +256,7 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th
const input = path.join('virtual-root')
const resolved = path.resolve(input)
const statCalls = []
const fsImpl = {
promises: {
readdir: async () => [
@@ -266,9 +271,11 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th
}
statCalls.push(fullPath)
if (fullPath.endsWith(`${path.sep}linked-dir`)) {
return { isDirectory: () => true }
}
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
}
}
@@ -301,12 +308,15 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
let peak = 0
let releaseStats
let markFirstStatStarted
const statsReleased = new Promise(resolve => {
releaseStats = resolve
})
const firstStatStarted = new Promise(resolve => {
markFirstStatStarted = resolve
})
const fsImpl = {
promises: {
readdir: async () => [
@@ -326,6 +336,7 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
active -= 1
const name = path.basename(fullPath)
if (name === failedName) {
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
}

View File

@@ -1,8 +1,7 @@
'use strict'
import fs from 'node:fs'
import path from 'node:path'
const fs = require('node:fs')
const path = require('node:path')
const { resolveDirectoryForIpc } = require('./hardening.cjs')
import { resolveDirectoryForIpc } from './hardening'
const FS_READDIR_STAT_CONCURRENCY = 16
@@ -37,7 +36,9 @@ function direntIsSymbolicLink(dirent) {
}
function shouldStatDirent(dirent) {
if (direntIsDirectory(dirent)) return false
if (direntIsDirectory(dirent)) {
return false
}
return direntIsSymbolicLink(dirent) || !direntIsFile(dirent)
}
@@ -70,13 +71,13 @@ async function mapWithStatConcurrency(items, mapper) {
}
const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length)
const workers = Array.from({ length: workerCount }, () => runWorker())
const workers = Array.from({ length: workerCount } as any, () => runWorker())
await Promise.all(workers)
return results
}
async function readDirForIpc(dirPath, options = {}) {
async function readDirForIpc(dirPath, options: any = {}) {
const fsImpl = options.fs || fs
let resolved
@@ -102,6 +103,4 @@ async function readDirForIpc(dirPath, options = {}) {
}
}
module.exports = {
readDirForIpc
}
export { readDirForIpc }

View File

@@ -1,7 +1,7 @@
/**
* Tests for electron/gateway-ws-probe.cjs.
* Tests for electron/gateway-ws-probe.ts.
*
* Run with: node --test electron/gateway-ws-probe.test.cjs
* Run with: node --test electron/gateway-ws-probe.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*
* The probe drives a real WebSocket handshake for the "Test remote" button.
@@ -9,16 +9,21 @@
* outcome (open, frame, error, early close, never-opens) without a network.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
import { test } from 'vitest'
import { probeGatewayWebSocket } from './gateway-ws-probe'
// Minimal WebSocket double: records listeners synchronously (the probe attaches
// them in its executor) and exposes emit() so the test can replay events.
function makeFakeWs() {
function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } {
const instances = []
class FakeWs {
url: string
closed = false
listeners: Record<string, any[]> = {}
constructor(url) {
this.url = url
this.listeners = {}
@@ -32,9 +37,12 @@ function makeFakeWs() {
this.closed = true
}
emit(type, event) {
for (const fn of this.listeners[type] || []) fn(event)
for (const fn of this.listeners[type] || []) {
fn(event)
}
}
}
return { FakeWs, instances }
}
@@ -51,11 +59,13 @@ test('probe resolves ok when the socket opens and stays open', async () => {
test('probe resolves ok immediately when a frame arrives', async () => {
const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', {
WebSocketImpl: FakeWs,
connectTimeoutMs: 1_000,
readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer
})
instances[0].emit('open')
instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' })
const result = await promise
@@ -95,11 +105,13 @@ test('probe fails when the gateway accepts then immediately closes (auth rejecte
test('probe times out when the socket never opens', async () => {
const { FakeWs } = makeFakeWs()
const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', {
WebSocketImpl: FakeWs,
connectTimeoutMs: 20,
readyGraceMs: 10
})
assert.equal(result.ok, false)
assert.match(result.reason, /Timed out/)
})

View File

@@ -36,13 +36,16 @@ const DEFAULT_READY_GRACE_MS = 750
* Attempt a live WebSocket connection and classify the outcome.
*
* @param {string} wsUrl - Fully-formed ws(s):// URL including the credential.
* @param {object} [options]
* @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor.
* @param {number} [options.connectTimeoutMs]
* @param {number} [options.readyGraceMs]
* @returns {Promise<{ ok: boolean, reason?: string }>}
*/
function probeGatewayWebSocket(wsUrl, options = {}) {
function probeGatewayWebSocket<T>(
wsUrl: string,
options: {
WebSocketImpl?: any
connectTimeoutMs?: number
readyGraceMs?: number
} = {}
) {
const WebSocketImpl = options.WebSocketImpl
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS
@@ -54,7 +57,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
})
}
return new Promise(resolve => {
return new Promise<any>(resolve => {
let settled = false
let opened = false
let connectTimer = null
@@ -66,6 +69,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
clearTimeout(connectTimer)
connectTimer = null
}
if (graceTimer !== null) {
clearTimeout(graceTimer)
graceTimer = null
@@ -73,14 +77,19 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
}
const finish = result => {
if (settled) return
if (settled) {
return
}
settled = true
clearTimers()
try {
socket?.close?.()
} catch {
// ignore — best effort teardown
}
resolve(result)
}
@@ -91,11 +100,15 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
ok: false,
reason: error instanceof Error ? error.message : String(error)
})
return
}
const onOpen = () => {
if (settled) return
if (settled) {
return
}
opened = true
// Upgrade accepted. Give the server a brief window to reject the
// credential post-handshake (early close) before declaring success.
@@ -118,7 +131,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
}
const onClose = event => {
if (settled) return
if (settled) {
return
}
if (opened) {
// Opened, then closed inside the grace window: the upgrade was accepted
// but the session was refused (e.g. ws-ticket/token rejected, or a
@@ -127,8 +143,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
ok: false,
reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).')
})
return
}
finish({
ok: false,
reason: closeReason(event, 'The gateway closed the WebSocket before it opened.')
@@ -154,8 +172,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
function addListener(socket, type, handler) {
if (typeof socket.addEventListener === 'function') {
socket.addEventListener(type, handler)
return
}
// Node's global WebSocket implements addEventListener; this fallback keeps the
// helper usable with the `ws` package's EventEmitter shape too.
if (typeof socket.on === 'function') {
@@ -164,25 +184,44 @@ function addListener(socket, type, handler) {
}
function extractErrorReason(event) {
if (!event) return ''
if (event instanceof Error) return event.message
if (!event) {
return ''
}
if (event instanceof Error) {
return event.message
}
const err = event.error || event.message
if (err instanceof Error) return err.message
if (typeof err === 'string') return err
if (err instanceof Error) {
return err.message
}
if (typeof err === 'string') {
return err
}
return ''
}
function closeReason(event, fallback) {
const code = event && typeof event.code === 'number' ? event.code : null
const reason = event && typeof event.reason === 'string' ? event.reason.trim() : ''
if (code && reason) return `${fallback} (code ${code}: ${reason})`
if (code) return `${fallback} (code ${code})`
if (reason) return `${fallback} (${reason})`
if (code && reason) {
return `${fallback} (code ${code}: ${reason})`
}
if (code) {
return `${fallback} (code ${code})`
}
if (reason) {
return `${fallback} (${reason})`
}
return fallback
}
module.exports = {
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_READY_GRACE_MS,
probeGatewayWebSocket
}
export { DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READY_GRACE_MS, probeGatewayWebSocket }

View File

@@ -1,14 +1,12 @@
'use strict'
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
// — no native addon, so it just works for anyone who pulls main (no
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
// first scan stays fast. Results are cached by the backend after the first run.
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const fsp = fs.promises
@@ -36,14 +34,14 @@ async function mapLimit(items, limit, fn) {
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker))
}
/**
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
*/
async function scanGitRepos(roots, options = {}) {
async function scanGitRepos(roots, options: any = {}) {
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
const found = new Map()
@@ -54,6 +52,7 @@ async function scanGitRepos(roots, options = {}) {
}
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
@@ -73,6 +72,7 @@ async function scanGitRepos(roots, options = {}) {
}
const subdirs = []
for (const entry of entries) {
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
// known heavy trees.
@@ -93,4 +93,4 @@ async function scanGitRepos(roots, options = {}) {
return [...found.entries()].map(([root, label]) => ({ label, root }))
}
module.exports = { scanGitRepos }
export { scanGitRepos }

View File

@@ -1,9 +1,8 @@
'use strict'
import assert from 'node:assert/strict'
const assert = require('node:assert/strict')
const test = require('node:test')
import { test } from 'vitest'
const { resolveRenamePath } = require('./git-review-ops.cjs')
import { resolveRenamePath } from './git-review-ops'
test('resolveRenamePath: plain path is unchanged', () => {
assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts')

View File

@@ -1,37 +1,16 @@
'use strict'
// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git`
// (a maintained wrapper around the system git binary — same git the rest of the
// app shells to, no native build) so we read structured status()/diffSummary()
// results instead of hand-parsing porcelain. Reads degrade to null/empty on a
// non-repo / remote backend; mutations reject so the renderer can toast.
const { execFile } = require('node:child_process')
const fs = require('node:fs/promises')
const path = require('node:path')
import { execFile } from 'node:child_process'
import fs from 'node:fs/promises'
import path from 'node:path'
// `simple-git` is a pure-JS runtime dep that workspace dedup hoists into the
// repo-root node_modules. Packaged builds set `files:` in package.json, which
// excludes node_modules from the asar, so the normal require() fails at launch
// (issue #52735: "Cannot find module 'simple-git'"). We ship the dep's
// closure under resources/native-deps/vendor/node_modules/ via extraResources
// + scripts/stage-native-deps.cjs, and resolve from there when the hoisted
// require() isn't reachable. The `vendor/` nesting matters: electron-builder
// drops a node_modules dir at the root of an extraResources copy but keeps a
// nested one. Dev mode never hits the fallback -- Node's normal lookup finds
// the hoisted copy.
let simpleGit
try {
simpleGit = require('simple-git')
} catch {
const resourcesPath = process.resourcesPath
if (!resourcesPath) {
throw new Error("git-review IPC: 'simple-git' not found and no resourcesPath to fall back to")
}
simpleGit = require(path.join(resourcesPath, 'native-deps', 'vendor', 'node_modules', 'simple-git'))
}
import simpleGit from 'simple-git'
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
import { resolveRequestedPathForIpc } from './hardening'
const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000
const COMMIT_CONTEXT_UNTRACKED_MAX = 80
@@ -52,7 +31,7 @@ function ghEnv(ghBin) {
// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on
// availability/auth without a throw. gh missing/unauthed → ok:false.
function runGh(args, cwd, ghBin) {
function runGh(args, cwd, ghBin): Promise<{ ok: boolean; stdout: string }> {
return new Promise(resolve => {
execFile(
ghBin || 'gh',
@@ -260,10 +239,11 @@ async function reviewList(repoPath, scope, baseRef, gitBin) {
const range = scope === 'branch' ? `${base}...HEAD` : base
const summary = await git.diffSummary([range])
const files = summary.files.map(file => ({
path: resolveRenamePath(file.file),
added: file.binary ? 0 : file.insertions,
removed: file.binary ? 0 : file.deletions,
added: 'insertions' in file ? file.insertions : 0,
removed: 'deletions' in file ? file.deletions : 0,
status: 'M',
staged: false
}))
@@ -291,6 +271,7 @@ async function reviewList(repoPath, scope, baseRef, gitBin) {
git.diffSummary(['--cached']),
git.diffSummary([])
])
const stagedCounts = countsByPath(staged)
const unstagedCounts = countsByPath(unstaged)
@@ -495,6 +476,7 @@ async function reviewCommitContext(repoPath, gitBin) {
const safe = args => git.diff(args).catch(() => '')
let status
try {
status = await git.status()
} catch {
@@ -510,9 +492,11 @@ async function reviewCommitContext(repoPath, gitBin) {
// Untracked files have no diff — list them so new files aren't invisible.
const untracked = status.not_added || []
if (untracked.length > 0) {
const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX)
const omitted = untracked.length - visible.length
const note =
`\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` +
(omitted > 0 ? `# ... ${omitted} more omitted\n` : '')
@@ -607,6 +591,7 @@ async function repoStatus(repoPath, gitBin) {
// fail soft and hide the coding rail instead of spamming IPC handler errors.
try {
const stat = await fs.stat(cwd)
if (!stat.isDirectory()) {
return null
}
@@ -615,11 +600,13 @@ async function repoStatus(repoPath, gitBin) {
}
let git
try {
git = gitFor(cwd, gitBin)
} catch {
return null
}
let status
try {
@@ -630,6 +617,7 @@ async function repoStatus(repoPath, gitBin) {
}
const detached = typeof status.detached === 'boolean' ? status.detached : !status.current
const files = status.files.map(file => ({
path: file.path,
staged: isStaged(file),
@@ -671,10 +659,12 @@ async function repoStatus(repoPath, gitBin) {
// can't stall the probe.
try {
const untracked = status.not_added.slice(0, 500)
for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
const batch = await Promise.all(
untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path))
)
result.added += batch.reduce((sum, n) => sum + n, 0)
}
} catch {
@@ -684,7 +674,7 @@ async function repoStatus(repoPath, gitBin) {
return result
}
module.exports = {
export {
branchBase,
fileDiffVsHead,
repoStatus,
@@ -695,8 +685,8 @@ module.exports = {
reviewDiff,
reviewList,
reviewPush,
reviewRevParse,
reviewRevert,
reviewRevParse,
reviewShipInfo,
reviewStage,
reviewUnstage

View File

@@ -1,40 +0,0 @@
'use strict'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { pathToFileURL } = require('node:url')
const { gitRootForIpc } = require('./git-root.cjs')
function mkTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))
}
test('gitRootForIpc returns null for invalid and device paths', async () => {
assert.equal(await gitRootForIpc(''), null)
assert.equal(await gitRootForIpc(' '), null)
assert.equal(await gitRootForIpc(null), null)
assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null)
assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
})
test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => {
const root = mkTmpDir()
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
const gitDir = path.join(root, '.git')
const srcDir = path.join(root, 'src')
const filePath = path.join(srcDir, 'index.ts')
fs.mkdirSync(gitDir)
fs.mkdirSync(srcDir)
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
assert.equal(await gitRootForIpc(root), root)
assert.equal(await gitRootForIpc(srcDir), root)
assert.equal(await gitRootForIpc(filePath), root)
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
})

View File

@@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { test } from 'vitest'
import { gitRootForIpc } from './git-root'
function mkTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))
}
test('gitRootForIpc returns null for invalid and device paths', async () => {
assert.equal(await gitRootForIpc(''), null)
assert.equal(await gitRootForIpc(' '), null)
assert.equal(await gitRootForIpc(null), null)
assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null)
assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
})
test('gitRootForIpc resolves directories files missing descendants and file URLs', async () => {
const root = mkTmpDir()
try {
const gitDir = path.join(root, '.git')
const srcDir = path.join(root, 'src')
const filePath = path.join(srcDir, 'index.ts')
fs.mkdirSync(gitDir)
fs.mkdirSync(srcDir)
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
assert.equal(await gitRootForIpc(root), root)
assert.equal(await gitRootForIpc(srcDir), root)
assert.equal(await gitRootForIpc(filePath), root)
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})

View File

@@ -1,8 +1,7 @@
'use strict'
import fs from 'node:fs'
import path from 'node:path'
const fs = require('node:fs')
const path = require('node:path')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
import { resolveRequestedPathForIpc } from './hardening'
function findGitRoot(start, fsImpl = fs) {
let dir = start
@@ -28,7 +27,7 @@ function findGitRoot(start, fsImpl = fs) {
return null
}
async function gitRootForIpc(startPath, options = {}) {
async function gitRootForIpc(startPath, options: { fs?: typeof fs } = {}) {
const fsImpl = options.fs || fs
let resolved
@@ -48,7 +47,4 @@ async function gitRootForIpc(startPath, options = {}) {
}
}
module.exports = {
findGitRoot,
gitRootForIpc
}
export { findGitRoot, gitRootForIpc }

View File

@@ -1,20 +1,19 @@
'use strict'
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const assert = require('node:assert/strict')
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
import { test } from 'vitest'
const {
import {
addWorktree,
ensureGitRepo,
listBranches,
parseWorktrees,
sanitizeBranch,
switchBranch
} = require('./git-worktree-ops.cjs')
} from './git-worktree-ops'
test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => {
assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes')

View File

@@ -1,16 +1,14 @@
'use strict'
// Git-driven worktree operations for the desktop "Start work" flow: spin up a
// fresh worktree the lightest way (`git worktree add -b`), list real worktrees,
// and remove them. Git is the source of truth; the renderer just drives these.
const path = require('node:path')
const fs = require('node:fs')
const { execFile } = require('node:child_process')
import { execFile } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
import { resolveRequestedPathForIpc } from './hardening'
function runGit(gitBin, args, cwd) {
function runGit(gitBin, args, cwd): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
gitBin,
@@ -306,6 +304,7 @@ async function listBranches(repoPath, gitBin) {
['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'],
resolved
)
const trees = await listWorktrees(resolved, gitBin)
const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path]))
const trunk = await defaultBranch(gitBin, resolved)
@@ -338,7 +337,7 @@ async function switchBranch(repoPath, branch, gitBin) {
return { branch: target }
}
module.exports = {
export {
addWorktree,
ensureGitRepo,
listBranches,

View File

@@ -1,279 +0,0 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { pathToFileURL } = require('node:url')
const {
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret,
resolveDirectoryForIpc,
resolveReadableFileForIpc,
resolveRequestedPathForIpc,
resolveTimeoutMs,
sensitiveFileBlockReason
} = require('./hardening.cjs')
async function rejectsWithCode(promise, code) {
await assert.rejects(promise, error => {
assert.equal(error?.code, code)
return true
})
}
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs('2750'), 2750)
})
test('encryptDesktopSecret requires available secure storage', () => {
assert.equal(
encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }),
null
)
assert.throws(
() => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }),
/Secure token storage is unavailable/
)
})
test('encryptDesktopSecret stores safeStorage base64 payload', () => {
const secret = encryptDesktopSecret('token-123', {
isEncryptionAvailable: () => true,
encryptString: value => Buffer.from(`enc:${value}`, 'utf8')
})
assert.deepEqual(secret, {
encoding: 'safeStorage',
value: Buffer.from('enc:token-123', 'utf8').toString('base64')
})
})
test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/)
assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null)
assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/)
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
})
test('path helpers reject blank non-string NUL and Windows device syntax', async () => {
await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path')
await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path')
await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path')
await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path')
const devicePaths = [
'\\\\?\\C:\\secret.txt',
'\\\\.\\C:\\secret.txt',
'\\\\?\\UNC\\server\\share\\secret.txt',
'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt'
]
for (const devicePath of devicePaths) {
assert.throws(
() => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
error => {
assert.equal(error?.code, 'device-path')
return true
}
)
await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path')
}
assert.throws(
() => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
error => {
assert.equal(error?.code, 'invalid-path')
return true
}
)
await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path')
})
test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => {
const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base')
assert.equal(
resolveRequestedPathForIpc('notes.txt', {
baseDir: ` ${baseDir} `,
purpose: 'File preview'
}),
path.resolve(baseDir, 'notes.txt')
)
})
test('resolveRequestedPathForIpc expands ~ to the home directory', () => {
assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir()))
assert.equal(
resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }),
path.resolve(os.homedir(), 'www/project')
)
// `~user` shorthand is NOT expanded — only the caller's own home.
assert.equal(
resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }),
path.resolve(os.tmpdir(), '~other/secret')
)
})
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const textPath = path.join(tempDir, 'notes.txt')
fs.writeFileSync(textPath, 'hello world', 'utf8')
const fromRelative = await resolveReadableFileForIpc('notes.txt', {
baseDir: tempDir,
maxBytes: 256,
purpose: 'File preview'
})
assert.equal(fromRelative.resolvedPath, textPath)
assert.equal(fromRelative.stat.size, 11)
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
purpose: 'File preview'
})
assert.equal(fromFileUrl.resolvedPath, textPath)
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
purpose: 'File preview'
})
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
await assert.rejects(
resolveReadableFileForIpc('missing.txt', {
baseDir: tempDir,
purpose: 'Text preview'
}),
/file does not exist/
)
const nestedDir = path.join(tempDir, 'directory')
fs.mkdirSync(nestedDir)
await assert.rejects(
resolveReadableFileForIpc(nestedDir, {
purpose: 'Text preview'
}),
/path points to a directory/
)
const largePath = path.join(tempDir, 'large.txt')
fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8')
await assert.rejects(
resolveReadableFileForIpc(largePath, {
maxBytes: 8,
purpose: 'File preview'
}),
/file is too large/
)
const envPath = path.join(tempDir, '.env')
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
await assert.rejects(
resolveReadableFileForIpc(envPath, {
purpose: 'File preview'
}),
/blocked for sensitive file/
)
const envTemplatePath = path.join(tempDir, '.env.example')
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
purpose: 'File preview'
})
assert.equal(envTemplate.resolvedPath, envTemplatePath)
})
test('resolveReadableFileForIpc blocks common sensitive files', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const sshDir = path.join(tempDir, '.ssh')
fs.mkdirSync(sshDir)
const blockedFiles = [
path.join(tempDir, '.env'),
path.join(tempDir, '.npmrc'),
path.join(sshDir, 'id_ed25519'),
path.join(tempDir, 'cert.pem'),
path.join(tempDir, 'cert.p12'),
path.join(tempDir, 'cert.pfx')
]
for (const filePath of blockedFiles) {
fs.writeFileSync(filePath, 'secret', 'utf8')
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
}
const allowed = path.join(tempDir, '.env.example')
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
})
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const envPath = path.join(tempDir, '.env')
const linkPath = path.join(tempDir, 'safe-name.txt')
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
try {
fs.symlinkSync(envPath, linkPath, 'file')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
return
}
throw error
}
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
})
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const directory = path.join(tempDir, 'project')
const filePath = path.join(tempDir, 'file.txt')
fs.mkdirSync(directory)
fs.writeFileSync(filePath, 'not a directory', 'utf8')
const resolved = await resolveDirectoryForIpc(directory)
assert.equal(resolved.resolvedPath, directory)
assert.equal(resolved.stat.isDirectory(), true)
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
})
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const directory = path.join(tempDir, 'actual-project')
const linkPath = path.join(tempDir, 'linked-project')
fs.mkdirSync(directory)
try {
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
return
}
throw error
}
const resolved = await resolveDirectoryForIpc(linkPath)
assert.equal(resolved.resolvedPath, linkPath)
assert.equal(resolved.stat.isDirectory(), true)
})

View File

@@ -0,0 +1,306 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { test } from 'vitest'
import {
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret,
resolveDirectoryForIpc,
resolveReadableFileForIpc,
resolveRequestedPathForIpc,
resolveTimeoutMs,
sensitiveFileBlockReason
} from './hardening'
async function rejectsWithCode(promise, code: string) {
await assert.rejects(promise, (error: any) => {
assert.equal(error?.code, code)
return true
})
}
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs('2750'), 2750)
})
test('encryptDesktopSecret requires available secure storage', () => {
assert.equal(
encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }),
null
)
assert.throws(
() => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }),
/Secure token storage is unavailable/
)
})
test('encryptDesktopSecret stores safeStorage base64 payload', () => {
const secret = encryptDesktopSecret('token-123', {
isEncryptionAvailable: () => true,
encryptString: value => Buffer.from(`enc:${value}`, 'utf8')
})
assert.deepEqual(secret, {
encoding: 'safeStorage',
value: Buffer.from('enc:token-123', 'utf8').toString('base64')
})
})
test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/)
assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null)
assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/)
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
})
test('path helpers reject blank non-string NUL and Windows device syntax', async () => {
await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path')
await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path')
await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path')
await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path')
const devicePaths = [
'\\\\?\\C:\\secret.txt',
'\\\\.\\C:\\secret.txt',
'\\\\?\\UNC\\server\\share\\secret.txt',
'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt'
]
for (const devicePath of devicePaths) {
assert.throws(
() => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
(error: any) => {
assert.equal(error?.code, 'device-path')
return true
}
)
await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path')
}
assert.throws(
() => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
(error: any) => {
assert.equal(error?.code, 'invalid-path')
return true
}
)
await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path')
})
test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => {
const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base')
assert.equal(
resolveRequestedPathForIpc('notes.txt', {
baseDir: ` ${baseDir} `,
purpose: 'File preview'
}),
path.resolve(baseDir, 'notes.txt')
)
})
test('resolveRequestedPathForIpc expands ~ to the home directory', () => {
assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir()))
assert.equal(
resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }),
path.resolve(os.homedir(), 'www/project')
)
// `~user` shorthand is NOT expanded — only the caller's own home.
assert.equal(
resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }),
path.resolve(os.tmpdir(), '~other/secret')
)
})
test('resolveReadableFileForIpc validates existence type size and sensitivity', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
try {
const textPath = path.join(tempDir, 'notes.txt')
fs.writeFileSync(textPath, 'hello world', 'utf8')
const fromRelative = await resolveReadableFileForIpc('notes.txt', {
baseDir: tempDir,
maxBytes: 256,
purpose: 'File preview'
})
assert.equal(fromRelative.resolvedPath, textPath)
assert.equal(fromRelative.stat.size, 11)
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
purpose: 'File preview'
})
assert.equal(fromFileUrl.resolvedPath, textPath)
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
purpose: 'File preview'
})
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
await assert.rejects(
resolveReadableFileForIpc('missing.txt', {
baseDir: tempDir,
purpose: 'Text preview'
}),
/file does not exist/
)
const nestedDir = path.join(tempDir, 'directory')
fs.mkdirSync(nestedDir)
await assert.rejects(
resolveReadableFileForIpc(nestedDir, {
purpose: 'Text preview'
}),
/path points to a directory/
)
const largePath = path.join(tempDir, 'large.txt')
fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8')
await assert.rejects(
resolveReadableFileForIpc(largePath, {
maxBytes: 8,
purpose: 'File preview'
}),
/file is too large/
)
const envPath = path.join(tempDir, '.env')
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
await assert.rejects(
resolveReadableFileForIpc(envPath, {
purpose: 'File preview'
}),
/blocked for sensitive file/
)
const envTemplatePath = path.join(tempDir, '.env.example')
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
purpose: 'File preview'
})
assert.equal(envTemplate.resolvedPath, envTemplatePath)
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})
test('resolveReadableFileForIpc blocks common sensitive files', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-'))
try {
const sshDir = path.join(tempDir, '.ssh')
fs.mkdirSync(sshDir)
const blockedFiles = [
path.join(tempDir, '.env'),
path.join(tempDir, '.npmrc'),
path.join(sshDir, 'id_ed25519'),
path.join(tempDir, 'cert.pem'),
path.join(tempDir, 'cert.p12'),
path.join(tempDir, 'cert.pfx')
]
for (const filePath of blockedFiles) {
fs.writeFileSync(filePath, 'secret', 'utf8')
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
}
const allowed = path.join(tempDir, '.env.example')
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-'))
try {
const envPath = path.join(tempDir, '.env')
const linkPath = path.join(tempDir, 'safe-name.txt')
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
try {
fs.symlinkSync(envPath, linkPath, 'file')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
// symlink creation is not permitted on this platform — skip
return
}
throw error
}
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-'))
try {
const directory = path.join(tempDir, 'project')
const filePath = path.join(tempDir, 'file.txt')
fs.mkdirSync(directory)
fs.writeFileSync(filePath, 'not a directory', 'utf8')
const resolved = await resolveDirectoryForIpc(directory)
assert.equal(resolved.resolvedPath, directory)
assert.equal(resolved.stat.isDirectory(), true)
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
try {
const directory = path.join(tempDir, 'actual-project')
const linkPath = path.join(tempDir, 'linked-project')
fs.mkdirSync(directory)
try {
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
// directory symlink creation is not permitted on this platform — skip
return
}
throw error
}
const resolved = await resolveDirectoryForIpc(linkPath)
assert.equal(resolved.resolvedPath, linkPath)
assert.equal(resolved.stat.isDirectory(), true)
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})

View File

@@ -1,7 +1,7 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { fileURLToPath } = require('node:url')
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
@@ -13,6 +13,7 @@ const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) {
const fallback =
Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS
const parsed = Number(timeoutMs)
if (Number.isFinite(parsed) && parsed > 0) {
@@ -62,6 +63,7 @@ function sensitiveFileBlockReason(filePath) {
const normalized = String(filePath || '')
.replace(/\\/g, '/')
.toLowerCase()
const basename = path.basename(normalized)
const ext = path.extname(basename)
@@ -87,6 +89,7 @@ function sensitiveFileBlockReason(filePath) {
if (basename.startsWith('.env.')) {
const suffix = basename.slice('.env.'.length)
if (!SAFE_ENV_SUFFIXES.has(suffix)) {
return `${basename} is blocked because it appears to contain environment secrets.`
}
@@ -107,9 +110,11 @@ function sensitiveFileBlockReason(filePath) {
return null
}
function ipcPathError(code, message) {
const error = new Error(message)
error.code = code
function ipcPathError(code: any, message: string): Error & { code: any } {
const error = new Error(message) as Error & { code: any }
;(error as any).code = code
return error
}
@@ -129,6 +134,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
}
const normalized = raw.replace(/\\/g, '/').toLowerCase()
if (
normalized.startsWith('//?/') ||
normalized.startsWith('//./') ||
@@ -141,7 +147,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
return raw
}
function resolveRequestedPathForIpc(filePath, options = {}) {
function resolveRequestedPathForIpc(filePath, options: { purpose?: string; baseDir?: fs.PathOrFileDescriptor } = {}) {
const purpose = String(options.purpose || 'File read')
let raw = rejectUnsafePathSyntax(filePath, purpose)
@@ -154,17 +160,21 @@ function resolveRequestedPathForIpc(filePath, options = {}) {
if (/^file:/i.test(raw)) {
let resolvedPath
try {
const parsed = new URL(raw)
if (parsed.protocol !== 'file:') {
throw new Error('not a file URL')
}
resolvedPath = fileURLToPath(parsed)
} catch {
throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`)
}
rejectUnsafePathSyntax(resolvedPath, purpose)
return path.resolve(resolvedPath)
}
@@ -178,14 +188,16 @@ function resolveRequestedPathForIpc(filePath, options = {}) {
return resolvedPath
}
async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
async function statForIpc(fsImpl: { promises: { stat: typeof fs.promises.stat } }, resolvedPath, purpose, typeLabel) {
try {
return await fsImpl.promises.stat(resolvedPath)
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
}
throw ipcPathError(
code || 'read-error',
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
@@ -201,6 +213,7 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
try {
const realPath = await fsImpl.promises.realpath(resolvedPath)
rejectUnsafePathSyntax(realPath, purpose)
return realPath
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
@@ -213,12 +226,20 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
function rejectSensitiveFilePath(filePath, purpose) {
const blockReason = sensitiveFileBlockReason(filePath)
if (blockReason) {
throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`)
}
}
async function resolveDirectoryForIpc(dirPath, options = {}) {
async function resolveDirectoryForIpc(
dirPath,
options: {
purpose?: string
baseDir?: fs.PathOrFileDescriptor
fs?: { promises: { stat: typeof fs.promises.stat } }
} = {}
) {
const purpose = String(options.purpose || 'Directory read')
const fsImpl = options.fs || fs
const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose })
@@ -233,7 +254,16 @@ async function resolveDirectoryForIpc(dirPath, options = {}) {
return { realPath, resolvedPath, stat }
}
async function resolveReadableFileForIpc(filePath, options = {}) {
async function resolveReadableFileForIpc(
filePath,
options: {
purpose?: string
baseDir?: fs.PathOrFileDescriptor
fs?: typeof fs
blockSensitive?: boolean
maxBytes?: number
} = {}
) {
const purpose = String(options.purpose || 'File read')
const fsImpl = options.fs || fs
const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose })
@@ -253,11 +283,13 @@ async function resolveReadableFileForIpc(filePath, options = {}) {
}
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
if (options.blockSensitive !== false) {
rejectSensitiveFilePath(realPath, purpose)
}
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
if (maxBytes && stat.size > maxBytes) {
throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
}
@@ -271,15 +303,15 @@ async function resolveReadableFileForIpc(filePath, options = {}) {
return { realPath, resolvedPath, stat }
}
module.exports = {
export {
DATA_URL_READ_MAX_BYTES,
DEFAULT_FETCH_TIMEOUT_MS,
TEXT_PREVIEW_SOURCE_MAX_BYTES,
encryptDesktopSecret,
rejectUnsafePathSyntax,
resolveDirectoryForIpc,
resolveReadableFileForIpc,
resolveRequestedPathForIpc,
resolveTimeoutMs,
sensitiveFileBlockReason
sensitiveFileBlockReason,
TEXT_PREVIEW_SOURCE_MAX_BYTES
}

View File

@@ -1,15 +1,17 @@
const assert = require('node:assert/strict')
const test = require('node:test')
import assert from 'node:assert/strict'
const {
import { test } from 'vitest'
import {
createLinkTitleWindow,
guardLinkTitleSession,
linkTitleWindowOptions,
readLinkTitleWindowTitle
} = require('./link-title-window.cjs')
} from './link-title-window'
function makeFakeBrowserWindow() {
const calls = { audioMuted: [] }
const FakeBrowserWindow = function (options) {
this.options = options
this.webContents = {

View File

@@ -1,11 +1,9 @@
'use strict'
// Hidden BrowserWindow used by tier-2 link-title resolution: when curl can't
// read a page <title> (bot walls, JS-rendered pages), we briefly load the URL
// in an offscreen window and read its title. That window loads arbitrary
// user-linked pages, so it must never emit sound or trigger real downloads.
function linkTitleWindowOptions(partitionSession) {
export function linkTitleWindowOptions(partitionSession) {
return {
show: false,
width: 1280,
@@ -25,7 +23,7 @@ function linkTitleWindowOptions(partitionSession) {
// Create the offscreen title-fetch window and immediately mute it. Without the
// mute, autoplaying media on the loaded page (e.g. a YouTube link) leaks ~2s of
// audio every time a session containing such links is re-rendered. See #49505.
function createLinkTitleWindow(BrowserWindow, partitionSession) {
export function createLinkTitleWindow(BrowserWindow, partitionSession) {
const window = new BrowserWindow(linkTitleWindowOptions(partitionSession))
try {
@@ -41,7 +39,7 @@ function createLinkTitleWindow(BrowserWindow, partitionSession) {
// Cancel any download the title-fetch window triggers. Without this, a link
// artifact URL served with Content-Disposition: attachment auto-downloads every
// time the Artifacts page renders and fetchLinkTitle loads it.
function guardLinkTitleSession(partitionSession) {
export function guardLinkTitleSession(partitionSession) {
try {
partitionSession.on('will-download', (_event, item) => item.cancel())
} catch {
@@ -52,20 +50,20 @@ function guardLinkTitleSession(partitionSession) {
// Read the page title from a title-fetch window. Callers schedule this from
// timers that can fire after finish() destroys the window, so every access must
// guard isDestroyed and swallow Electron's "Object has been destroyed" throws.
function readLinkTitleWindowTitle(window) {
export function readLinkTitleWindowTitle(window) {
try {
if (!window || window.isDestroyed()) return ''
if (!window || window.isDestroyed()) {
return ''
}
const contents = window.webContents
if (!contents || contents.isDestroyed()) return ''
if (!contents || contents.isDestroyed()) {
return ''
}
return contents.getTitle() || ''
} catch {
return ''
}
}
module.exports = {
createLinkTitleWindow,
guardLinkTitleSession,
linkTitleWindowOptions,
readLinkTitleWindowTitle
}

View File

@@ -1,13 +1,14 @@
/**
* Tests for OAuth-session Electron net.request helpers.
*
* Run with: node --test electron/oauth-net-request.test.cjs
* Run with: node --test electron/oauth-net-request.test.ts
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
import { test } from 'vitest'
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
test('serializeJsonBody returns undefined for absent bodies', () => {
assert.equal(serializeJsonBody(undefined), undefined)
@@ -21,6 +22,7 @@ test('serializeJsonBody JSON-encodes request bodies', () => {
test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => {
const headers = []
const request = {
setHeader(name, value) {
headers.push([name, value])

View File

@@ -14,7 +14,4 @@ function setJsonRequestHeaders(request) {
request.setHeader('Content-Type', 'application/json')
}
module.exports = {
serializeJsonBody,
setJsonRequestHeaders
}
export { serializeJsonBody, setJsonRequestHeaders }

View File

@@ -1,30 +0,0 @@
/**
* Regression coverage for the OAuth-session Electron net.request path.
*
* Electron net rejects manual Content-Length/Host headers with
* net::ERR_INVALID_ARGUMENT. Node HTTP helpers may still set Content-Length;
* this guard is scoped to fetchJsonViaOauthSession only.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const source = fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8')
function extractFetchJsonViaOauthSession() {
const start = source.indexOf('function fetchJsonViaOauthSession')
const end = source.indexOf('// Mint a single-use WS ticket', start)
assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist')
assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist')
return source.slice(start, end)
}
test('OAuth Electron net request does not set forbidden Content-Length header', () => {
const fn = extractFetchJsonViaOauthSession()
assert.match(fn, /electronNet\.request/)
assert.doesNotMatch(fn, /setHeader\(['"]Content-Length['"]/)
assert.match(fn, /request\.write\(body\)/)
})

View File

@@ -1,4 +1,4 @@
const { contextBridge, ipcRenderer, webUtils } = require('electron')
import { contextBridge, ipcRenderer, webUtils } from 'electron'
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
@@ -24,12 +24,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onState: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:pet-overlay:state', listener)
return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener)
},
// Main renderer subscribes to overlay control messages.
onControl: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:pet-overlay:control', listener)
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
}
},
@@ -41,6 +43,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
// Hermes Cloud: one portal login powers discovery + silent per-agent sign-in
// (cloud-auto-discovery Phase 3).
cloud: {
status: () => ipcRenderer.invoke('hermes:cloud:status'),
login: () => ipcRenderer.invoke('hermes:cloud:login'),
logout: () => ipcRenderer.invoke('hermes:cloud:logout'),
discover: org => ipcRenderer.invoke('hermes:cloud:discover', org),
agentSignIn: dashboardUrl => ipcRenderer.invoke('hermes:cloud:agent-sign-in', dashboardUrl)
},
profile: {
get: () => ipcRenderer.invoke('hermes:profile:get'),
set: name => ipcRenderer.invoke('hermes:profile:set', name)
@@ -87,6 +98,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:zoom:changed', listener)
return () => ipcRenderer.removeListener('hermes:zoom:changed', listener)
}
},
@@ -132,68 +144,88 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
const channel = `hermes:terminal:${id}:data`
const listener = (_event, payload) => callback(payload)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
},
onExit: (id, callback) => {
const channel = `hermes:terminal:${id}:exit`
const listener = (_event, payload) => callback(payload)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
}
},
onClosePreviewRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:close-preview-requested', listener)
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
},
onOpenUpdatesRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:open-updates', listener)
return () => ipcRenderer.removeListener('hermes:open-updates', listener)
},
onDeepLink: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:deep-link', listener)
return () => ipcRenderer.removeListener('hermes:deep-link', listener)
},
signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'),
onWindowStateChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:window-state-changed', listener)
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
},
onFocusSession: callback => {
const listener = (_event, sessionId) => callback(sessionId)
ipcRenderer.on('hermes:focus-session', listener)
return () => ipcRenderer.removeListener('hermes:focus-session', listener)
},
onNotificationAction: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:notification-action', listener)
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
},
onPreviewFileChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:preview-file-changed', listener)
return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener)
},
onBackendExit: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:backend-exit', listener)
return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
},
// Soft gateway-mode apply finished tearing down the primary backend. Renderer
// should wipe session lists + re-dial without a window reload.
onConnectionApplied: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:connection:applied', listener)
return () => ipcRenderer.removeListener('hermes:connection:applied', listener)
},
onPowerResume: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:power-resume', listener)
return () => ipcRenderer.removeListener('hermes:power-resume', listener)
},
onBootProgress: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:boot-progress', listener)
return () => ipcRenderer.removeListener('hermes:boot-progress', listener)
},
// First-launch bootstrap progress -- emitted by the install.ps1 stage
// runner in main.cjs (apps/desktop/electron/bootstrap-runner.cjs).
// runner in main.ts (apps/desktop/electron/bootstrap-runner.ts).
// Renderer's install overlay subscribes to live events and queries the
// current snapshot via getBootstrapState() to recover after a devtools
// reload mid-bootstrap.
@@ -204,6 +236,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onBootstrapEvent: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:bootstrap:event', listener)
return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener)
},
getVersion: () => ipcRenderer.invoke('hermes:version'),
@@ -220,6 +253,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onProgress: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:updates:progress', listener)
return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
}
},

View File

@@ -1,62 +0,0 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
}
// ---------------------------------------------------------------------------
// prepareProfileDeleteRequest must return the torn-down profile name so the
// caller can skip ensureBackend for that profile (issue #52279).
// ---------------------------------------------------------------------------
test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
const source = readElectronFile('main.cjs')
// Locate the function definition and its closing brace.
const fnStart = source.indexOf('async function prepareProfileDeleteRequest(')
assert.notEqual(fnStart, -1, 'prepareProfileDeleteRequest function not found')
// The function must contain "return profile" (pool and primary paths).
const fnBody = source.slice(fnStart, fnStart + 800)
const returnProfileCount = (fnBody.match(/return profile/g) || []).length
assert.ok(
returnProfileCount >= 2,
`expected at least 2 "return profile" statements (primary + pool paths), found ${returnProfileCount}`
)
// The early-exit guard must return null (not void/undefined).
assert.match(fnBody, /return null/, 'early-exit guard should return null, not undefined')
})
test('hermes:api handler routes profile-delete requests to the primary backend', () => {
const source = readElectronFile('main.cjs')
// The handler must capture prepareProfileDeleteRequest's return value.
assert.match(
source,
/const tornDownProfile = await prepareProfileDeleteRequest\(request\)/,
'handler should capture the return value of prepareProfileDeleteRequest'
)
// The handler must use the return value to skip ensureBackend for the
// torn-down profile, routing to the primary (null) instead.
assert.match(
source,
/const routeProfile = tornDownProfile \? null : profile/,
'handler should route to primary backend when a profile was just torn down'
)
// ensureBackend must be called with the conditional route profile.
assert.match(
source,
/const connection = await ensureBackend\(routeProfile\)/,
'handler should pass routeProfile (not raw profile) to ensureBackend'
)
})

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,11 +1,8 @@
const assert = require('node:assert/strict')
const test = require('node:test')
import assert from 'node:assert/strict'
const {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry
} = require('./session-windows.cjs')
import { test } from 'vitest'
import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows'
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
// test fire the 'closed' event, mirroring the slice of the Electron API the
@@ -96,6 +93,7 @@ test('registry opens one window per session and focuses on re-open', () => {
const registry = createSessionWindowRegistry()
let built = 0
const win = makeFakeWindow()
const factory = () => {
built += 1
@@ -145,6 +143,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', ()
let built = 0
const second = makeFakeWindow()
const result = registry.openOrFocus('s1', () => {
built += 1
@@ -158,6 +157,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', ()
test('registry ignores empty / non-string session ids', () => {
const registry = createSessionWindowRegistry()
let built = 0
const factory = () => {
built += 1

View File

@@ -1,9 +1,9 @@
// Secondary "session windows" — one extra OS window per chat so a user can
// work with multiple chats side by side. The pure, Electron-free pieces live
// here so they can be unit-tested with node --test (mirroring how the rest of
// electron/*.cjs splits testable logic out of the main.cjs monolith).
// electron/*.ts splits testable logic out of the main.ts monolith).
const { pathToFileURL } = require('node:url')
import { pathToFileURL } from 'node:url'
// Secondary windows open at the minimum usable size — a compact side panel for
// subagent watch / cmd-click session pop-out, not a second full desktop.
@@ -12,7 +12,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// Shared webPreferences for every window that renders the chat transcript — the
// primary window AND the secondary session windows. Keeping it in one place is
// the whole point: the two BrowserWindow definitions in main.cjs used to be
// the whole point: the two BrowserWindow definitions in main.ts used to be
// hand-copied, and the secondary windows silently lost `backgroundThrottling:
// false`, so a streamed answer stalled until the window regained focus.
//
@@ -21,7 +21,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// blurred/occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
function chatWindowWebPreferences(preloadPath) {
function chatWindowWebPreferences(preloadPath: string) {
return {
preload: preloadPath,
contextIsolation: true,
@@ -42,7 +42,7 @@ function chatWindowWebPreferences(preloadPath) {
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
// session): the renderer resumes it lazily so the gateway never builds an agent
// just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) {
function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch, newSession }: any = {}) {
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
@@ -115,7 +115,7 @@ function createSessionWindowRegistry() {
}
}
module.exports = {
export {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,

View File

@@ -1,12 +1,13 @@
const assert = require('node:assert/strict')
const test = require('node:test')
import assert from 'node:assert/strict'
const {
import { test } from 'vitest'
import {
MACOS_TAHOE_DARWIN_MAJOR,
OVERLAY_FALLBACK_WIDTH,
macTitleBarOverlayHeight,
nativeOverlayWidth
} = require('./titlebar-overlay-width.cjs')
nativeOverlayWidth,
OVERLAY_FALLBACK_WIDTH
} from './titlebar-overlay-width'
// This static reservation is only the pre-layout FALLBACK. Once laid out the
// renderer reads the exact width from navigator.windowControlsOverlay

View File

@@ -1,6 +1,4 @@
'use strict'
const OVERLAY_FALLBACK_WIDTH = 144
export const OVERLAY_FALLBACK_WIDTH = 144
/**
* Static pre-layout reservation (px) for the right-side native window-controls
@@ -16,15 +14,18 @@ const OVERLAY_FALLBACK_WIDTH = 144
*
* @param {{ isMac?: boolean }} opts
*/
function nativeOverlayWidth({ isMac = false } = {}) {
if (isMac) return 0
export function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = false } = {}) {
if (isMac) {
return 0
}
return OVERLAY_FALLBACK_WIDTH
}
// macOS Tahoe ships as Darwin 25 (Sequoia is 24); the Darwin number is truthful,
// unlike the product version which macOS reports as 16 or 26 depending on the
// build SDK.
const MACOS_TAHOE_DARWIN_MAJOR = 25
export const MACOS_TAHOE_DARWIN_MAJOR = 25
/**
* Height (px) to pass to `titleBarOverlay` on macOS. Tahoe (Darwin 25+)
@@ -36,8 +37,6 @@ const MACOS_TAHOE_DARWIN_MAJOR = 25
*
* @param {{ darwinMajor?: number, titlebarHeight?: number }} opts
*/
function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) {
export function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) {
return darwinMajor >= MACOS_TAHOE_DARWIN_MAJOR ? 0 : titlebarHeight
}
module.exports = { MACOS_TAHOE_DARWIN_MAJOR, OVERLAY_FALLBACK_WIDTH, macTitleBarOverlayHeight, nativeOverlayWidth }

View File

@@ -1,7 +1,8 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { resolveBehindCount, shouldCountCommits } from './update-count'
// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0`
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus

View File

@@ -1,5 +1,3 @@
'use strict'
// Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful
// number worth computing. On a SHALLOW checkout (installer clones with
// --depth 1) the local history often shares no merge-base with the freshly
@@ -19,10 +17,14 @@ function shouldCountCommits({ isShallow, hasMergeBase }) {
// (developers / Docker dev images) keep the exact count path unchanged.
function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) {
if (!shouldCountCommits({ isShallow, hasMergeBase })) {
if (currentSha && targetSha && currentSha === targetSha) return 0
if (currentSha && targetSha && currentSha === targetSha) {
return 0
}
return 1 // behind by an unknown amount — show a generic "update available"
}
return Number.parseInt(countStr, 10) || 0
}
module.exports = { resolveBehindCount, shouldCountCommits }
export { resolveBehindCount, shouldCountCommits }

View File

@@ -1,9 +1,9 @@
/**
* Tests for electron/update-marker.cjs the in-app update mutual-exclusion
* Tests for electron/update-marker.ts the in-app update mutual-exclusion
* marker that prevents a desktop relaunched mid-update from spawning a backend
* the updater then kills in a loop (#50238).
*
* Run with: node --test electron/update-marker.test.cjs
* Run with: node --test electron/update-marker.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*
* Why this matters: the gate must (a) report a live update only when the
@@ -12,16 +12,24 @@
* strand future launches, and (c) self-heal by deleting a stale marker file.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('fs')
const os = require('os')
const path = require('path')
import fs from 'fs'
import assert from 'node:assert/strict'
import os from 'os'
import path from 'path'
const { markerPath, isPidAlive, readLiveUpdateMarker, writeUpdateMarker, UPDATE_MARKER_MAX_AGE_MS } = require('./update-marker.cjs')
import { test } from 'vitest'
import {
isPidAlive,
markerPath,
readLiveUpdateMarker,
UPDATE_MARKER_MAX_AGE_MS,
writeUpdateMarker
} from './update-marker'
function tmpHome(tag) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-marker-${tag}-`))
return dir
}
@@ -29,10 +37,12 @@ function writeMarker(home, pid, startedAtSec) {
fs.writeFileSync(markerPath(home), `${pid}\n${startedAtSec}`)
}
const ALIVE = () => true // injected kill that "succeeds" => pid alive
const DEAD = () => {
const ALIVE: typeof process.kill = () => true // injected kill that "succeeds" => pid alive
const DEAD: typeof process.kill = () => {
const err = new Error('no such process')
err.code = 'ESRCH'
;(err as any).code = 'ESRCH'
throw err
}
@@ -85,9 +95,11 @@ test('isPidAlive: own pid is alive, impossible pid is dead', () => {
test('isPidAlive: EPERM counts as alive (process owned by another user)', () => {
const eperm = () => {
const err = new Error('operation not permitted')
err.code = 'EPERM'
;(err as any).code = 'EPERM'
throw err
}
assert.equal(isPidAlive(4242, eperm), true)
})

View File

@@ -16,20 +16,20 @@
*
* This module holds the PURE, side-effect-light logic (path, pid liveness,
* parse + staleness) so it is unit-testable without booting Electron. The
* polling/boot-progress wrapper lives in main.cjs where the boot-progress and
* polling/boot-progress wrapper lives in main.ts where the boot-progress and
* log sinks are.
*/
const fs = require('fs')
const path = require('path')
import fs from 'fs'
import path from 'path'
// Even with a live-looking PID, never treat a marker older than this as a live
// update. A full update (git pull + pip + desktop rebuild) is minutes, not tens
// of minutes; past this the marker is almost certainly stale (e.g. the OS
// recycled the pid onto an unrelated process), so the gate self-heals.
const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000
export const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000
function markerPath(hermesHome) {
export function markerPath(hermesHome) {
return path.join(hermesHome, '.hermes-update-in-progress')
}
@@ -37,10 +37,14 @@ function markerPath(hermesHome) {
// not deliver a signal — it just probes existence/permission. ESRCH => dead;
// EPERM => alive but owned by another user (still "alive" for our purposes).
// Injectable `kill` keeps it unit-testable.
function isPidAlive(pid, kill = process.kill.bind(process)) {
if (!Number.isInteger(pid) || pid <= 0) return false
export function isPidAlive(pid, kill: typeof process.kill = process.kill.bind(process)) {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
try {
kill(pid, 0)
return true
} catch (err) {
return Boolean(err && err.code === 'EPERM')
@@ -59,9 +63,21 @@ function isPidAlive(pid, kill = process.kill.bind(process)) {
* Pure-ish: file I/O against the given path, plus an injectable pid probe and
* clock for tests.
*/
function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPDATE_MARKER_MAX_AGE_MS } = {}) {
export function readLiveUpdateMarker(
hermesHome,
{
kill,
now = Date.now,
maxAgeMs = UPDATE_MARKER_MAX_AGE_MS
}: {
now?: () => number
maxAgeMs?: number
kill?: typeof process.kill
} = {}
) {
const file = markerPath(hermesHome)
let raw
try {
raw = fs.readFileSync(file, 'utf8')
} catch {
@@ -80,8 +96,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD
} catch {
void 0
}
return null
}
return { pid, ageMs }
}
@@ -107,9 +125,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD
* If the updater never starts (spawn failure) the marker still contains a
* real PID, so `readLiveUpdateMarker` will self-heal once that PID exits.
*/
function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) {
export function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) {
const file = markerPath(hermesHome)
const startedAt = Math.floor(now() / 1000)
try {
fs.writeFileSync(file, `${pid}\n${startedAt}\n`, 'utf8')
} catch {
@@ -117,11 +136,3 @@ function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) {
// updater will write its own when it reaches run_update.
}
}
module.exports = {
UPDATE_MARKER_MAX_AGE_MS,
markerPath,
isPidAlive,
readLiveUpdateMarker,
writeUpdateMarker
}

View File

@@ -1,8 +1,8 @@
/**
* Tests for electron/update-rebuild.cjs the retry-once policy for the desktop
* Tests for electron/update-rebuild.ts the retry-once policy for the desktop
* `--build-only` rebuild during self-update.
*
* Run with: node --test electron/update-rebuild.test.cjs
* Run with: node --test electron/update-rebuild.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*
* Why this matters: a first rebuild can return nonzero on a still-settling tree
@@ -12,10 +12,11 @@
* success, and must run at most twice.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs')
import { test } from 'vitest'
import { runRebuildWithRetry, shouldRetryRebuild } from './update-rebuild'
test('shouldRetryRebuild retries only on a non-success exit', () => {
assert.equal(shouldRetryRebuild(0), false)
@@ -25,30 +26,39 @@ test('shouldRetryRebuild retries only on a non-success exit', () => {
test('a clean first rebuild runs once and does not retry', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: 0 })
})
assert.deepEqual(codes, [0])
assert.equal(result.code, 0)
})
test('a failed first rebuild retries once and succeeds', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: attempt === 0 ? 1 : 0 })
})
assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 0)
})
test('a rebuild that keeps failing runs at most twice and reports the failure', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: 1, error: 'rebuild-failed' })
})
assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 1)
assert.equal(result.error, 'rebuild-failed')

View File

@@ -1,5 +1,3 @@
'use strict'
/**
* Retry-once policy for the desktop `--build-only` rebuild during self-update.
*
@@ -20,10 +18,12 @@ function shouldRetryRebuild(code) {
*/
async function runRebuildWithRetry(rebuild) {
let result = await rebuild(0)
if (shouldRetryRebuild(result.code)) {
result = await rebuild(1)
}
return result
}
module.exports = { shouldRetryRebuild, runRebuildWithRetry }
export { runRebuildWithRetry, shouldRetryRebuild }

View File

@@ -1,8 +1,8 @@
/**
* Tests for electron/update-relaunch.cjs the pure decision + script helpers
* Tests for electron/update-relaunch.ts the pure decision + script helpers
* behind the Linux in-app update relaunch (#45205).
*
* Run with: node --test electron/update-relaunch.test.cjs
* Run with: node --test electron/update-relaunch.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*
* What this locks (review acceptance criteria for PR #45205):
@@ -17,24 +17,25 @@
* (keep a working window) unless a non-interactive fallback applies.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const {
unpackedDirName,
resolveUnpackedRelease,
decideRelaunchOutcome,
sandboxPreflight,
sandboxFallbackFromEnv,
import { test } from 'vitest'
import {
buildRelaunchScript,
collectRelaunchArgs,
collectRelaunchEnv,
buildRelaunchScript,
shellQuote
} = require('./update-relaunch.cjs')
decideRelaunchOutcome,
resolveUnpackedRelease,
sandboxFallbackFromEnv,
sandboxPreflight,
shellQuote,
unpackedDirName
} from './update-relaunch'
const ROOT = '/home/u/.hermes/hermes-agent'
const UNPACKED = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked')
@@ -91,6 +92,7 @@ test('decideRelaunchOutcome: only under-unpacked + sandbox-ok relaunches', () =>
// ---------------------------------------------------------------------------
const fakeStat = (uid, mode) => () => ({ uid, mode })
const throwStat = () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
@@ -150,6 +152,7 @@ test('collectRelaunchArgs drops Electron internals, keeps user/launcher args', (
'--profile=work', // app flag — keep
'--remote-debugging-port=9222' // internal — drop
]
assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/agent/42', '--profile=work'])
assert.deepEqual(collectRelaunchArgs(undefined), [])
})
@@ -165,6 +168,7 @@ test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt-
HOME: '/home/u', // not preserved
UNRELATED: 'x'
}
assert.deepEqual(collectRelaunchEnv(env), {
HERMES_HOME: '/home/u/.hermes',
HERMES_DESKTOP_REMOTE_URL: 'http://box:9119',
@@ -207,6 +211,7 @@ test('buildRelaunchScript embeds pid/exec/args/env/cwd and is valid bash', () =>
// It must be syntactically valid bash (`bash -n`). Write to a temp file and lint.
const tmp = path.join(os.tmpdir(), `hermes-relaunch-test-${Date.now()}.sh`)
fs.writeFileSync(tmp, script)
try {
execFileSync('bash', ['-n', tmp], { stdio: 'pipe' })
} finally {
@@ -222,13 +227,16 @@ test('buildRelaunchScript with no args/env still lints clean', () => {
env: {},
cwd: ''
})
const tmp = path.join(os.tmpdir(), `hermes-relaunch-test2-${Date.now()}.sh`)
fs.writeFileSync(tmp, script)
try {
execFileSync('bash', ['-n', tmp], { stdio: 'pipe' })
} finally {
fs.rmSync(tmp, { force: true })
}
// exec line has no trailing args.
assert.match(script, /exec '\/opt\/Hermes\/Hermes'\n/)
})

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