rememberLog prepended only '[hermes] ' to each line, so desktop.log and
the in-app RECENT LOGS view carried no timestamps while agent.log and
gateway.log (Python logging) did.
Extract the line format into a small pure helper (desktop-log-line.ts)
and prefix each line with an ISO-8601 UTC timestamp shared per chunk,
matching the Python-side convention. Regression tests assert the shape
contract: timestamp + [hermes] tag + verbatim message. Fixes#84405.
- Add disableF12Title/disableF12Desc to i18n/types.ts contract
- Use focused BrowserWindow from menu click callback
- Persist and restore disable-f12 from main process (cold-launch)
- Replace built-in menu role 'toggleDevTools' (which had F12 accelerator)
with explicit menu item using Ctrl+Shift+I / Cmd+Opt+I only
- Add f12Blocked flag in main process, controllable via IPC
- Add 'Disable F12 DevTools' toggle in Settings → Advanced
- F12 still opens DevTools by default; toggle blocks it
- Ctrl+Shift+I (or Cmd+Opt+I on Mac) always works regardless
9c75e4863f added the global ⌘⇧G snap-to-cursor shortcut and wired its
dispose() into closeHudWindow() and before-quit. It missed the HUD's
own 'closed' listener (spawnHudWindow's win.on('closed', ...)), which
fires when the window is closed from its own side — e.g. ⌘W — without
going through closeHudWindow() first.
After a ⌘W close, the shortcut stays registered with no HUD left to
apply it to: harmless (applyHudSnapToPointer guards on a destroyed/null
hudWindow) but it keeps CommandOrControl+Shift+G claimed until the HUD
is reopened (register() releases first) or the app quits.
dispose() is idempotent (guards on its own `active` chord), so calling
it unconditionally in the 'closed' handler is safe even on paths where
closeHudWindow() already released it.
The salvaged branch predates the electron test project's node:test -> vitest
migration (test:desktop:platforms is now `vitest run --project electron`).
Import `test` from vitest so the suites are collected; assertions stay on
node:assert/strict per the existing electron test convention.
Addresses both review findings on the remote-gateway download PR:
1. Unbounded buffering (finding #1). fetchBuffer / fetchBufferViaOauthSession
accumulated the entire response (then copied it again via Buffer.concat)
before saveGatewayFile even opened the save dialog, so a large gateway file
could exhaust the native process. Both auth paths now stream: once response
headers arrive the connect timeout is cleared, the filename is derived, the
save dialog is shown, and the body is piped to the chosen destination with
backpressure. A read/write error tears down the stream and unlinks the
partial file. The byte-moving, data-URL decoding, and filename/path helpers
are extracted into gateway-file-download.ts so they're unit-testable without
Electron.
2. No fallback for older gateways (finding #2). saveGatewayFile required the new
/api/fs/download route. Desktop and the remote gateway update independently,
so a gateway predating this PR 404s. Added a 404-only compatibility fallback
to the existing capped /api/fs/read-data-url route (bounded, so it only
serves smaller files — enough to keep older backends working).
Tests: gateway-file-download.test.ts covers streaming, backpressure,
error-cleanup (unlink on write/response error), data-URL decoding, filename
derivation (incl. traversal reduction), and 404 detection;
gateway-file-download-transport.test.ts asserts both transports stream (no
whole-body Buffer.concat) and that the 404 fallback is wired. Both registered
in the desktop platform test list. Server-side /api/fs/download tests
(streaming + sensitive-file reject) already pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Non-repo explicit projects (plain folders) get a main-checkout lane whose
label is the folder basename, not a branch. Clicking "+" (new session) on
such a lane calls switchBranchInRepo -> switchBranch, which sanitizes the
basename to "" and throws "Branch name is required.", aborting the
session creation. Short-circuit switchBranch for roots that are not git
work trees so callers proceed with a plain session.
Fixes#83028
Review fixes from #86679 comments (trevorgordon981, helix4u, kshitijk4poor):
- Edit inheritance: mergeConnectionInput preserves fields the editor does
not carry (cloud org, ssh remoteHermesPath/remoteProfile) so a rename no
longer wipes them. When the payload carries an ssh host string, stored
user/port are NOT inherited — the composite host field is authoritative,
fixing the stale user/port resurrection on edit.
- Token hygiene: tokens only persist on token-auth remotes; switching an
entry to oauth (or cloud) clears the stale envelope.
- Plain-text opt-in: the panel now surfaces the same consent dialog as
Settings -> Gateway on keyring-less machines (registry list exposes
secureTokenStorage; save retries with allowPlainTextToken after consent).
- Registry test isolation: hermes:connections:test builds the probe directly
from the registry entry instead of coercing against v1 connection.json —
no more inheriting the v1 global token for a different host, and the local
entry now probes the app-managed backend (never v1 remote/ssh state, so
the test button can no longer trigger a v1 file write).
- 'local' id reserved at the validation boundary: a crafted IPC payload can
no longer replace the local entry via upsert.
- Cloud creation hidden in the editor (a dialable cloud entry comes from the
Cloud sign-in/discovery flow); migrated cloud entries stay editable.
- First-run migration write is guarded: a failed write keeps the migrated
registry in memory instead of hard-failing every connections IPC call.
- uniqueLabel(): single label-dedup helper — counts up instead of "X 2 2",
clamps 253-char migrated URL-host labels under LABEL_MAX; used by
normalizeRegistry and both migration paths.
- UI copy: staged-rollout note replaces the "side by side" claim; test
failure toast leads with the failure wording; dropped unused i18n keys.
Tests: +9 pure-module cases (reserved id, token-drop rules, merge
inheritance, ssh host precedence, uniqueLabel); electron+settings suites
1355 passed.
First slice of multi-source agent support: the desktop can now persist ANY
number of named backends (local runtime, remote gateways, Hermes Cloud
instances, SSH hosts) side by side instead of one global connection plus
per-profile overrides.
- electron/connection-registry.ts: pure v2 registry module — required
case-insensitively-unique labels (device names), @name-device handle rule
for duplicate profile names across sources (agentHandle), defensive
normalizeRegistry for corrupt files, one-time v1→v2 migration that imports
the global block + per-profile overrides (deduped by URL/host) and leaves
connection.json untouched for older builds.
- main.ts: connections.json storage beside connection.json (same secret
posture: safeStorage-encrypted tokens, 0600, tighten-before-parse, mtime
cache) + hermes:connections:* IPC (list/save/remove/set-primary/test).
Test maps registry entries onto the existing testDesktopConnectionConfig
probe stack — no new probe code.
- Settings → Connections: manage the registry (add/edit/remove/test/make
primary) with forced naming; local entry is non-removable; removing the
primary retargets to local. en + zh locales.
Storage-level only by design: routing/pool generalization to composite
(connection, profile) keys, the multi-source roster, plugin SDK surface, and
fan-out updates land as follow-up PRs.
Three fixes for generated/displayed images in the desktop chat:
- Shell fallback context menu no longer swallows right-clicks on images:
the guard now yields to Electron's native image menu (Copy Image, Copy
Image Address, Save Image As...) for img/picture/video/canvas targets,
matching the existing editable/selection carve-outs.
- Save Image As / download button: generated-image URLs (fal.media etc.)
end in an extensionless content hash, so saves produced an unopenable
"All Files" blob. The main-process save dialog and the renderer anchor
fallback both now append a MIME-derived extension, add image type
filters, and default to the user's Downloads directory instead of the
process cwd (win-unpacked on packaged Windows installs).
- New will-download handler routes any Chromium-initiated download
through the same Downloads-dir + guaranteed-extension policy.
Validation: new unit tests for the filename derivation (6 passing);
npm run check:lint green (tsc x3 + eslint, 0 errors).
The update hand-off spawned the detached updater, called unref(), and
quit unconditionally after the 2.5s dwell. Node reports exec failures
(ENOENT/EACCES) asynchronously via the child 'error' event, and a
short-lived updater can die inside that window — in both cases the app
vanished with no updater, no relaunch, and no evidence (the reported
macOS incident, and the posix.sh early-death reports on the same
thread).
Add observeUpdaterHandoff(): watch the just-spawned child for 'error'
and early 'exit' during the existing dwell (no added latency — the
dwell doubles as the settle window). Clean exit 0 inside the window
stays a success (the Windows `cmd start` wrapper exits immediately by
design); a spawn error, non-zero exit, or signal death is a failed
hand-off. On failure:
- applyUpdates (Windows hand-off): don't quit — restart the backend and
surface a structured error to the UI.
- applyUpdatesPosixHandoff (mac/linux): don't quit — surface the error.
- handOffWindowsBootstrapRecovery: return false so the caller falls
through to its next recovery path instead of quitting into nothing.
The pre-written update marker names the dead child pid, so
readLiveUpdateMarker self-heals it; no marker cleanup needed. Children
without an event interface settle ok after the window, keeping the
observation a best-effort hardening rather than a new way to wedge an
update.
Covered by 7 new unit tests (spawn-error, non-zero exit, signal death,
clean exit 0 wrapper, survival, double-settle, event-less child).
Closes#66753
Fixes#73495. Two cold-start defects made the configured Hermes Cloud
agent vanish after a Desktop restart even though the persisted Portal
session was still renewable:
1. hasLivePortalSession() trusted the FIRST cookies.get() on the lazy
`persist:` partition. It now reuses the warmOauthCookieStore()
warm-up + bounded reread that hasLiveOauthSession() gained in
PR #67769, so a single hydration false-negative no longer clears the
agent list and flips the panel to signed-out.
2. Discovery required the short-lived `privy-token` access cookie but
treated its absence as a full interactive re-login, even when the
30-day `privy-session` / `privy-refresh-token` renewal cookies
survived the process exit. New cookiesHavePrivyAccessToken() splits
"signed in (renewable)" from "discovery can succeed right now";
discoverCloudAgents() and cloudAgentSilentSignIn() now mint a fresh
access token via one bounded, hidden, deadline-capped portal load
(renewPortalAccessSilently) before or after a 401, and only surface
needsCloudLogin when renewal genuinely cannot complete.
PRIVY_SESSION_COOKIE_VARIANTS also learns `privy-refresh-token` so a
renewal-only jar still counts as signed in rather than demanding an
interactive login while usable refresh material sits in the partition.
Tests: connection-config.test.ts covers the access/session split,
including the exact renewal-only cold-start jar from the issue repro.
Persist backend ownership for reliable cleanup, park inactive panes, and
evict unreferenced transcripts so Desktop stays responsive over long sessions.
💘 Generated with Crush
Assisted-by: Crush:gpt-5.6
On Windows with high-DPI displays (150%+ scaling), Chromium
re-evaluates zoom on focus change (alt-tab). The persisted zoom
level was only re-asserted on show/restore/resize/move events.
Add 'focus' to zoomReassertWindowEvents() so the zoom level is
restored when the window regains keyboard focus, covering both
main and secondary session windows through the existing
installZoomReassertOnWindowEvents() abstraction.
Extend zoom.test.ts to exercise the registered focus handler.
Fixes#50837
renderer-bundle.ts shipped in #85887 as pure/injectable but had no unit
coverage. Lock the contract that heals a torn self-update: the boot loader
must prefer a complete generation and only report a repair when every copy
is torn.
parseModuleAssetRefs: module scripts + modulepreload only (not stylesheets
or classic scripts), CDN/absolute refs dropped, ./ and query/hash stripped.
missingRendererAssets: intact -> [], torn -> the dangling chunk(s),
per-copy dir-relative existence (the split app.asar vs app.asar.unpacked
case), and an unreadable/module-free index is not treated as torn.
Resolves the same backend the app launches (usually a venv python running
-m hermes_cli.main), writes the launcher script, and spawns the terminal
detached so it outlives the app. Resolution only — never ensureRuntime, which
would start a first-run install from a menu click.
Pure helpers for handing a session to an external terminal: the
`--tui --resume <id>` argv, a launcher script that carries the resolved
runtime's command and PYTHONPATH, and per-platform emulator resolution.
macOS opens the .command with no -a so LaunchServices routes it to whichever
app the user bound to shell scripts; Linux leads with Debian's
x-terminal-emulator alternative before the concrete emulators; Windows prefers
Windows Terminal over a cmd console.
sharedPrimaryRoute() inferred "served by the shared primary backend" from
the mere presence of connection.profile. But pooled backends (a local named
profile, or a per-profile remote override) also carry `profile` so their
WebSocket URL mints against the right backend. Both descriptors looked the
same, so ensureGatewayForProfile() took the shared-primary branch for a
pooled profile and never dialed its socket — Desktop stayed on the default
profile's socket even though the sidebar (REST) listed the right sessions.
Regression from #85665 (d16e236). Tag only the true shared-primary
descriptor with an explicit `sharedPrimary: true` marker in ensureBackend()
and check that marker instead of `profile`. Covers both the local-pool and
remote-override routes — the whole bug class, not one path.
Test asserts both sides of the invariant: a { profile, sharedPrimary: true }
descriptor activates the primary socket without dialing, and a pooled
descriptor carrying { profile } dials its own exact WebSocket URL.
Supersedes #85750, #85778, #85932Fixes#85777
Co-authored-by: Tigrannnnnnn <122704900+Tigrannnnnnn@users.noreply.github.com>
Co-authored-by: Don Tuttle <11698271+wdon@users.noreply.github.com>
Co-authored-by: plcunha <145560011+plcunha@users.noreply.github.com>
Same local-ahead blind spot as the CLI SSH fast path, on the desktop's
passive SSH-official check: tips differ but ahead_by == 0 means the remote
tip is reachable from HEAD (carried local commit). Treat that as up to date
instead of 'update available' — the nudge toward hermes update is exactly
what wipes carried work.
Widens andyst-dev's #84860 to the desktop sibling site.
The honesty half (no fabricated counts) leaves shallow installs permanently
count-less. The compare API knows the full graph regardless of local clone
depth: GET /repos/<o>/<r>/compare/<current>...<target> returns ahead_by —
exactly the behind count the shallow boundary lost.
- hermes_cli/banner.py: _github_compare_behind() (bounded, unauthenticated,
best-effort); wired into _check_via_rev and the shallow branch of
_check_via_local_git. ahead_by==0 with differing tips = local-ahead => 0.
- hermes_cli/update_cmd.py: hermes update --check shallow path prints the
exact count when recoverable, presence-only wording otherwise.
- apps/desktop/electron/update-count.ts: compareApiUrl() +
parseCompareBehindCount() pure helpers; main.ts fetches the count when
resolveBehindCount() returns null, and the SSH-official passive path stops
fabricating behind:1 (uses compare API + updateAvailable flag).
- apps/desktop/src/lib/version-status.ts: updateAvailable now applies to the
client target too, so a shallow desktop install shows '(update)' instead of
nothing (or the old frozen '(+1)').
Fixes#84591; CLI siblings of #78253 / #53479 behavior.
E2E: live compare API returned 61/62 for real 61/62-commit gaps and 0 for the
reversed (local-ahead) pair; real shallow-clone fixture (depth-1 clone +
depth-1 fetch, merge-base broken) recovers the exact count with the API and
falls back to the honest sentinel offline.
On an installer checkout (clone --depth 1) with no merge-base against the
freshly fetched origin tip, resolveBehindCount returned the sentinel 1 and
every surface rendered it as a literal count: 'A new update is ready (1
change included).' — even when the true distance was far larger (observed:
90 commits). The sentinel was meant to mean 'update available, exact count
unknown', but nothing downstream distinguished it from a real one.
- update-count.ts: return null (unknown) instead of the numeric sentinel
- main.ts: flag updateAvailable explicitly and still serve the (capped)
commit log so 'See what's new' stays useful in the unknown case
- updates.ts: toast fires for behind:null + updateAvailable, with
count-free copy instead of being swallowed by the <= 0 guard
- about-settings.tsx: status line and action buttons key off
updateAvailable; unknown size renders the new count-free string
- i18n: updateReadyUnknown / updateReadyMessageUnknown in all 5 locales
Refs #51922 (the shallow-clone special case this UI now renders honestly).
Tests: vitest electron 10/10, ui 44/44 (3 FAIL-BEFORE reds turned green),
tsc typecheck clean, eslint clean on all touched files.
* fix(desktop): load the intact renderer bundle when an update tears one copy
index.html and the hashed chunks it names are one generation. A packaged app
ships that bundle twice (inside app.asar and, via asarUnpack, beside it in
app.asar.unpacked), so an update that replaces the app while its files are
locked can leave the two copies from different generations. resolveRendererIndex
took the first index.html that existed, so it could pick the torn one and the
window died on its first lazy import with "Failed to fetch dynamically imported
module" -- with no way out, because every relaunch reloaded the same copy.
Check each candidate's declared modules and prefer a complete generation; when
both are torn, log which files are missing and how to repair instead of leaving
the crash unexplained.
* fix(cli): rebuild the desktop app when its renderer bundle is half-replaced
The content stamp hashes the SOURCE tree, which an interrupted update leaves
intact, so `hermes desktop` reported "up to date" and skipped the rebuild that
would repair a torn bundle -- the app relaunched into the same crash and
reinstalling looked like the only option.
Treat a bundle whose index.html names missing chunks as stale regardless of the
stamp, and say so on the way into the rebuild.
The disk-plugin door now scans two Electron-local roots through one
pipeline: the standalone <HERMES_HOME>/desktop-plugins/<name>/plugin.js
door, and <HERMES_HOME>/plugins/<name>/desktop/plugin.js — the desktop
half of a regular agent-plugin package. A feature that needs both SDKs
ships as one installable folder instead of two co-dependent plugins.
Records are keyed by entry-file path (folder names can collide across
roots), each root gets its own fs watch with the poll staying alive
until every root is covered, and older Electron shells without the new
agentPluginsRoot resolver simply skip the unified root.
The helpers were tested; nothing proved main.ts called them. Reverting both
call sites and both imports in readDesktopConnectionConfig /
writeDesktopConnectionConfig left the whole suite green (947 passed / 2
skipped, tsc 0, eslint clean, e2e 1 passed 1 skipped) while connection.json
went back to 0644 — the user-visible fix this PR promises was untested.
The e2e spec could not catch it by construction: it asserts the ENCRYPTION
contract with a raw-bytes scan, and safeStorage keeps the token opaque
regardless of the file's mode, so a 0644 file passes that scan every time.
There was no mode assertion anywhere in e2e/.
Adds the missing third contract — unreadable by other local accounts — on all
three paths that can produce the file:
- write: assert the mode of the artifact test 1 already proves the app wrote.
- read, valid file: seed the app's own encrypted connection.json back to 0644
and assert launch tightens it. Scoped to the MODE only, so it is independent
of the still-deferred plaintext migration — the fixture's token is already
ciphertext, so nothing re-encrypts, no #62319 opt-in marker is involved, and
no rotation guidance is owed.
- read, corrupt file: a truncated file still holds the token bytes and throws
into the swallowing catch, so it would be the one file never tightened. This
is the only test that distinguishes the chmod's placement relative to the
parse.
Also moves the tighten above JSON.parse for exactly that reason, and pins the
cache invariant the placement depends on: the tighten must be a chmod, not a
rewrite, because it sits inside the function whose cache keys on mtimeMs.
Asserted as `mode & 0o077 === 0` rather than `=== 0o600` to avoid a
change-detector, and skipped on win32, where chmod maps to the read-only bit
and the fix deliberately no-ops (ACLs are PR #77527).
Every assertion was mutation-tested: reverting the full wiring fails all three;
reverting only the write path fails only the write test; deleting only the
tighten-on-read fails only the two read tests; moving the tighten below the
parse fails only the corrupt test; making the tighten a rewrite instead of a
chmod fails the mtime assertions. Bundle greps confirmed each mutation reached
dist/electron-main.mjs before the run.
(cherry picked from commit 99cfc16e7cdb759b674d890563f6a82113326547)
`connection.json` under the desktop app's Electron `userData` was written with no
file mode, so it landed at the `0644` umask default — while its two
credential-bearing neighbours in the same directory, `desktop-installation.json`
and `native-oauth-tokens.json`, were already `0600`. That file holds the
safeStorage-encrypted gateway token plus the fields that are NOT encrypted: the
gateway URL and the SSH host, user, and key path.
- Route the single write choke point through a helper that creates the file
owner-only and atomically.
- Tighten an already-existing `0644` file once per launch on the read path, so
installs that already have one do not stay world-readable until the next save.
- Refuse to act on a path that is a symlink or not owned by the current user,
matching the guards `desktop-installation.ts` already applies to its sibling.
The symlink guard alone turned out to be insufficient, and that is worth
recording: `writeSecretFileAtomic` tightens its *temp* path, so a symlink planted
at `connection.json.tmp` meant `writeFileSync` followed it, the guard correctly
bailed, and `renameSync` then moved the link onto `connection.json` permanently.
Measured, guard-only vs. as-landed:
guards only token leaked: true config is a symlink: true 755
guards + temp unlink token leaked: false config is a symlink: false 600
So the temp path is unlinked before the write.
Issue #77486's headline claim — that a dashboard session token is persisted in
plaintext — does not hold against main. The token has been safeStorage-encrypted
since the desktop app reached mainline in 51c68d4ab, and `encryptDesktopSecret`
aborts with an actionable message rather than degrading to plaintext when
safeStorage is unavailable. The `{ encoding: 'plain', value }` literal does exist
at main.ts:7084, but only on the `persistToken: false` branch, whose sole caller
is the connection-test handler, which never writes. So no mainline path *writes*
a plaintext token. The commits that did contain a plaintext-writing fallback
(d3d177283, d208f2c2c) are not ancestors of main — they live only on
upstream/bb/gui-* and the desktop-pr20059-installers pre-release tag.
At-rest migration of legacy non-safeStorage payloads is deliberately NOT included.
An earlier revision of this branch implemented it and it was removed after review
reproduced two token-loss paths: it force-converts the opt-in plaintext choice
PR #62319 adds (silently reverting the user's decision, then destroying the token
on the next launch without the `--password-store=basic` flag), and it converts a
portable credential into a keychain-bound one with no consent — destroying the
only recoverable copy while not remediating the real exposure, since every
existing backup still holds the plaintext and the true remedy is rotation. It also
persisted raw `parsed`, bypassing `sanitizeConnectionProfiles`. A comment at the
read path records the three preconditions any future attempt needs.
`decryptDesktopSecret`'s non-safeStorage read fallback is untouched — it is what
lets a pre-release or hand-edited config work at all.
Windows still inherits the userData directory ACL rather than an explicit
owner-only one; mode bits are advisory there, so that half is deferred to
PR #77527 rather than growing a second ACL implementation here.
e2e: `at-rest-connection-token.spec.ts` asserts the at-rest contract
implementation-independently — the token's plaintext value (and its base64 form)
must not appear in a raw-bytes scan of any file under userData or HERMES_HOME,
AND the app must still put the exact original token on the wire after a restart,
so a fix that simply drops the token cannot pass. Proven non-vacuous by mutation:
writing `{ encoding: 'plain', value }` still fails the scan while the
file-exists and gateway-URL guards pass. The migration case is a documented
`test.fixme` naming its three blockers.
Electron project 928 -> 924 tests (-9 migration, +5 new guard and
mechanism-isolation). Two of those five exist because reverting either owner-only
mechanism alone initially scored zero failures — they were masking each other, so
either could have been deleted green.
(cherry picked from commit 6e01add6578f08f015a567d3a7a7378f2ec3e768)
A pasted GitHub PR comment deep link (#discussion_r… / #issuecomment-…)
now lands as a typed review attachment instead of a bare url chip. The
card attaches optimistically and resolves through gh in the background —
author, file:line anchor, body, and the diff hunk — expanding at send
into an anchored fenced block, so "address this" carries exactly what
"this" is. When gh can't answer (offline, unauthenticated, foreign repo,
remote gateway) the card downgrades to the plain url ref and nothing is
lost.
A manual:true hand-off result is the durable action-required channel: on a
browserless Linux box with no working notifier, the boot dialog is the first
and only place the message ever surfaces. The 30-minute freshness gate
discarded it if the user reopened Hermes later, stranding exactly the machine
the channel exists to serve. Parse before the age check and skip the window
for manual results; the file is still unlinked before any age check, so it's
surfaced at most once. Ordinary results still expire.
Regression: a stale ordinary result is discarded (and consumed) while a stale
manual result is still returned once.
Round 4 of helix4u's review — the durable fallback is now real:
- Result protocol gains `manual`: an ok result the user still must act
on (reopen the app, reinstall the GUI package, fix the sandbox helper).
Both orchestrators set it on every DONE_NOTE/downgrade path; the Desktop
consumer surfaces manual results in a real dialog on next boot instead
of a log line — the browserless-Linux disappearance now ends at a
visible dialog, worst case one boot later. Older result files without
the field parse as manual:false (covered).
- notify ladder verifies EXECUTION, not existence: zenity/kdialog must
survive their first second (an instant death means no display and falls
through); the no-surface case is an explicit best-effort contract whose
guaranteed channel is the result dialog.
- mac DONE_NOTE + failed relaunch of the kept/rolled-back bundle is no
longer swallowed (`|| true` dropped): the durable message carries both
facts.
- launch/gate matrices assert `manual` in the result JSON; consumer
round-trip tested in handoff-result.test.ts.
Address helix4u's review:
- finish() now delivers the outcome BEFORE publishing it: mac bundle swap
and the linux relaunch gate run first, then the result file, marker
removal, and the shim event -- the app launch itself goes last so it
can't race the result write. A gated/skewed linux install (AppImage/
deb/rpm, broken sandbox helper) surfaces its message in the result file
AND holds the shim window open with it instead of closing on a false
'Opening Hermes...'.
- mac swap is transactional with a checked rollback; a failed install
restores the previous bundle and the result says so (exit 7 when even
rollback fails). Failed 'open' rewrites the result truthfully.
- linux gate is an exact port of the deleted update-relaunch.ts logic:
anchored path-segment match on <root>/apps/desktop/release/linux-unpacked,
chrome-sandbox absent = namespace build = fine, present = root+setuid
required, with the real opt-outs (ELECTRON_DISABLE_SANDBOX, --no-sandbox
among replayed args, or the Desktop vouching) instead of the invented
HERMES_DESKTOP_NO_SANDBOX. collectRelaunchArgs/sandboxFallbackFromEnv
live in updater-process.ts again; the Desktop passes filtered launch
args (after --) and --relaunch-cwd so a deep-link or --no-sandbox
launch survives the update.
- result/status JSON strings are escaped (git permits '"' in branch
names) and the result write is atomic (tmp + rename).
- coverage: resolvePosixScriptHandoff + ported helpers in
updater-process.test.ts (19 pass); repro.sh gate / npm run
update:repro:gate asserts the whole gate matrix and round-trips a
hostile branch name through the result JSON.