`new URL("//", origin)` threw inside the http request handler, so a
request like `GET //` escaped as an uncaught main-process TypeError and
was never answered. Reject unparseable targets up front.
YouTube shows error 153 unless the page embedding its player has an http(s)
origin matching the player's `origin` param; the packaged renderer is file://
and Electron drops stamped Referer headers. Serve one wrapper page per video
from a lazily started 127.0.0.1 host in the main process and point the
file:// renderer's YouTube iframe at it. The renderer itself stays on file://,
so settings and backend connections don't move. Dev/web renderers, which
already have an http origin, keep embedding directly, and so does a renderer
bundle running on a main process without the host.
The out-of-scope strip matched any request header whose value the session
had ever injected, so an unrelated request that happened to carry the same
name+value (e.g. an identity provider using the same API key header) had it
silently removed, and the value set grew without bound for the session's
lifetime.
Track the header names injected per webRequest id instead: an out-of-scope
hop of that same request drops them, every other request is untouched.
Entries are forgotten on webRequest.onCompleted/onErrorOccurred so the map
stays bounded by in-flight requests.
Also: the tracking map is now a required parameter (no silent per-call
default that disables stripping), requests with nothing to add and nothing
tracked take a callback({}) fast path, redundant String() coercions are
gone, and the configured headers are walked once. The test swaps the
production-forbidden Authorization header for X-Api-Key and pins that an
unrelated request with the same name and value is not stripped, including
after the original request id completes.
Chromium carries app-set request headers (Authorization, CF-Access-*) across
redirects, so a gateway that 30x-redirects to another origin or a path outside
the connection's scope leaked those credentials to the redirect target.
applyRemoteRequestHeaders now remembers every configured header value the
session injected and removes it from hops that are outside its gateway scope,
re-applying only the headers resolved for the current URL. Unrelated requests
that happen to use the same header name with a different value are untouched.
Hand rebuild of #129625 onto current main: keeps the withEmbedRefererStamp
wrapper on the single onBeforeSendHeaders listener, drops the live Electron
fixture/test.
Salvage of #129625.
Public http:// and LAN hosts navigate; data:, javascript:, file: and garbage
still return null. The matrix kvnloo asked for on #129758, now that the
host allowlist is gone and the protocol check is the only gate.
The threshold-crossing event was dropped before onEvent. For a preview watch,
1000 sibling events followed by the target-file write tripped the breaker on
the write itself, and the poll baseline was then taken after that change, so
it was never reported. The triggering event is now forwarded; only the events
queued behind it are dropped.
The storm-breaker poll mapped a throwing snapshot to '', which is also the
valid readdir snapshot of an empty directory, so an empty watched directory
vanishing after a trip was never reported. Failures are now null.
The expected-404 change is inert without its helper: the catch block guards
on isNotFoundApiError, but the function (defined next to
handleHermesApiRequest in the source commit) was lost when the change was
ported onto bb/. Restore it verbatim.
resolveStoredSession() is a probe ladder — cache, then a by-id GET on the
active backend, then the same GET scoped to each other profile until one
hits. A miss on rungs 2 and 3 is expected and already handled: the backend
answers 404 {"detail":"Session not found"} and the renderer falls to the
next rung.
But ipcMain.handle logs "Error occurred in handler for 'hermes:api'" with a
full stack for every rejected invoke, with no per-handler opt-out — so each
designed fall-through printed a multi-line stack on startup and on every
session switch, burying real hermes:api failures.
Extract the handler body, and for that one expected case resolve with a
sentinel instead of rejecting; preload — our own code on the other side of
the same seam — rethrows it as an Error with the identical '404: <body>'
message. The renderer contract is byte-identical (the ladder and its tests
are untouched); only Electron's logging is bypassed. Every other failure
still rejects and still logs in full.
The sentinel check is deliberately narrow (a plain object whose only key is
the sentinel, with a string value) so no backend payload can be mistaken
for one.
Fixes#79265
Chat embeds are plain iframes in the default session, and Electron allows a
single onBeforeSendHeaders listener per webRequest hook — installRemoteHeaderRules
registered its own listener on session.defaultSession, so a separate stamp there
was silently discarded. Compose the stamp into the shared listener (belt-and-braces
alongside the loopback renderer origin from the previous commit).
Co-authored-by: Konstantin Khlopkov <konstantin.khlopkov93@gmail.com>
Packaged builds load the renderer over `file:`, whose origin is opaque
("null"). YouTube rejects a null-origin embed, so every YouTube embed was
dead in a packaged app while working in dev (where the renderer is served
over http from the Vite dev server).
Serve the packaged renderer from a loopback HTTP origin instead, so embeds
get a real origin in packaged builds too. `rendererBaseUrl()` becomes the
single place that resolves the renderer location -- dev server, packaged
HTTP origin, or the file:// fallback -- and the main window, session
windows and the pet overlay all route through it.
Addresses both problems raised in review of #63492:
- Navigation guard: `will-navigate` previously admitted any `file:` URL when
packaged, and the earlier revision of this PR compared URL strings against
the base. Both are unsound. A string compare accepts
`http://127.0.0.1:47891@attacker.example/` -- everything before the `@` is
userinfo, so it shares our prefix while its real origin is
attacker.example -- which would load attacker content into a window
carrying the `hermesDesktop` preload bridge. Navigation is now checked by
PARSED ORIGIN in a pure, unit-tested helper (`renderer-url.ts`), with a
regression that asserts the old prefix check would have let the hostile URL
through. `file:` bases compare by resolved path, since their origins are
all "null".
- Port collision: the server bound a fixed 47891 and rejected on any bind
error, while `whenReady` awaits it before `createWindow()` -- so an
occupied port meant startup never reached window creation. It now falls
back to an OS-assigned ephemeral port, covered by a test that binds a
squatter on the requested port first. The fixed port is still preferred
because the renderer's origin keys its localStorage, so a port that
changed per launch would orphan persisted renderer state.
Session windows keep upstream's `rendererIndexPath` path for the file://
fallback: passing a file:// URL as `devServer` would have produced
`index.html/?win=secondary`.
Scoped to the embed fix only. The OAuth/boot-recovery changes that were
mixed into this branch are split out into a separate change.
Main's menu template is MenuItemConstructorOptions[], so the local
WindowMenuItem interface (role?: string, submenu: Array<{role: string}>)
no longer satisfies it and tsc rejects the pushed item. Derive the type
from MenuItemConstructorOptions instead: role comes from Electron's own
role union (which includes 'windowMenu'), and submenu items are full
MenuItemConstructorOptions so the click-only Close item fits.
Also carry main's #65457 behaviour into the extracted template: the
non-macOS Close item is click-only with registerAccelerator: false, not
role: 'close' (which would claim CommandOrControl+W before the
before-input-event run that routes terminal-focused Ctrl+W to word
erase). Test updated to match.
Co-authored-by: victorjmilagres <294917029+victorjmilagres@users.noreply.github.com>
Re-apply of the import-order fix-up from the superseded branch: on today's
main the window-focus-policy import sits between the hunks, so the
perfectionist/sort-imports resolution is window-focus-policy →
window-menu → window-open-policy (eslint --fix verified clean).
The stale-fullscreen heuristic inferred staleness from geometry alone
(near-origin + >=90% of the work area), so a valid normal window the user
sized on purpose was rewritten to a centered 80% window with its position
dropped (review on window-state.ts:161).
Recovery no longer guesses:
- persistWindowState records boundsCapturedFullScreen at save time via
the existing save path (sanitizeWindowState schema, backward
compatible: absent on legacy snapshots, strict boolean like
isMaximized).
- Flagged snapshots recover on the work area they cover, as before.
- Legacy unflagged snapshots recover only on an unambiguous match:
bounds equal to a display's work area or full bounds within 2px,
never the >=90% shape.
The reviewer's exact case (1824x988 at (0,0) on a 1920x1040 work area) is
now preserved verbatim; the motivating legacy 1920x1040 snapshot and an
exact full-display-bounds snapshot still recover.
eslint --fix on main.ts and windows-sandbox-fallback.test.ts only:
sort-imports moves the linux-gpu-fallback import into its slot and
sort-named-imports orders the test's named imports; the padding-line
warnings it also fixed in touched hunks are warning-severity only.
The #121954 Linux SIGTERM signature reads details.signalName, but the
option type only declared type/exitCode, so tsc rejected the test
fixtures after the rebase onto current main.
One GPU-child death now produces exactly one bounded relaunch instead of
two racing ones: the #121954 sandbox signature (SIGTERM/143) is tried
first — that host matrix shows --disable-gpu still crashes — and any
other GPU failure reason falls to the #124843 software ladder,
including the relapse after a --no-sandbox boot died again.
Also: HERMES_DESKTOP_DISABLE_GPU=1 now appends --disable-gpu so the GPU
process is fully spawn-blocked, not just compositing (#97616 — a GPU
child still spawned and died error_code=1002 with compositing-only
switches), and a post-reveal check catches the #124843 silent-retry
manifestation (window reveals, no gpu-process child, sub-zygote
spinning at ~350% CPU) that the reactive ladder never sees, engaging the
sticky software fallback for the next launch.
On Linux hosts where the sandboxed GPU child cannot start (dies pre-main
on an FD-ownership violation), Chromium prints "GPU process isn't usable.
Goodbye." and aborts before the UI shows — a deterministic crash loop
(four SIGTRAP/SIGSEGV crashes in two days on the reporting host). The
host isolation matrix in #121954 shows only --no-sandbox reaches the UI;
--disable-gpu crashes too, ruling out a driver/ANGLE cause.
The #38216 recovery ladder is exactly this shape: two consecutive
mid-boot aborts engage --no-sandbox, sticky per app version, with a
one-shot sandbox re-probe after an update. Extend it to Linux:
- decideWindowsSandboxLaunch accepts linux alongside win32 (platform
parameter is already injectable; behavior on other platforms and on
healthy Linux hosts is unchanged — healthy boots write an ok marker).
- shouldRelaunchForGpuSandboxCrash gains a narrow Linux fast path: GPU
child killed with SIGTERM / exit 143, Chromium's own shutdown for a
GPU process that never became usable. Windows keeps the breakpoint
signature; other Linux exits (SIGSEGV 139 driver faults) do not fire.
- Renderer crash-loop relaunch and ACL repair stay Windows-only (no ACL
model on Linux; Linux renderer loops carry no sandbox signature).
main.ts gates the ladder block, the successful-boot marker write, and
the clean-quit marker write on linux alongside win32; the GPU child
handler is shared unchanged. First crash on a fresh Linux host now
recovers into a usable app via the relaunch instead of dying.
On Mesa/Wayland the Chromium GPU child can fail init (error_code=1002)
and retry inside a sub-zygote forever: ~350% CPU, no gpu-process, no
crash. Catch the GPU launch-failure/crash via child-process-gone,
relaunch once with --disable-gpu, and keep a sticky per-version marker
so the next boot goes straight to software rendering. Reactive only:
healthy stacks keep full acceleration. Re-probes the GPU once after an
app update instead of degrading forever.
Review follow-up: HERMES_DESKTOP_DISABLE_GPU=0 (the documented opt-out)
now clears the stale fallback marker instead of carrying it into the
override boot, so the next launch without the override keeps the GPU:
previously the marker stayed `fallback`, main.ts stayed sticky, and only
an app-version change could ever clear it. The --disable-gpu relaunch
path keeps preserving the marker — that boot must stay sticky.
Cold start read spawn-ledger.json, picked the newest record, and attached
without checking PID liveness. After any shutdown that record is dead, so
the app dialled a port nobody listens on. spawnOrAttach() takes an optional
isPidAlive probe (wired to isPidAliveWindows in main) and decides spawn for
dead PIDs before any network I/O; the attach fallback rung skips them too.
PID reuse still faces the HTTP probe + token handshake, which stays the
validating boundary. Closes#123586
The remote spawn argv pins the CLI-normalized profile slug, but the
lockfile metadata the spawn script publishes carried the raw roster/
bridge label. pidIsOurDashboard proves ownership by comparing the live
--profile value against lock.profile, so a case-folded or label profile
made every cleanup classify our own backend as FOREIGN and leak it (the
#91668 class).
Normalize once at both producers: connect() resolves the requested
profile through backendProfileArg before anything derives from it
(argv, lockfile metadata, ownedSpawn rewrite, reuse comparison), and
buildSpawnCommand stamps the same pinned value into the lockfile
metadata it publishes, so a direct spawn (roster bridge) cannot publish
a record that disagrees with its own argv.
The desktop hands profile values verbatim into 'hermes --profile <value>' in
three places (local backend serve, external TUI resume, remote SSH serve).
A non-string or non-slug value crossing that boundary hit the CLI's
normalize_profile_name str()-coercion, which bootstrapped a phantom
profiles/0/ directory (state.db + SOUL.md, no config.yaml) that then counted
as a listed profile identity (#88842).
Guard all three with a pure backendProfileArg helper (trim + case-fold +
PROFILE_ID_RE slug test, 'default' allowed): a value that isn't a valid
profile id is dropped from argv instead of spawned. Sibling paths
(serveBackendArgs, tuiResumeArgs, buildSpawnCommand) all get the same
treatment; existing tests pinning valid profiles stay green and new tests
prove the drop/normalize contract on each surface.
Desktop-spawned backends were inheriting the Electron renderer via
HERMES_WEB_DIST. Opening that backend in a normal browser rendered the
Desktop app and failed with a missing IPC bridge.
Port of PR #44556 onto TypeScript Electron: resolve hermes_cli/web_dist
for primary + pooled spawn (serve and legacy dashboard --no-open).
Addresses both review notes on the structured missing-file reads change:
1. Sibling IPC surface: hermes:watchPreviewFile rejected with ENOENT for
every restored file tab whose path was gone, producing the same stack
trace per probe at startup that the read handlers were fixed to stop.
It now returns the same { ok: false, error, message, path } result
(shared missingFileResult() helper in hardening.ts); the renderer
guards the union at both call sites (preview-pane.tsx,
runtime-loader.ts). Audited remaining surfaces: normalizePreviewTarget
already answers null via its internal catch; saveImageFromUrl and the
fs:* handlers are user-initiated actions where a loud failure is
correct; media playback goes through protocol.handle (no ipcMain.handle
rejection logging).
2. Prune known-dead references: a confirmed-missing read now tombstones
the preview tab (missing: true) via markPreviewTabMissing(). The pane
shows an explicit "File no longer exists" warning state for the rest
of the session, and decodePreviewTabs drops tombstoned tabs at restore
so day-2 boots stop re-probing paths known to be dead. The facade
throws the typed DesktopFileMissingError so components distinguish
expected absence from real failures without string matching.
New i18n strings added for all five locales; tests cover the shared
helper, the typed facade error, tombstone idempotency, and restore-time
pruning. vitest --project ui --project electron: 7017 passed.
Restored preview tabs and session transcripts routinely reference files
that no longer exist (deleted, moved, or cleared from /tmp on reboot).
The hermes:readFileText / readFileDataUrl / readFileDataUrlForAttach IPC
handlers rejected with ENOENT, and Electron logged a full stack trace
for every rejection at startup even though the renderer already rendered
the failure gracefully as 'preview unavailable'.
Main process now classifies ENOENT/ENOTDIR via isMissingFileError() and
returns { ok: false, error, message, path } instead of rejecting. The
renderer facade (desktop-fs.ts) converts the structured result back into
a thrown error so every existing try/catch caller behaves identically,
and all raw bridge callers (inline preview, directive image, media,
generated image, file preview, attach utils, runtime plugin loader)
guard the union result.
Fixes the whole bug class: missing-file probes are now silent instead of
producing one console stack trace per path at every launch.
tsc inferred only the old { signature, value } return, so the bare
boolean from dbus-native 0.15 failed the electron typecheck. The
runtime test already covered both.
plainValues now defaults to true, so IsStatusNotifierHostRegistered
arrives as true rather than { value: true }. The old check always
failed and minimize-to-tray reported that no tray was available.
preload exposes hermes:quick-entry:late-result, the composer reducer owns an explicit late-result transition, and the quick window applies it: a late success clears the surface, a late failure proves non-acceptance and keeps the text, and a late result without an unknown submit changes nothing.
Regression: three reducer cases plus the preload/global type surface.
A relay timeout no longer deletes the correlation or invites a retry: it resolves as an unknown, non-retryable outcome and keeps the correlation reconcilable. A late ack for a timed-out submit fires onLateResult exactly once instead of being dropped, and never hides the window behind the user. main.ts forwards that outcome to the capture window over hermes:quick-entry:late-result.
Regression: unknown-timeout contract, late success, late failure, and duplicate-late-ack coverage in electron/quick-entry.test.ts.