Three parity fixes between the API server and the native gateway's
agent-runtime resolution, integrated with the provider-aware request
routing that landed in #70853:
- Session-persisted model is honored: POST /api/sessions {"model": ...}
stores a model that the chat handlers previously fetched and threw
away. A stored value that matches a model_routes alias goes through
the route path (route provider/credentials apply); a raw model string
threads through as session_model, pinning the session's turns ahead
of per-request body values but below an explicit session /model
override.
- Empty-model recovery: provider-catalog default when config has no
model.default but a provider resolved, plus last-known-good model
recovery (#35314) keyed on gateway_session_key only (never ephemeral
session_id — no unbounded growth from one-off requests).
- Provider auth failures surface as controlled responses: RuntimeError
from _resolve_runtime_agent_kwargs() is re-raised as a dedicated
_ProviderAuthResolutionError at the call site, caught narrowly in
_run_agent() and the /v1/runs executor to return run.py's response
shape instead of an undifferentiated 500 (session-chat endpoints
previously returned a raw aiohttp 500 with no JSON body).
Salvaged from PR #57947 by @FvanW; session-model route-alias resolution
from PR #59941 by @kaishi00.
Co-authored-by: kaishi00 <kaishi00@users.noreply.github.com>
cron/scheduler.py deliberately applies utf-8/replace only on Windows via
popen_kwargs (non-Windows keeps locale default per its test contract) —
drop the sweep's unconditional inline kwargs there. Update the gateway
force-kill kwarg snapshot for the new guard.
- whatsapp taskkill + webhook gh-comment assert_called_with: add the two
new kwargs
- test_status fake_run: accept **kwargs so signature-strict stub doesn't
TypeError on encoding/errors
- Strip the salvaged commit's inline encoding kwargs where main had since
gained its own (process_registry, local env, cua doctor, gateway,
commands, gateway_windows — the latter keeps its locale-aware
_schtasks_encoding() from #38186)
- Revert encoding kwargs mistakenly applied to non-subprocess APIs
(exa get_contents, tempfile.mkstemp in webhook.py)
- Guard the ddgs worker Popen (new on main since #55339)
- Update two kwarg-snapshot test assertions for the new kwargs
AST-driven pass over every subprocess.run/Popen/check_output/check_call/call
with text=True (or universal_newlines=True) and no explicit encoding=:
append encoding='utf-8', errors='replace' at the kwarg site. 136 call
sites across 28 files (cli.py, hermes_cli/main.py, tools_config.py,
environments, computer_use, gateway, scripts, skills helpers, agent/*).
Together with the salvaged #55339/#60741 commits this closes out issue
#53428's bug class; the salvaged #60751 linter rule in
check-windows-footguns.py now enforces it repo-wide (verified: 807 files
scanned, zero findings).
Adds a new rule to scripts/check-windows-footguns.py that flags
subprocess.run/Popen/call/check_output/check_call(..., text=True, ...) calls
missing an explicit encoding= kwarg.
On Chinese Windows (cp936/GBK) and other non-UTF-8 default codepages,
text=True without encoding= decodes child output with
locale.getpreferredencoding(False), crashing _readerthread with
UnicodeDecodeError on non-default-codepage bytes (issues #47939, #53428,
rule prevents future regressions.
Rule design:
- Pattern matches 'text=True' / 'text = True'
- post_filter skips lines that:
- already pass encoding= on the same line
- are method definitions (def text)
- contain text=True inside string literals
- are not subprocess-shaped calls (heuristic via _is_likely_subprocess_call)
- Two helper functions: _is_likely_subprocess_call, _looks_like_string_literal
- Multi-line calls where subprocess.X( and text=True are on different lines
are not flagged (acceptable false negative for a line-based scanner)
Also fixes the linter's own footgun: get_staged_files() and get_diff_files()
used subprocess.check_output(text=True) without encoding= — now fixed.
Suppresses 4 false positives on non-Windows platform-exclusive calls:
- tools/voice_mode.py (Termux/Android)
- tools/environments/singularity.py (Linux HPC)
- plugins/google_meet/cli.py (macOS system_profiler)
Test plan:
- 21 unit tests in tests/scripts/test_footgun_subprocess_encoding.py
- TestDetection: 6 cases verifying the rule flags real subprocess calls
- TestSuppression: 7 cases verifying false-positive avoidance
- TestHelpers: 7 cases for the two helper functions
- TestFullRepoScan: scans the whole tree and asserts the new rule finds
only the 7 call sites that PR #60741 fixes (or zero, once #60741 merges)
Verified: full-repo scan reports 7 matches on main (the #60741 sites),
4 platform-exclusive calls correctly suppressed, zero false positives.
Hermes-sweeper review on #60741 flagged that _op_version (the paired
op probe used by the same setup/status CLI flow at lines 127 and 205)
still ran text=True without explicit encoding/errors.
Add encoding='utf-8', errors='replace' to match _op_whoami and the
production op read path at agent/secret_sources/onepassword.py:271-278.
Also extend the regression test to cover _op_version alongside
_op_whoami, and update the module docstring to reflect the widened
scope. Test sensitivity verified: reverting the source change makes
test_op_version_passes_utf8_encoding fail with encoding=None.
PR #55339 adds encoding='utf-8', errors='replace' to 26 subprocess.run(text=True)
call sites across the codebase. The triage review (thanks @alt-glitch) diffed
this PR against #55339 and found that 5 of the 6 originally-touched call sites
are already covered there byte-identically:
- hermes_cli/main.py::_probe_container
- hermes_cli/setup.py SSH probe
- tools/tts_tool.py::_generate_neutts
- tools/transcription_tools.py::_prepare_local_audio
- tools/transcription_tools.py::_transcribe_local_command (both branches)
The one genuinely net-new site — hermes_cli/onepassword_secrets_cli.py::_op_whoami
(the 1Password op CLI whoami probe) — is NOT in #55339 and is fixed here.
Without explicit encoding=, text=True decodes child output with
locale.getpreferredencoding(False) — cp936 on Chinese Windows — which crashes
_readerthread on non-GBK bytes, cascading into pipe buffer fills, event loop
stalls, and TUI freezes (issues #47939, #53428, #57238).
Scope narrowed per triage feedback: the other 5 sites should land via #55339.
Refs #53428 (together with #55339).
On Windows with Chinese locale (GBK), subprocess.run(text=True) without
explicit encoding causes UnicodeDecodeError crashes. This fix adds
encoding='utf-8', errors='replace' to all subprocess.run() and
subprocess.Popen() calls that use text=True across 76 non-test Python files.
Fixes#53428 (master tracker for Windows GBK locale crash).
Note: credential_pool.py and electron changes excluded per reviewer request —
those will be submitted as separate focused PRs.
* feat(desktop): add Cron Blueprints to the GUI
The desktop app had a Cron jobs panel but no Blueprints tab, so the
parameterized automation templates the dashboard offers were unreachable
there (parity matrix: Dashboard=Y, GUI=N).
Adds a Jobs/Blueprints segmented toggle to the cron panel. The Blueprints
tab renders the catalog from the existing GET /api/cron/blueprints endpoint,
one card per template with a typed form (time/enum/weekdays/text slots).
Submitting POSTs to /api/cron/blueprints/instantiate, which fills the slots
and creates a real cron job via the same create_job path as a hand-written
one. The new job is merged into the shared atom so the Jobs tab
and sidebar reflect it immediately.
Backend already served both endpoints; this is desktop frontend only.
Strings added to all four locales.
* fix(desktop): stop cron blueprint cards clipping and tabs overlapping close X
Blueprint cards were wrapped in PanelBlock (a max-h-48 overflow-auto <pre>
for monospace code), which capped each card and forced an inner scroll,
clipping the copy. Use a plain auto-height card div so rows grow with their
content and the gallery scrolls as one.
PanelHeader actions sat under the overlay's absolutely-positioned close X
(no layout space reserved). Reserve pr-8 clearance when actions are present.
* refactor(desktop): narrow blueprint card i18n dep, document intentional scoping
Address review nits on the Cron Blueprints PR:
- BlueprintCard's submit useCallback depended on the whole t.cron object; it
only uses the blueprints slice. Bind const b = c.blueprints and use it (plus
narrow the dep) throughout the card.
- Document the intentional GET-vs-POST profile asymmetry on the blueprint
endpoints (global catalog vs per-profile instantiate) — the prior comment
claimed both were profile-scoped.
- Note why the blueprints tab collapses 'all' scope to 'default' (a blueprint
creates a real per-profile job; 'all' is not a writable target).
* fix(desktop): default blueprint delivery to This desktop, not origin
The blueprint catalog is shared with the dashboard, so its deliver slot
defaults to 'origin' (the chat/home-channel a dashboard or gateway job was
created from). Desktop has no origin chat and no home-channel picker, so the
seeded 'origin' rendered unlabeled and, at delivery, fell through the
home-channel fallback to nowhere when no gateway was configured.
Seed the deliver slot to 'local' (This desktop) when the backend default is
'origin' or empty, drop the origin option from the desktop dropdown, and label
the remaining options with the desktop's own delivery labels — matching the
manual cron editor (local/telegram/discord/slack/email). Also skip the
backend's origin-centric deliver help, which contradicts desktop semantics.
* refactor(desktop): align blueprint cards with the Panel/settings idiom
Address PR review (UI consistency with neighboring surfaces):
- Card container: drop the standalone-card look (bg-foreground/5) for the
shared in-panel grouping token bg-(--ui-bg-quinary), matching the cron
editor's in-surface groupings so blueprints sit in the Panel family.
- Form fields: replace the bespoke <label>+<Input> rows with the shared
ListRow primitive from settings/primitives (label+help on the left, control
on the right, stacks in a narrow pane) — same idiom as settings/messaging.
No behavior change; blueprint deliver remap and $cronJobs merge untouched.
* fix(desktop): satisfy eslint on the cron blueprint files
CI check:lint failed on import/export ordering and an unused import in the
blueprint changes:
- hermes.ts: sort AutomationBlueprint before AuxiliaryModelsResponse in the
type import + re-export blocks, and drop the unused AutomationBlueprintField
import (still re-exported for blueprints.tsx).
- cron/index.tsx: alphabetize the dialog/segmented-control imports and the
./blueprints vs ../shell/statusbar-controls group.
- blueprints.tsx: add the required blank lines between statements.
eslint --fix only; no behavior change. typecheck + blueprint tests green.
* refactor(desktop): blueprints reuse the cron editor dialog + shared card
Address review: the blueprint UI was still going its own way on the card and
form. Reuse the app's canonical pieces instead of a bespoke surface.
- CronEditorDialog gains a 'blueprint' mode: EditorState carries the blueprint
+ target profile, the dialog renders the typed slots with the same
Field/FieldHint/DialogFooter/error-block chrome as manual New cron, and
submit routes to instantiateAutomationBlueprint. One dialog, one editor state
machine. Resolves the accordion, the border-t divider, ListRow-vs-Field, the
ad-hoc buttons, and the plain error <p> in one move.
- Blueprint cards use selectableCardClass({ prominent: true }) (the shared
theme/pet/gateway card idiom), caller owns padding (p-2), whole card is a
button that opens the dialog pre-filled. No inline form.
- Gallery renders via PanelDetail, not PanelBody's master/detail row.
- i18n: drop the now-unused blueprints.setUp/cancel, add blueprints.dialogDesc
across en/ja/zh/zh-hant + types.
- Dropped the stray \u2014 literal comment.
Logic (origin->local deliver, desktopDeliverOptions, merge into $cronJobs) is
unchanged and stays unit-tested. typecheck + eslint + tests green.
* fix(desktop): dropdown no longer closes the cron dialog; unify deliver targets
Two cron-dialog bugs:
1. Dismissing any Select dropdown inside the cron editor dialog closed the whole
dialog. Radix portals Select/Popover content outside the dialog, so the
dismiss pointerdown reached the Dialog's DismissableLayer as an
outside-interaction. Guard DialogContent.onInteractOutside: swallow
interactions originating from a [data-radix-popper-content-wrapper] (a
dropdown dismiss inside our own dialog), compose with any caller handler. Fix
is at the shared Dialog level so every dialog benefits.
2. Blueprint deliver only offered 'This desktop'. The blueprint used the backend
blueprint field.options (configured gateways only) while the manual editor
hardcoded local/telegram/discord/slack/email regardless of what's connected.
Wire the desktop to GET /api/cron/delivery-targets (the documented single
source of truth, already used by the dashboard) via getCronDeliveryTargets,
and render both the manual editor and the blueprint deliver slot through one
shared DeliverSelect. Now all three surfaces agree and only offer connected
platforms; unconfigured-home-channel targets show a hint.
i18n: add cron.deliverNeedsHomeChannel across en/ja/zh/zh-hant + types.
typecheck + eslint + vitest green.
* fix(desktop): clicking away from an open dropdown no longer closes the dialog
The onInteractOutside guard only caught pointerdowns whose target was inside
the popper wrapper. But dismissing an open Select by clicking elsewhere inside
the dialog also closes the popover, which moves focus — and Radix Dialog reads
that as focusOutside and closes the whole dialog. (Radix Select 2.3.1 has no
modal prop, so that escape hatch isn't available.)
Guard both paths at the shared DialogContent level: onInteractOutside AND
onFocusOutside now swallow the event when it originates from a Radix popper OR
when any [data-radix-popper-content-wrapper] is open at event time (covers the
focus/re-dispatch case where the target is no longer the popper). A genuine
backdrop click with no dropdown open still closes the dialog. Export
isInteractionFromPopper + unit-test the three cases.
typecheck + eslint + vitest green (7 dialog tests).
* fix(desktop): portal popovers into their dialog so dropdowns don't close it
Root cause (affected every dialog, not just cron): Radix Select/Popover/
DropdownMenu portal to document.body — a SIBLING of the dialog, outside its DOM
subtree. Dismissing a dropdown (or clicking another field) moves focus out of
the dialog subtree, which the Dialog's modal FocusScope/DismissableLayer reads
as an outside interaction and closes the whole dialog. Separate body-level
portals also make z-index across the two fragile.
The earlier onInteractOutside/onFocusOutside guards treated symptoms and didn't
hold (and Radix Select 2.3.1 has no modal prop to disable its layer). Real fix
is a layering system: DialogContent publishes its content node via
DialogPortalContainerContext; SelectContent/PopoverContent/DropdownMenuContent
call usePopoverPortalContainer() and portal INTO that node when inside a dialog
(document.body otherwise). The popover is then a true DOM descendant of the
dialog — focus stays in, dismissal no longer closes the dialog, and both share
one stacking context so z-index is deterministic.
Test: with a Dialog open, an open Select's item is a descendant of the dialog
(portalled in), verified in jsdom. typecheck + eslint + component tests green.
* fix(desktop): bump radix-ui so dismissing a dropdown can't close its dialog
Upstream bug, not app-layer: with radix-ui 1.6.0 (dismissable-layer 1.1.13),
an open modal Select sets pointer-events: none on the dialog body, so a click
anywhere inside the dialog hit-tests through to the overlay. The Dialog's
DismissableLayer defers its outside-pointerdown decision to the click, but the
overlay is a registered dismissable surface exempt from the interception check
— so the Select swallowing the press didn't count, the deferred onDismiss
fired, and the dialog closed along with the dropdown.
dismissable-layer 1.1.17 adds shouldHandlePointerDownOutside, which makes the
dialog's layer ignore the press entirely while a higher layer has its pointer
events disabled. Bump radix-ui ^1.4.3 -> ^1.6.5 (dismissable-layer 1.1.17,
dialog 1.1.21, select 2.3.5); lockfile diff is Radix-only.
Repro test fires the pointerdown/up/click sequence on the overlay with a
Select open inside the dialog: red on 1.6.0 (dialog closed), green on 1.6.5.
Counterpart test keeps a genuine overlay click closing the dialog.
typecheck + dialog/select component tests green. Full-suite failures on this
Windows host reproduce identically without the bump (pre-existing env issues).
Add authenticated GET /api/model/options to the gateway API server,
sharing the dashboard/TUI picker payload builder so external clients
can sync to the user's configured Hermes provider catalog instead of
scraping the single OpenAI-compatible /v1/models alias.
- new shared hermes_cli.inventory.build_model_options_payload() wraps
build_models_payload with the stable picker shape and safe
custom-provider probe policy (probe current only on normal open,
probe all + cache bust on explicit refresh)
- dashboard web_server and TUI gateway model.options refactored onto
the shared builder; dashboard build moved off the event loop via
run_in_threadpool
- capabilities endpoint advertises model_options
- docs for both API server and programmatic integration
Salvaged from PR #54689 by @abundantbeing.
* feat(desktop): add Webhooks page for subscription CRUD
Brings the desktop GUI to parity with the dashboard's Webhooks page.
Adds a /webhooks route that lists webhook subscriptions, enables the
webhook gateway platform, and creates/toggles/deletes subscriptions,
hitting the same /api/webhooks* endpoints the dashboard and CLI use.
- types/hermes.ts: WebhookRoute, WebhooksResponse, WebhookCreatePayload,
WebhookCreateResponse, WebhookEnableResponse
- hermes.ts: getWebhooks, enableWebhooks, createWebhook, deleteWebhook,
setWebhookEnabled (profile-scoped) + type re-exports
- app/webhooks/index.tsx: WebhooksView (enable card, restart banner,
subscription list with copy/toggle/delete, create dialog with
one-time secret reveal); optimistic toggle, profile re-home
- routing: routes.ts, contrib/surfaces.tsx, chat/route-tile.tsx
- nav: command palette, keybinds (nav.webhooks), sidebar row
- i18n: en + zh full, types interface; ja/zh-hant fall back to English
- test: webhooks-rest.test.ts covers the REST helper contracts
* feat(desktop): surface Webhooks in the status bar instead of nav
Moves the Webhooks entry point from the sidebar nav / command palette /
keybind to a status bar action next to Cron, matching where scheduled
jobs live. The /webhooks route, page, and REST helpers are unchanged.
- use-statusbar-items.tsx: add webhooks action (Globe icon) after cron
- i18n: shell.statusbar.webhooks / openWebhooks (en, zh, types)
- revert nav wiring: sidebar row, command palette entry, nav.webhooks
keybind action + label, commandCenter.nav webhooks entry
* feat(desktop): add skills field to webhook create form
Exposes the backend's per-subscription skills list in the create dialog
(comma-separated) and shows skill badges on subscription rows, so this
page covers the skill-backed endpoint case as well as general CRUD.
- create form: Skills input; passes skills[] to createWebhook
- rows: render skill badges
- i18n: fieldSkills / fieldSkillsPlaceholder (en, zh, types)
Consolidates the skill-endpoint framing from #42817.
Co-authored-by: LionGateOS <98371158+LionGateOS@users.noreply.github.com>
* feat(desktop): render Webhooks as an overlay instead of a full page
Webhooks took over the whole workspace/chat pane because 'webhooks' was
missing from OVERLAY_VIEWS, so it routed through PageSearchShell while
cron rendered through the Panel overlay. Add it to OVERLAY_VIEWS and wire
it up like cron: mount WebhooksView as a floating overlay in wiring.tsx,
render null for the webhooks route in the workspace table, and convert
WebhooksView from PageSearchShell to the Panel primitive with an onClose.
Drop webhooks from route-tile BUILTIN_PAGES so it can't be tiled as a
page either.
* fix(desktop): place webhook URL copy button next to the URL
The URL span used flex-1, stretching it across the row and pushing the
copy button to the far right. Drop flex-1 so the span sizes to content
and the copy button sits directly after the URL.
* fix(desktop): top-align the deliver-only checkbox with its wrapped label
The label used h-9 items-center, centering the checkbox against the
two-line hint text. Switch to items-start so the checkbox aligns to the
first line.
* feat(desktop): give Webhooks a cron-style master/detail layout
Replace the single-column subscription list with the same Panel
master/detail cron uses: a left PanelList of subscription rows (status
dot + kebab menu) capped by a PanelAddButton, and a right PanelDetail
showing the selected subscription's deliver/events/skills, URL with copy,
description, and prompt. Enable/restart banners sit above the body; the
empty state keeps its own New subscription action.
* fix(desktop): drop header on the Webhooks empty state to match cron
The zero-subscriptions state still rendered the PanelHeader with the
title and the refresh/new buttons on the right. Remove it so the empty
state is just the centered PanelEmpty (icon, message, New subscription),
matching the cron empty state. Also drop the header from the loading
state.
* fix(desktop): top-align deliver-only checkbox and its wrapped label
Drop the min-h-9/pt-1.5 baseline shim that pushed the row down and made
the wrapped hint look misaligned. Use plain items-start with a mt-0.5 on
the checkbox so it sits at the first line, and wrap the hint in a
leading-snug span.
* fix(desktop): drop header refresh/new buttons; + button owns create flow
The populated Webhooks header carried a refresh icon and a New
subscription button. Remove both — the PanelAddButton at the bottom of
the list is the create flow, matching cron. Profile-change reload and the
refresh hotkey still run; the restart banner keeps its own refresh.
* fix(desktop): nudge deliver-only checkbox down 2px
Bump the checkbox top margin from mt-0.5 to mt-[4px] so it sits level
with the first line of the wrapped hint.
* refactor(desktop): reuse shared primitives on the Webhooks page
Address OutThisLife's review — stop reinventing primitives the app
already ships:
- copy: drop the local navigator.clipboard button for the shared
CopyButton (routes through the Electron clipboard bridge + haptic +
error state instead of swallowing failures)
- banners: enable/restart callouts now use Alert variant=warning
(primary color-mix tokens) instead of a hand-rolled amber palette
- toggle: detail Enable/Disable is a Switch (messaging idiom), not a
text ghost button
- checkbox: create dialog uses the Checkbox primitive, not a raw input
Rows/chips already moved to PanelListRow/PanelDetail/PanelPill in the
earlier cron-layout pass. Left the main-list fetch on manual load and
the local Field helper: cron itself does both, so useQuery here would
diverge from the reference idiom rather than align with it.
* refactor(desktop): move Webhooks fetch to the react-query layer
Replace the manual useState/useEffect load with useQuery keyed by
['webhooks', profileScope] — profile change re-fetches automatically, no
effect. reload() invalidates the query; the optimistic toggle writes the
cache via queryClient.setQueryData then invalidates so backend truth
wins. Load failures surface via an error-watching effect (react-query v5
dropped useQuery onError). Refresh hotkey calls refetch().
Left the create-dialog Field helper as-is: settings ListRow is a
side-by-side settings row, wrong for a stacked dialog form, and cron's
editor dialog (the reference) defines the same local Field.
* refactor(desktop): drop bespoke Field for shared ListRow/ToggleRow
Remove the local Field wrapper entirely. Every create-dialog field now
uses settings ListRow (wide, so label stacks over the full-width
control), the deliver-only pref uses ToggleRow (ListRow + Switch, haptic
baked in) instead of a bare Checkbox, and the created URL/secret reveal
rows use ListRow too. No component in this file is hand-rolled anymore.
* fix(desktop): pair webhook create fields into a 2-column layout
The single-column dialog scrolled awkwardly. Group fields: name +
description side by side, prompt full-width under them, events + skills
side by side, deliver-to + deliver-only side by side. Drop the
deliver-only help text and rename the label to 'Deliver payload only'
(remove the now-unused fieldDeliverOnlyHint i18n key from en/zh/types).
* fix(desktop): align Webhooks page with cron conventions and DESIGN.md
- Delete copy uses deleteDescPrefix + bolded name + deleteDescSuffix (no em-dash)
- Drop the duplicated detail-header Switch + Trash2; enable/disable and delete
live only in the row kebab, matching CronJobDetail
- Collapse three copyable-value chromes into one flat token-backed CopyValueRow
- Delete success toast gains a w.deleted title
- Replace hand-rolled delete Dialog with shared ConfirmDialog
---------
Co-authored-by: LionGateOS <98371158+LionGateOS@users.noreply.github.com>
Adds a first-run Desktop choice between installing Hermes locally and
connecting to an existing remote Hermes gateway. The choice appears after
backend resolution but before ensureRuntime(), so selecting remote cannot
accidentally trigger local bootstrap.
New modules:
- first-run-setup-gate: concurrent first-run decision gate and reset semantics
- primary-backend-startup: Electron-free orchestration seam (saved remote
resolution, gate decision, remote re-resolution, local continuation)
- primary-connection-rehome: prevents dual-owner race where both cold boot()
and renderer softSwitch() could connect simultaneously
- first-run-remote-form: extracted remote form with stale-result guards
Reuses existing connection-config IPC, encrypted token storage, OAuth
session partition, and primary backend resolution.
Fixes#38602Fixes#36970
Follow-up to salvaged PR #70549:
- Replace fragile 'or' assertions with single precise checks that catch
partial-rewrite regressions (would have masked a missing closing brace)
- Add test_pty_path_uses_rewritten_command covering the PTY spawn path
that was modified but previously untested
Base-url+model dedup was meant for custom shim aliases, but it also
skipped first-class providers that share an inference host while using
different credentials. That stranded xai-oauth spending-limit failover
to the xai API-key provider when both used the same model slug.
A session pinned to a named custom provider could silently reroute to the
user's default provider on resume/rebuild. Session rows persist the RESOLVED
provider — bare "custom" for every named providers:/custom_providers: entry —
and when no base_url survived in model_config, the existing heal
(canonical_custom_identity) had only the config.model.provider fallback left.
For users whose global default is a BUILT-IN provider (e.g. nous) that tier
cannot fire, so the bare provider was dropped, resume fell back to the default
provider with the session's custom model name, and the default endpoint 404'd
with "Model '<x>' not found. The requested model does not exist in our
configuration or OpenRouter catalog." Re-selecting via /model fixed it until
the next resume — the reported symptom.
Add a model-name recovery tier between the base_url reverse-lookup and the
config fallback: find_custom_provider_identity_by_model() maps the stored
model back to the entry that serves it (model/default_model/models catalog,
dict and legacy list shapes). The session row always stores the model, so the
entry identity survives even when the row has no base_url AND the global
default points elsewhere.
All five bare-custom heal sites in tui_gateway/server.py now pass the model:
_ensure_session_db_row, _stored_session_runtime_overrides,
_runtime_model_config, _make_agent, and _model_picker_context.
Stale truncate_before_user_ordinal=0 from a desynced Desktop client
resolved to history[:0] and replace_messages() wiped the durable
transcript. Require confirm_empty_truncate for that edge and have
intentional first-turn restore/regenerate paths send it.
The Telegram adapter's connect retry loop could silently stall after
'Connecting to Telegram (attempt 1/8)...' with the event loop permanently
parked in select() — all threads idle, no attempt 2/8 ever scheduled.
Root cause analysis:
- The retry loop reused the same Application object across all
8 attempts. After a failed initialize() the app could be in a partially-
initialized state (closed httpx transports from ,
or flag set before the hang) causing subsequent calls
to silently skip real initialization.
- CancelledError (a BaseException, not an Exception) propagated silently
through all except handlers with no logging — the task driving the retry
loop could exit without any trace.
- No total watchdog bound existed for the entire retry loop; only per-attempt
timeouts via _await_with_thread_deadline. If the loop itself stalled
between attempts (between-attempt sleep, cleanup, or scheduling), there
was no timeout to catch it.
Fixes:
1. **Total watchdog deadline**: Compute a total deadline for the entire
connect loop (8 attempts × init_timeout + 120s margin). Before each
attempt, check the wall clock; if exceeded, raise OSError immediately
instead of attempting another initialize().
2. **Fresh Application per retry**: On each failed attempt, rebuild
via and re-register all handlers. The old
app is best-effort shutdown with . This ensures
each retry starts with a clean slate — no stale transports, no stale
flag, no leaked state from the previous attempt.
3. **BaseException logging + propagation**: Added
(placed LAST after all other handlers) to log CancelledError and other
non-Exception signals before propagating. Previously these exited the
retry loop silently with no log message.
4. ** block for app rebuild**: The clause runs after
every failed attempt that isn't the last, rebuilding the app and
discarding the old one regardless of which exception class caused the
failure.
Issue #68915: when the agent runs a compound command with trailing & (e.g.
`cd /app && node server.js &`), bash parses it as `(A && B) &` — a subshell
that holds the stdout pipe open forever when B is a long-running server.
The existing _rewrite_compound_background in terminal_tool.py correctly
rewrites this to `A && { B & }` to avoid the subshell fork, but it was only
applied in the foreground execute() path (tools/environments/base.py).
The background spawn_local() path bypasses base.py entirely and passed the
raw command directly to Popen/PTY, leaving the deadlock unmitigated.
Fix: apply _rewrite_compound_background in spawn_local() before the command
is passed to Popen or PTY spawn. Uses a lazy import to avoid circular
dependency (terminal_tool imports process_registry).
- PTY spawn path: now uses safe_command (rewritten)
- Popen spawn path: now uses safe_command (rewritten)
- Session.command still stores the original (unrewritten) command for display
- Simple `cmd &` is left unchanged (no subshell bug)
Tests: 4 regression tests verifying (1) compound is rewritten, (2) simple bg
is preserved, (3) multi-line compounds are rewritten, (4) session.command
stores original.
Follow-up on the salvaged #68469 commit:
- Literal-IP hostnames never take the proxy DNS-delegation path (a
getaddrinfo failure on a literal IP is not a proxy-environment
symptom, and IPs need no DNS) — keeps the private-IP/metadata floor
intact under proxy env vars.
- Adds TestProxyEnvironmentDnsDelegation: delegation fires only for
hostnames, metadata hostname/IP floor holds, DNS-success path
unchanged, empty proxy var ignored.
- Guards the three pre-existing DNS-failure tests against ambient
proxy env vars so they don't flake on developer machines.
When the runtime blocks direct DNS (NVIDIA OpenShell, Docker + Squid,
corporate proxy with DNS-only-via-proxy), socket.getaddrinfo() fails
and is_safe_url() blocks *all* requests — including legitimate public
URLs via the configured proxy.
Add _proxy_is_configured() helper that checks HTTPS_PROXY, HTTP_PROXY,
http_proxy, https_proxy, ALL_PROXY, all_proxy. When DNS fails AND a
proxy is configured, delegate DNS resolution to the proxy rather than
blocking outright.
Blocked hostnames (metadata.google.internal, 169.254.169.254, etc.)
are checked BEFORE DNS resolution, so cloud metadata endpoints remain
blocked regardless of proxy status.
Fixes#32217
Replace 3 duplicated entry_id resolution blocks (try/except +
entry_id_for_api_key + fallback to None) in agent_init.py,
chat_completion_helpers.py, and switch_model with a single
sync_credential_pool_entry_id(agent) function in agent_runtime_helpers.
Follow-up to #70323.
Track the selected credential by stable pool entry ID so token refreshes and shared cursor movement cannot detach failures from the entry that issued them. Stop unmatched single-entry pools from reporting a no-op rotation as successful recovery.
Co-authored-by: Maxim Esipov <maksesipov@gmail.com>
Registers `ar` in the supported-language set and alias table and ships
locales/ar.yaml at full key and placeholder parity with en.yaml, covering
approval prompts and gateway slash-command replies. Identifiers, commands,
paths, config keys, model/provider names, and {placeholder} tokens are kept
verbatim.
Co-authored-by: Da7-Tech <286182457+Da7-Tech@users.noreply.github.com>
Adds the Arabic catalog to the dashboard, registers it in the locale list
and picker, and flips the document direction to RTL when Arabic is active.
Introduces a `defineLocale` merge helper (mirroring the desktop app) so the
Arabic catalog can be a partial override that falls back to English for any
untranslated key instead of hand-porting every future string.
Co-authored-by: morolab <ahmedmoro@gmail.com>
Arabic is the desktop app's first right-to-left locale. The i18n provider
now sets `document.dir`/`lang` from the active locale so Tailwind logical
utilities flip automatically, and `ar` is registered in the catalog,
language options, and alias table. The catalog is a partial `defineLocale`
so keys added to English later fall back cleanly.
Co-authored-by: 3ssiri <assiri@gmail.com>
Co-authored-by: Da7-Tech <286182457+Da7-Tech@users.noreply.github.com>
Follow-ups on the salvaged #54426 routing contract:
- Bare `model` without `provider` on the OpenAI-compatible endpoints
(/v1/chat/completions, /v1/responses) is now opt-in via
gateway.platforms.api_server.direct_model_requests (default off) —
generic OpenAI clients hardcode model names ('gpt-4o', ...) and
existing deployments rely on those falling back to the gateway
default. Explicit `provider` requests and the Hermes-native
session-chat + /v1/runs surfaces are always honored.
Idea credit: PR #22825 by @mssteuer.
- A model_routes alias with no `model` key can no longer leak the
alias string as the executing model name (defensive; parse-time
validation already drops such routes).
- Fix mis-indented _run_agent call args in _handle_session_chat_stream.
- Docs: document the opt-in flag.
Carry model, provider, and model_options through the API server's
execution surfaces (session chat, Chat Completions, Responses, /v1/runs)
without mutating global configuration. Precedence: session /model
override -> model_routes alias -> direct request selection -> global
defaults. Conflicting route/provider mixes fail closed with 400.
model_options stays request-scoped regardless of which selection wins.
Salvaged from PR #54426 by @abundantbeing.
test_docker_network_config.py landed on main after the #58489 revert and
stubbed docker ps with the 2-field ID\tState format. The re-landed
egress-aware reuse probe requests ID\tState\tEgressLabel when egress is
off, so the fake line failed to parse and the reuse path never fired.
Fixture-only change; production behavior is unchanged.
The salvaged #61978 covers tui_gateway/server.py. The crash reported on
Jul 24 came from a sibling site it doesn't touch: the desktop update
panel's _recent_upstream_commits() in hermes_cli/web_server.py runs
git log with text=True and no encoding. Commit 84db32484f put a bug
emoji (UTF-8 f0 9f 90 9b) in a subject on main; byte 0x90 is undefined
in cp1252, so every Windows desktop install behind that commit crashed
in subprocess._readerthread during the update check (#52649).
Guard every text=True capture site in the desktop-backend process with
encoding='utf-8', errors='replace':
- hermes_cli/web_server.py: git log update panel, memory-provider setup
runner, WhatsApp bridge npm install, docker probe
- hermes_cli/banner.py: all 5 git sites (update check runs at startup)
- tui_gateway/host_supervisor.py: build-sha probe, ps probe, compute-host
Popen drain threads
- tui_gateway/compute_host.py: build-sha probe, ps rss probe
The gateway startup maintenance block gained a maybe_auto_archive call in
the same provably-off-loop __init__ site as maybe_auto_prune_and_vacuum;
bump the reviewed sync-escape count from 3 to 4.
Sessions settings gain an "Auto-archive stale chats" toggle with a
configurable idle threshold, persisted to sessions.* in config.yaml so
the backend sweep owns the policy. Sidebar pins (localStorage) are
mirrored to the backend pinned flag at boot and on every change —
pre-existing pins migrate transparently — so the sweep can never hide a
pinned chat.