The native-app (RFC 8252) login passed its unit tests but failed in real
Electron runtime — the tests mocked the exact seams that were broken. Four
runtime defects, each proven against a live gated gateway:
1. Lockfile drift: apps/desktop declared @assistant-ui/react +
@assistant-ui/react-streamdown but package-lock.json didn't place them, so
`npm ci` (CI + every fresh checkout) failed to install them → Vite
"Failed to resolve @assistant-ui/react". Reconcile the lockfile.
2. Double JSON encoding: postJsonNoAuth pre-JSON.stringify'd the body before
fetchJson (which stringifies again), so /auth/native/token received a JSON
string, not an object → gateway 422 "Input should be a valid dictionary" →
native login silently fell back to the embedded webview.
3. Cookie-only liveness gate: buildRemoteConnection (and the Settings
connected indicator) treated "signed in" as "has OAuth cookie". The native
flow stores a bearer and sets no cookie, so a completed native login looped
the UI into needsOauthLogin. Accept native token OR cookie.
4. Cookie-only REST path: the hermes:api handler routed oauth-mode REST through
the cookie partition only. A cookieless native session → 401 no_cookie on
every API call. Prefer the native bearer (with transparent refresh), else
cookie — mirroring mintGatewayWsTicket, which was already bearer-aware.
The three decision points (2–4) are extracted into a pure
native-auth-decisions.ts with regression tests, since the mocked flow tests
could not catch them. Verified live: system-browser login → cookieless bearer
→ connected chat, no embedded webview.
The Desktop app can now sign in to a gated gateway using the user's SYSTEM
browser and OAuth 2.0 for Native Apps (RFC 8252) instead of an embedded
Electron BrowserWindow, and authenticates with bearer tokens it holds itself
instead of relying on HttpOnly browser session cookies.
Why brokered: the upstream IDP (Nous Portal) binds client_id to the gateway
instance and only permits redirect_uris on the gateway's own origin, so a
desktop loopback redirect can't be a direct Portal client. The gateway
therefore acts as the authorization server TO the desktop and an OAuth client
TO the Portal, reusing the existing PKCE start_login/complete_login provider
path unchanged.
Server (Ben's dashboard-auth lane):
- native_flow.py: in-memory broker — binds the desktop's PKCE challenge to a
completed Session, mints a single-use, short-TTL, PKCE-verified gateway
authorization code. Constant-time compare, single-use (consumed before the
PKCE check so a wrong verifier can't be retried), capacity-bounded.
- routes.py: GET /auth/native/authorize (starts the brokered PKCE login,
loopback-only redirect_uri, S256-only), POST /auth/native/token (loopback
code + verifier -> tokens in the JSON body, never Set-Cookie), POST
/auth/native/refresh (desktop-held RT rotation). /auth/callback branches to
mint a loopback code + 302 to 127.0.0.1 when a broker_state rides the PKCE
cookie; the cookie/SPA path is untouched.
- middleware.py: the gate accepts Authorization: Bearer <access_token>,
verified via the same verify_session provider stack (no cookie set/read),
with the same "provider unreachable -> 503, not logout" semantics.
- web_server.py /api/status: advertise auth_flows (["cookie","native_pkce"])
so clients can detect the capability; native_pkce only when a brokerable
OAuth provider is registered.
Desktop (Ben's lane):
- native-oauth.ts: pure PKCE/capability/URL/callback/token helpers.
- native-oauth-login.ts: loopback-listener orchestration (system browser via
openExternal, ephemeral 127.0.0.1 listener, state/PKCE verification), all
I/O injected for testability.
- main.ts: capability-gated oauth-login IPC — native flow when advertised,
automatic fallback to the existing embedded-webview cookie flow otherwise;
tokens stored encrypted (safeStorage/OS keychain), REST + ws-ticket
authenticated by bearer, transparent refresh, logout clears both shapes.
Tests: 18 server pytest (broker unit + full authorize->callback->token E2E +
cookieless bearer auth of a gated route + ws-ticket mint + capability
advertisement + refresh); desktop node --test/vitest for both pure modules
(PKCE, capability detection, callback CSRF, loopback round trip, timeout,
browser-open failure). Electron project typechecks clean.
Docs: website/docs/guides/desktop-native-signin.md.
* feat(desktop): configure repository discovery
* fix(config): preserve additive default migration
* fix(desktop): stabilize session-actions-menu gateway mock for repo-scan subscribe
projects.ts now runs $gateway.subscribe(syncReposScanning) at module load, and
nanostores fires the subscriber synchronously. session-actions-menu.test.ts
reaches projects.ts transitively via the session store but mocked
@/store/gateway without $gateway, crashing the whole desktop vitest suite
("No \ export is defined"). Simply adding $gateway: atom(null) exposed a
second issue: the synchronous subscriber calls the mock's activeGateway()
during the transitive import, before the module-level const initializes (TDZ).
Hoist the mock fns via vi.hoisted() so activeGateway is defined before the
hoisted vi.mock factory runs, and add $gateway: atom(null) to the mock. Mirrors
the self-contained mock pattern already used in projects.test.ts. Also maps the
PR author's commit email for attribution.
Supersedes #67630; incorporates review feedback from that PR.
Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>
---------
Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>
Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com>
Drop the unused DEDUPE_WINDOW_MS export and rename its interval so
"window" isn't overloaded against BrowserWindow in a multi-window
feature (windowMs → intervalMs). DRY the completion-sound play path.
No behavior change.
With multiple full windows, each renderer independently reacts to the
same backend event, so one-shot cues fired N times: OS notifications
(the per-renderer throttle can't see other windows), the turn-end sound
(playCompletionSound runs on every message.complete, ungated by focus),
and auto-spoken replies (double voice when a chat is open in two windows).
Add a single race-free owner in the main process (electron/event-dedupe.ts):
main handles IPC serially, so the first window to claim a key within a
short window wins and peers stay quiet. Notifications collapse at the
hermes:notify choke point; the sound and spoken replies claim via a new
hermes:ambient:claim IPC (keyed by session / reply id). Off Electron the
claim falls back to "emit", preserving single-window behavior.
The sound's mute check runs before the claim so a muted window can't win
the cue and silence an audible peer.
Repoint session.newWindow (⌘⇧N) from the compact new-session pop-out to
openNewWindow(), which opens a full peer instance via the new openWindow
bridge, and add a "New Window" entry to the ⌘K palette (shown with its
hotkey hint, gated on canOpenNewWindow()). Relabel the action "New window".
Drops the retired openNewSessionWindow bridge and the vestigial
isNewSessionWindow()/new=1 flag; renames the shared opener helper.
Add createInstanceWindow() — a full-chrome peer of the primary that
renders the complete app (sidebar, routing, its own draft) against the
shared backend, so several GUI windows can run at once. Mirrors the
primary's window options + chatWindowWebPreferences (backgroundThrottling
stays off so a streamed answer never stalls when blurred) but never
overwrites the mainWindow global and doesn't respawn the backend — the
renderer's getConnection() joins the running one. New windows cascade off
their source via the pure, tested instanceWindowBounds().
Exposed via the hermes🪟openInstance IPC and a "New Window" File
menu item. Per-window fullscreen state now targets the window itself, and
titlebar/native-theme repaints reach every open chat window instead of
only the primary.
Retires the now-orphaned compact new-session pop-out (its only caller was
⌘⇧N, repointed in the follow-up commit): drops createNewSessionWindow,
the hermes🪟openNewSession handler, and the newSession/new=1 URL
flag.
The SSH modules predate the stricter lint config that landed on main (curly, no-empty, perfectionist sorting, prettier). Mechanical lint:fix + fmt pass, empty catch blocks filled with the codebase's void-0 convention, and inline no-control-regex disables on the three deliberate control-char patterns (same pattern as lib/ansi.ts).
Sync with main after the contribution-shell refactor (#60638) and the backendConnectionState extraction (#65885) landed. Seven conflicts, all resolved in favor of main's new architecture with the SSH lifecycle ported on top:
- electron/main.ts: adopt backendConnectionState (attempt tokens, attachProcess, clearPromiseForAttempt) as the sole owner of the primary backend; drop the branch's raw hermesProcess/connectionPromise globals and commitConnectionFailure call site. SSH liveness-streak classification, teardown, and the SSH quit path are layered onto the new ownership model; before-quit combines SSH transactional teardown with the Windows sandbox marker (#38216).
- desktop-controller.tsx: deleted on main; its SSH beforeConnectionSwitch cleanup (preserved-route fresh draft, overlay return-route reset, project-tree reset, close-all-terminals) moves to contrib/wiring.tsx's useGatewayBoot call.
- use-session-actions/index.ts: keep main's onFreshDraftRouteIntent (fires unconditionally); preserveRoute gates only the navigate.
- gateway-settings.tsx: keep main's acceptSavedConfig/connectedCloudUrl; every save/sign-in/sign-out success path routes through acceptSavedConfig with the branch's stale-async seq guards intact.
- boot-failure-reauth.ts: keep both sshFailureMessage and main's isRemoteReauthFailure formatting.
- Both test harnesses take the union of props.
Validation: tsc -b clean; desktop suite 1704 passed / 1 skipped; electron SSH/connection modules 225 passed; python SSH + web_server suites 508 passed.
Keep-awake lives only in Settings → Advanced now. Remove the statusbar
quick-toggle (+ its Sun icon, store toggle helper, and keepAwakeOn/Off
strings across locales). Since the statusbar was what eagerly loaded the
store at boot, move persistence to the main process (keep-awake.json,
re-applied on app ready — same pattern as translucency), so a cold launch
restores the blocker without the renderer opening Settings.
Blocking #1 — gateway-connecting-overlay.tsx reduced-motion regression:
the top `if (reduce) setPhase('gone')` fired unconditionally on mount
whenever reduce-motion was on, so every OS reduced-motion user lost the
CONNECTING overlay during cold boot entirely (jumped to 'gone' before the
gateway was even open). The intent was to skip the exit *choreography*,
not to skip showing the overlay. Removed the unconditional top block and
the redundant nested preview block; kept only the third branch
(`gatewayState === 'open' && shownRef.current` → `reduce ? 'gone' :
'text-out'`) which correctly gates the short-circuit on connect. Also
fixed `if(reduce)` missing-space, 6-space misindent, and the same 3-line
comment pasted three times.
Nit #1 — tsconfig excludes e2e, so specs were never typechecked in CI.
Added tsconfig.e2e.json (extends base, includes e2e/ + playwright.config.ts,
adds @playwright/test types) and wired it into the typecheck script. This
surfaced three latent type errors that are fixed in the same commit:
- fix-electron-tracing.ts: `app._context` and `electron._playwright` are
private APIs — added `as any` on the access before the existing cast.
- playwright.config.ts: `reducedMotion: 'reduce'` directly under `use:`
is not a valid UseOptions property in playwright 1.58; it's a
BrowserContextOption accessed via `contextOptions: { reducedMotion:
'reduce' }`. The old form was silently ignored at runtime, so
reduced-motion emulation wasn't actually active — screenshots could
catch overlays mid-fade (exactly what the comment warned about).
Nit #2 — fix-electron-tracing.ts reaches into Playwright internals
(_playwright, _allContexts, _context) with no public contract. Added a
header comment calling out the `@playwright/test` exact pin (=1.58.2) so a
future bump knows to re-verify the private symbols still exist.
Nit #3 — main.ts TEST_WORKER_INDEX block had stray 6-space indentation.
Verified: tsc -p . && tsconfig.electron && tsconfig.e2e → 0 errors;
vitest boot-failure-overlay (3/3) + boot-failure-reauth (21/21) pass;
npm run build clean; playwright e2e/boot-failure.spec.ts 2/2 pass.
Add a "keep computer awake" toggle (Claude-style) for long/overnight runs:
the renderer owns the device-local pref and mirrors it to the Electron main
process, which holds a single `powerSaveBlocker('prevent-app-suspension')` —
the same authority split as translucency. Surfaced as a statusbar quick-toggle
and a Settings row.
Introduce a dedicated System settings section (device-local machine prefs) and
de-crowd Appearance by moving Window Translucency + UI Scale into it (both are
main-process/window-owned, not visual theme). Give Haptic Feedback its first
Settings home there too (the titlebar quick-toggle stays). Relocated i18n copy
into `settings.system` across en/zh/zh-hant/ja; wired the `system` route into
the SettingsView union + allowlist + nav.
The boot-failure screenshot showed a progress bar because of two bugs:
1. waitForBootFailure matched on "Let's get you setup" (the onboarding
header that mounts from frame 1 during normal boot), so the screenshot
fired at ~86% progress while the Preparing component's progress bar was
still painted.
2. The Preparing component kept rendering the progress bar even after
boot.error was set — it just turned the bar red and appended the error
text below it.
Fixes:
- Preparing bails out (returns null) when boot.error is set, so
BootFailureOverlay (z-1400) owns the screen exclusively.
- applyDesktopBootProgress no longer clobbers a previously-set boot.error
when a late progress event arrives with error: null — failDesktopBoot is
terminal for the boot cycle.
- waitForBootFailure guards against progress bars being visible and matches
on actual failure signals (error toast, Retry/Repair buttons), not the
onboarding header.
- setupDeadBackend now accepts { fakeError: true } which injects
HERMES_DESKTOP_BOOT_FAKE_ERROR to trigger a real boot failure — the
previous dead-provider fixture never actually caused a boot failure
(hermes serve starts fine; the dead endpoint only matters at chat time).
- boot-failure.spec.ts updated to use { fakeError: true }.
Verified: e2e test passes with 0 progress bars in the DOM at screenshot
time (confirmed via DOM inspection), 16/16 vitest tests pass, typecheck
clean.
- Type gatewayState in session store
- Electron main.ts: force-show window for e2e test workers
- tsconfig: include e2e test types
- nix/devShell.nix: add cage for headless visual testing on tiling WMs
Detect the remote platform on connect and Test SSH: uname first (Linux/Darwin
keep the POSIX lifecycle), else an encoded-PowerShell probe that routes a
Windows host through connectWindowsRemote. The Windows lifecycle mirrors the
POSIX one — probe, one-shot token upload, spawn, readiness scrape, tunnel,
reuse-by-exact-ownership, and teardown — over a PowerShell helper dialect.
Preserve a backend on indeterminate process state (never destroy on unknowns);
a thrown terminate aborts before remove-lock so a live backend is never
orphaned. Interactive terminals on a Windows backend open PowerShell in the
requested cwd instead of a POSIX login shell.
A `persist:` partition's cookie store hydrates lazily, so the first
cookies.get() on a fresh launch can return empty for a signed-in user.
That false-negative made hasLiveOauthSession() throw "not signed in",
which on the no-retry initial boot path surfaced as the transient
"Hermes couldn't start" OAuth overlay that always cleared on Retry.
hasLiveOauthSession now reads once (no added latency on the happy path);
only on an empty read does it warm the store (flushStorageData + a
throwaway get, memoized) and re-read with a bounded ~180ms backoff
before trusting the negative. Genuinely signed-out users still resolve
false quickly and get the overlay. Fixes the whole class: the same
function backs the reconnect path and the Settings connected indicator.
* fix(desktop): allow write-build-stamp from non-git checkouts
Stop hard-failing npm pack when neither GITHUB_SHA nor git HEAD is
available (ZIP installs / broken .git). Emit an explicit fallback stamp
instead so local Windows desktop builds can finish (#50823).
* fix(desktop): treat fallback stamps as unpinned; harden Windows install
Keep all-zero fallback commits out of -Commit/--commit pins and fetch
install.ps1 by branch instead. After bootstrap, pin the marker to the
checkout HEAD so isBootstrapComplete accepts it. On Windows, force ZIP
checkout, seed GITHUB_SHA (ASCII-only install.ps1), and avoid the pack
stamp failure.
* fix(install): pin core.autocrlf=false before ZIP-path checkout (#50823 review)
The ZIP-fallback path added in #67643 runs `git checkout -f FETCH_HEAD`
before core.autocrlf gets pinned (which only happened later, on the
shared clone-path config). On Git for Windows -- where core.autocrlf
defaults to true -- that renormalizes the repo's LF text files to CRLF in
the working tree during checkout, leaving the freshly-created managed
checkout dirty versus HEAD and aborting the next `hermes update`. That is
the exact "dirty tree the user never touched" failure the surrounding
code already guards against (install.ps1:1461-1469, 1750-1753).
Move the `config core.autocrlf false` pin to run immediately after
`git init`, before the fetch/checkout. The later idempotent pin on the
shared clone path is retained so git-clone installs are unaffected.
Addresses teknium1's review on #67643 and supersedes it, preserving the
original author's two commits.
Co-authored-by: HexLab98 <8422520+HexLab98@users.noreply.github.com>
* chore(contributors): map austinpickett commit email for attribution
The check-attribution CI gate flagged austinpickett@users.noreply.github.com
as an unmapped commit-author email (introduced by the autocrlf fix commit
on this PR). Add the per-email mapping file as the gate instructs (the
legacy AUTHOR_MAP in scripts/release.py is frozen).
---------
Co-authored-by: HexLab98 <liruixinch@outlook.com>
Co-authored-by: austinpickett <austinpickett@users.noreply.github.com>
Co-authored-by: HexLab98 <8422520+HexLab98@users.noreply.github.com>
simple-git's custom-binary validation rejects paths containing spaces, so
the default Windows Git install (C:\Program Files\Git\cmd\git.exe) made
every Review pane git call throw and the pane silently showed 'No diffs'.
The binary is resolved inside the Electron main process from known install
locations or PATH — never renderer/user input — so for spaced paths we opt
into simple-git's supported unsafe.allowUnsafeCustomBinary escape hatch
rather than falling back to PATH (often absent in GUI-launched apps).
Simplified from PR #64713 by @unsupportedpastels; supersedes the 8.3
short-path approaches in #55337/#60156.
Fixes#54888
The sidebar refresh fired three /api/profiles/sessions calls (recents, cron,
messaging), and each one reopened every selected profile's state.db and re-ran
list_sessions_rich + session_count — ~3N DB opens/counts per refresh, on every
turn/broadcast/reconnect.
Add GET /api/profiles/sessions/sidebar: one pass that opens each profile DB
once and runs the three source-scoped queries together (recents scoped to the
active profile; cron + messaging cross-profile), returning the three windows in
one payload. Same read-only projection, 300s active heuristic, and caller-
supplied source taxonomy (recents_exclude / messaging_exclude / source=cron) as
the per-slice endpoint.
Renderer refreshSessions now makes one listSidebarSessions call and distributes
recents/cron/messaging to their stores (cron *jobs* stay a separate getCronJobs
API). Electron splices remote profiles per slice via fetchProfilesSessionSlice
(reusing the proven per-slice merge) so remote correctness is preserved; the
no-remote common case gets the single-open fast path.
From the Desktop performance audit (P1: "Batch sidebar session slices").
Surgical reapply of the surviving half of PR #43517 by @jingsong-liu
(the branch predates the ts-ify migration; its other half — zoom restore
after reload/navigation — landed via #66989).
On US layouts Plus is physically Shift+=, so Cmd+Plus arrives with the
shift modifier set. The blanket 'input.shift' early-return in
installZoomShortcuts silently swallowed keyboard zoom-in on macOS: the
chord matched neither branch and fell through to nothing. Shift is now
evaluated per-chord: zoom-in accepts it, zoom-reset and zoom-out still
reject it (Ctrl/Cmd+Shift+0 and Shift+'-' are different chords).
Ground-truth reapply of PR #40414 by @liuhao1024. The original branch
predates the ts-ify migration and implemented the gesture by injecting
a DOM wheel listener via executeJavaScript + a new IPC channel. Current
Electron surfaces the modifier+wheel gesture natively as the main-process
webContents 'zoom-changed' event, so the salvage uses that instead:
no renderer injection, no new preload surface, no new IPC channel.
The handler routes through setAndPersistZoomLevel — the same
persist+notify funnel as the keyboard shortcuts — so wheel zoom uses
the same 0.1 half-step, persists to zoom-state.json across restarts,
and keeps the settings Scale control in sync. Session windows get the
gesture automatically via wireCommonWindowHandlers; the pet overlay
stays opted out via zoomWiringForWindowKind.
Surgical reapply of PR #46429 by @SongotenU (the branch predates the
ts-ify migration; main.cjs no longer exists so a direct cherry-pick
cannot apply).
Main already adopted half of the PR's fix when zoom restore moved into
wireCommonWindowHandlers (57dfebe3d) — session windows are covered. The
surviving delta is the listener lifetime: 'once' spends the listener on
the first did-finish-load, so any later full load in the same
webContents — the crash-recovery webContents.reload(), a manual reload,
or an in-place navigation that lands on a fresh per-host zoom entry —
came up at default zoom while the settings Scale control still showed
the persisted percentage. 'on' re-applies the persisted level after
every completed load; restorePersistedZoomLevel routes through the
applyZoomLevel funnel so the renderer stays in sync.
Surgical reapply of the surviving halves of PR #57414 by @Sahil-SS9
(the branch predates the ts-ify migration and the zoom apply/notify
funnel, so a direct cherry-pick no longer applies):
- Zoom persists to a main-process zoom-state.json as the primary store.
The old localStorage-only store lives under Electron's cache/storage
folders, which crash recovery can move or recreate — wiping zoom
exactly when the user recovers from a crash. localStorage stays as a
secondary mirror; pre-JSON installs migrate on first read.
- Window geometry persists at ready-to-show, so a crash before the
first resize/move/close still captures the restored bounds.
The third half of #57414 (one-shot --no-sandbox relaunch on Windows
renderer crash loops) was superseded by #66842, which ships the same
recovery gated on the 0x80000003 sandbox-crash signature.
Adapted to current main: restore/persist route through the
applyZoomLevel funnel (39230d173) so the settings UI Scale control
stays in sync, and JSON writes go through writeFileAtomic.
Follow-up to the salvaged #66803 (@HexLab98):
- Two-strike boot marker: a single mid-boot abort (task-manager kill,
power loss) no longer disables the sandbox — only a second consecutive
abort, or a signature-confirmed GPU/renderer STATUS_BREAKPOINT death,
engages --no-sandbox.
- Version-scoped stickiness: the fallback marker records the app version
and re-probes the sandbox once after an update (new Electron or
installer ACL repair may have fixed the host) instead of degrading
forever. A failed re-probe returns straight to fallback.
- Launch-time icacls repair now runs only when the marker shows a prior
aborted boot (icacls /T recurses the whole install tree — healthy
launches skip it; the installer grants the ACE at install time), and
targets the install dir only. The userData grant is dropped: granting
S-1-15-2-2 RX on userData would expose Hermes sessions/config to every
AppContainer app on the machine.
- Renderer crash-loop recovery (same class as #56726, credit @Sahil-SS9
in PR #57414): a Windows renderer crash loop bearing the breakpoint
exit code gets the same one-shot --no-sandbox relaunch instead of a
dead window; unrelated crash loops keep the sandbox.
- Manual --no-sandbox launches are honored but never made sticky.
Tests: 15/15 windows-sandbox-fallback vitest; full desktop electron
suite 432 passed / 1 skipped.
Hot-path pass following the switch-latency work. Four independent costs,
one theme — work that runs on every boot or every turn but only needed
to run on actual change:
- electron: start the Python backend in parallel with the renderer load
instead of on did-finish-load. The backend cold boot is the dominant
startup cost and was serialized behind Chromium's load; the connection
promise is shared, so the renderer's getConnection() joins the
in-flight boot, and its getBootProgress() pull on mount recovers any
progress events emitted before the renderer was listening.
- boot/soft-switch: after the socket connects, run the independent
post-connect fetches (cwd seed, config, session lists) concurrently
instead of serially — profile adoption still lands first because the
session fetch scopes by it.
- session.info: config refetch is now gated to the foreground context
and coalesced (one trailing fetch per event burst) — it used to fire
two REST calls per event, including background sessions' heartbeats.
model-options invalidation now requires a VALUE change vs the
session's cached runtime state; the backend stamps model/provider on
every event, so the presence-typed flags refetched the provider
catalog once or twice per turn for a model that never changed.
- turn complete: sidebar refreshes (recents + cron + messaging fan-out,
each scanning profile state.dbs server-side) coalesce across
near-simultaneous completions; $sessions and profile totals keep
their identity when a refresh returns content-identical rows (same
signature gate cron/messaging already use), and the loading flag no
longer flickers over a populated list.
node-pty's published npm tarball ships the POSIX `spawn-helper` with mode
0644 (no exec bit). node-pty `posix_spawnp`s that helper on macOS/Linux, so a
non-executable copy fails every embedded-terminal spawn with
`Error: posix_spawnp failed.`. Packaged builds are unaffected because
stage-native-deps.mjs chmods the staged copy, but the dev flow
(`npm run dev` -> `electron .`) resolves node-pty straight from node_modules,
which nothing chmods -- so the first terminal in dev always dies.
Restore the exec bit once, lazily, right before the first spawn, via a small
DI-testable helper. Idempotent: already-executable copies (packaged builds)
are left untouched, and stat/chmod failures are collected and logged rather
than thrown so terminal startup never breaks.
* fix(desktop): trust Windows system CAs for remote gateways
Load Windows-trusted roots into Node's default TLS context before Desktop probes remote backends, while preserving bundled and extra CAs.
* test(desktop): cover Windows system CA installation
Verify existing trust roots survive the merge and that unsupported or unavailable stores fail open without changing TLS defaults.
Reassert the persisted webContents zoom after BrowserWindow moved, covering Windows monitor transitions where Chromium recalculates display scaling and drops the user-selected zoom.
Rebased onto current main, where the electron test harness migrated from
node:test to vitest (test:desktop:platforms = `vitest run --project electron`,
auto-discovering electron/*.test.ts). Swap the node:test import for vitest and
drop the .ts-extension import hack; the obsolete package.json node --test list
edit is dropped in the cherry-pick resolution since vitest auto-discovers.
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>