Preview-pane guest pages (Streamlit's traceback "Ask Google" / "Ask …"
buttons, plain `<a target="_blank">` anchors) could not open anything: the
`<webview>` has no `allowpopups`, so Chromium drops the popup before any
handler runs. Opening from `setWindowOpenHandler` is banned
(GHSA-9f4c-93c8-jc8g, window-open-policy.ts), so this adds an explicit
click bridge instead:
- main.ts installs a guest preload through `will-attach-webview`, keyed on
the `persist:hermes-preview` partition only.
- The preload forwards a clicked `_blank` anchor's resolved href to the
host renderer via `ipcRenderer.sendToHost`; it opens nothing itself.
- PreviewPane admits the URL and routes it through the existing audited
`hermes:openExternal` IPC.
Salvaged from PR #112959 (squash of its two commits).
Fixes#112941
githubTokenFromGhCli cached the outcome of `gh auth token` for the whole
process, including "none": a user who ran `gh auth login` after launching
the Desktop stayed anonymous (and rate-limited) until an app restart, since
only a 401 on a real token cleared the cache. Only a token is cached now; a
null answer (gh missing, logged out, hung) is re-asked by the next hourly
check, one bounded spawn per check at most.
Part of #112615
Closes the last atom of #112615. A Desktop app started from the Dock, Finder or a
desktop launcher inherits a minimal environment, so the GITHUB_TOKEN / GH_TOKEN rung
landed in #113190 never fires for exactly the users the issue is about (shared exit
IP, 60/hour anonymous budget exhausted by neighbours). The update check now walks the
same ladder as the Python GitHub client (tools/skills_hub_github.py::GitHubAuth):
1. GITHUB_TOKEN, then GH_TOKEN, from the launch env (unchanged).
2. `gh auth token` — execFile with an argv array (no shell), stdin closed, 3 s
timeout, windowsHide. `gh` is resolved on PATH plus the GUI-safe install
locations backend-env already appends for the backend (Homebrew, /usr/local,
~/.local/bin, nix profile, the Windows GitHub CLI installer dirs). The outcome
(token or none) is cached for the process lifetime so gh runs at most once.
3. Anonymous.
A rejected credential (401) still retries anonymously; the log line names the
source (env vars vs gh login) and never the token, once per source per process.
A rejected gh token also drops the cache so a re-login is picked up by the next
check. envTokenRejected is renamed githubTokenRejected since both rungs use it.
Live: with PATH=/usr/bin:/bin:/usr/sbin:/sbin and no env token, the module found
gh, resolved a credential in 135 ms and api.github.com reported a 5000/hour core
budget (anonymous: 60). A logged-out gh and a hanging gh both resolved to null
(anonymous) in 5 ms / 3001 ms.
Docs: the credential ladder is documented on the Desktop "Updating" page and in
the environment-variables reference.
Serve foreground queue promotions and concurrent opens without interrupting backend work. Park retired scopes and keep failed-stop generations fenced until exit.
Co-authored-by: Nikolas de Hor <nikolasdehor79@gmail.com>
Co-authored-by: Mark Sheppard <mark@orchardstreetpress.com>
Co-authored-by: chelsealong <chelsealong@126.com>
A foreground bot open against a full 3-slot local pool waited 30s for a slot
and timed out (production: 7,122 slot waits, 6,305 timeouts, ~790 cancelled
starts in 10 days on a 17-profile host). Nothing could free a slot: a child's
hard lease lives until the child exits (main.ts child 'exit' handler,
teardownFailedLocalBackend, stopPoolBackend after the bounded SIGTERM->SIGKILL
exit), while LRU eviction and the idle reaper key off lastActiveAt, which the
renderer refreshes every 60s for every open socket. A bot-tile-pinned resident
is keepalive-fresh forever. Occupied is not busy.
New `electron/pool-retire.ts` (pure, DI-testable like pool-spawn-coordinator):
a foreground dial that finds `activeCount >= poolMaxBackends()` may retire ONE
resident, under these rules:
* Proof is the backend's. Each LRU candidate is probed over
`/api/health/idle` (running sessions + running cron jobs + prompts waiting on
a human). Only `true` is idle; `false` and `null` (older runtime 404, probe
error, unreadable ledger) are busy. The renderer-published `activeTurn`
lease is an early skip, never the proof; entries no longer initialise it to
false, so a fresh backend is not evictable by default.
* Admission fence: concurrent foreground dials share one retirement and one
probe; the coordinator hands the freed slot to the ticket that queued first.
* Identity recheck (`pool.get(key) === entry`, no new turn lease) after every
await, and a re-probe immediately before the stop.
* The waiter's `coordinator.request()` is issued BEFORE the stop so it sits at
the queue head; the slot is released only by the retired child's real exit
through the existing stopPoolBackend -> releaseLocalBackendSlot path.
* `hermes:pool:retiring` is broadcast to every window before the SIGTERM so
the renderer parks the scope instead of redialing into the vacated slot.
main.ts grows only the trigger point, the HTTP probe, the broadcast and the
retirer construction. Kept from #104871 with credit: the trigger point before
`coordinator.request`, the `touchBackend(profile, options)` IPC widening
(preload.ts / global.d.ts), and the LRU-among-eligible selector shape.
Tests (pool-retire.test.ts): fence with two concurrent tickets over a real
LocalBackendSpawnCoordinator (one probe, one SIGTERM, slot granted only after
the simulated exit, exactly one waiter served); identity recheck aborts on a
swapped entry or a lease published after the probe; idle null / cron-running
ineligible with fall-through to the queue; renderer activeTurn:false loses to
a backend re-probe.
Co-authored-by: bounce12340 <128559392+bounce12340@users.noreply.github.com>
Only Log lines were sanitized at the emitter; the manifest-step error string
was still built from the raw child stderr/stdout tail, so a failing
`install.sh --manifest` (print_error writes `${RED}✗${NC}`) put escape bytes
straight into the Setup failure banner. Run stripAnsi / strip_ansi over the
embedded tail on both the Electron and the Tauri bootstrap surfaces (#112675).
The Electron install overlay drives the same install.sh through
bootstrap-runner.ts::spawnBash / spawnPowerShell and forwarded every child
line verbatim, so its Details panel and "Copy output" showed the same
`\x1b[0;32m✓\x1b[0m` mojibake as the Tauri Setup app (#112675) — same
class, sibling surface. cleanInstallerLogLine() strips at the ONE emit
boundary (six sites: stdout/stderr/flush in both spawn helpers) using the
shared stripAnsi from apps/shared/src/ansi.ts, so the main-process log ring,
the renderer and the clipboard all read one clean line; \r progress redraws
collapse to the last frame a terminal would show. Relative import, as
hardening.ts does: the esbuild main bundle has no tsconfig path aliases;
../shared/src/ansi.ts joins tsconfig.electron.json's include list.
#112679 (@KoNit-K) fixed this in the renderer's applyEvent + snapshot; the
emitter is the boundary every consumer shares, so it lands here instead.
Live A/B (verbatim spawnBash out of each checkout, bash script printing what
install.sh prints into a pipe):
before: "\u001b[0;32m✓\u001b[0m Detected: macos (macos)", "\r 12%\r 67%\r100%\u001b[K"
after: "✓ Detected: macos (macos)", "100%"
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
With the paste-time interception gone nothing calls
`git.review.fetchPrComment`: remove the Electron handler, its preload entry,
the `parsePrCommentUrl`/`reviewFetchPrComment` helpers (a second copy of the
renderer's regex, free to drift), the remote-gateway stub and the
`HermesPrComment` typing. Reads only; no user-visible behaviour.
Separate commit so it can be dropped independently of the paste fix.
Cherry-picked hunks from 74b31c5b79d92 (#112480); the `reviewFetchPrComment`
export added on main since is removed too.
Wiring GITHUB_TOKEN/GH_TOKEN into fetchGitHubApi made a stale or revoked
token fail the check closed ('api.github.com answered HTTP 401.') on
machines where the anonymous request had always worked. An authenticated
401 now logs once that the env token was rejected and repeats the request
without the Authorization header; every other status is surfaced as before.
envTokenRejected is the pure gate so the decision is unit-tested.
Trim the salvaged ladder to its env rung and make the 403 copy actionable.
- github-api-auth.ts keeps githubTokenFromEnv / githubApiHeaders; the
`gh auth token` rung (resolveGithubToken, readGhCliToken, the per-process
cache and the execFile import in main.ts) is dropped. Whether a GUI app may
silently spend the user's gh CLI login on a passive background check is a
product call, not a bug fix; the pure seam makes re-adding it a ~15-line
follow-up.
- fetchGitHubApi reads GITHUB_TOKEN / GH_TOKEN from process.env per request
(nothing stored) and attaches x-ratelimit-remaining / x-ratelimit-reset plus
whether the call was authenticated to the rejected error.
- describeUpdateCheckFailure moves out of the unimportable main.ts into
update-api-check.ts. A 403/429 with x-ratelimit-remaining: 0 now says the
anonymous budget is 60/hour per network address shared with everyone behind
the same connection, gives the real reset time and names GITHUB_TOKEN as the
remedy; a 403 without rate-limit headers is reported as a plain HTTP 403
instead of a rate limit the user did not cause.
- Docs: desktop.md "Updating" and the GITHUB_TOKEN row in
environment-variables.md.
- Tests trimmed to two invariants per module.
Why: behind a shared exit IP "try again in an hour" is advice the user cannot
act on, and the old copy hid the one thing that does help.
The passive update check reads the branch tip SHA from api.github.com. The
anonymous REST budget is 60 requests/hour keyed on the client IP, so one shared
exit (office NAT, VPN, a proxy node many users sit behind) exhausts it for
everyone on it and the check reports `GitHub API rate limit reached (HTTP 403)`
— which reads as "Hermes can't reach the update server", with a retry promise
that cannot help a saturated exit.
Resolve a credential the way the Python client already does
(tools/skills_hub_github.py::GitHubAuth): GITHUB_TOKEN / GH_TOKEN first, then
the `gh` CLI's keyring token, then anonymous. The `gh` rung is what makes this
work for a GUI-launched app, which inherits a minimal environment; the env rungs
alone would leave the affected users unchanged.
- electron/github-api-auth.ts: precedence ladder + header builder (pure; the gh
lookup is injected so the ladder is testable without Electron)
- electron/main.ts: resolve once per process, reuse resolveGhBinary, build the
fetchGitHubApi headers through the module. Anonymous headers are unchanged.
- electron/github-api-auth.test.ts: 6 contracts (env precedence, blank
fall-through, no Authorization key when anonymous, `token` scheme, gh only
consulted when env is empty, gh failure degrades instead of throwing)
Verified on macOS from an exhausted shared exit (remaining 0/60, HTTP 403 for
the tip SHA): the same code path answers 200 / limit 5000 once a token resolves.
The latch was re-armed on every ready, so a backend that dies shortly after
each restart was respawned forever, each round firing the persistent
'stopped working' toast — #112344 asked for a backoff-bounded self-heal
and a visible no-backend state. The latch now grants at most 3 respawns
per 2-minute window; past that, claim() refuses, the supervisor logs and
surfaces one 'keeps crashing — relaunch Hermes Desktop' exit instead of
starting another child, and a new window re-opens the budget.
A ready primary child's exit that is classified "stale" used to log and
return; recovery then hinged on the renderer noticing its socket drop and
re-dialing. When nothing followed the exit (slot invalidated without a
replacement start, or the child's own `error` handler cleared the slot
first) the UI kept running with no engine — 9 h observed (#112344).
Builds on the cherry-picked #112356 wiring and fixes its inputs:
- The decision reads the PRIMARY slot only: `hasCurrentOwner` is the
connection state's process OR promise (a remote descriptor and a
published in-flight attempt both count as owners), `hasPendingStart` is a
startHermes() in-flight counter. `localBackendLifecycle.hasPending()` was
wrong here — it counts pool children and stops, so any live profile
backend would have suppressed primary recovery (#112989's finding).
- Every deliberate emptying of the slot (`resetHermesConnection`, the
boot-failure catch) goes through `invalidatePrimaryConnection()`, which
marks the next stale exit intentional until the next startHermes(); the
soft-rehome flag alone missed hard re-homes, profile switches and the
latched-failure path, where a spurious respawn + "backend stopped" toast
would have raced the caller's own restart.
- The once-per-empty-slot latch lives in the pure module
(`electron/backend-exit-recovery.ts`, renamed from
backend-stale-exit-recovery.ts) instead of a module flag mutated on a
throwaway state object; `reset()` re-arms it when a backend becomes ready.
- The respawn decision and a failed respawn are logged to desktop.log;
the previous `.catch(() => {})` hid them.
- Tests trimmed to the two invariants that drive the real
backend-connection-state machine through the reporter's sequence.
Co-authored-by: Mohamad Kanso <91088196+MohamadKanso@users.noreply.github.com>
The stale-exit, ready-exit and pooled-profile exit lines logged a bare
exit code and threw away the spawn-time output tail, so the reason a
supervised backend died never reached desktop.log — exactly the evidence
needed to diagnose the never-respawned stale exit in #112344.
The before-ready path already appended the tail; this routes all three
exit surfaces through one formatBackendExitLine helper so the child's
last stderr lines land next to the exit line (empty tail keeps the log
byte-identical to the old shape).
The cp mock rejected before writing anything, so origin/main's direct copy into
the final target also left no target and the test was green on both sides. Have
the mock write a partial plugin.js into whatever destination it was handed before
rejecting: with a direct copy that half-tree sits at <appRoot>/media and the
assertion fails; with staged publication it lands in the staging sibling and is
swept. Verified red with origin/main's desktop-plugins-root.ts swapped in.
Extract the contributor's staging + rename sequence into
`publishDesktopTree` and make `installDesktopPluginFromGit` use it too:
its `copyDesktopTree` had the same mkdir + cp shape, so a copy that failed
half-way left an empty `<root>/<name>/` and every later install attempt
was refused with "already exists. Enable force reinstall to replace it".
One helper, both copy sites, same guarantee: a published folder is always
complete (marker included) or absent.
Docs: describe the staged copy and the marker-less/plugin.js rule in the
desktop plugin SDK guide.
`npm run check:lint` from apps/desktop reports the cherry-picked import order
as a perfectionist/sort-imports error and the new local in
`venvHermesShimPath` as a padding warning; both introduced by the pick.
Co-authored-by: fangliquanflq <fangliquan@qq.com>
Follow-up to the cherry-picked #112966 so the uv-default `.venv` layout is
supported end to end, not only at the lookup sites:
- `_ZIP_PRESERVED_TOP_LEVEL` gains `.venv`. The dirty-tree guard runs
`git status --ignored=matching`, so a gitignored `.venv/` surfaced as
`!! .venv/` and refused every ZIP fallback on such installs ("the working
tree has uncommitted changes or untracked files") — the live runtime was
being treated as user data the overlay would destroy.
- `_repair_venv_on_current_checkout` recreates the venv at the resolved
directory instead of a literal `venv`, so a broken `.venv` is rebuilt in
place rather than growing a second environment that `project_venv_dir()`
then prefers while `bin/hermes.cmd` still launches the old one.
- `_refuse_update_if_venv_foreign_owned` scans the resolved venv (the only
remaining `PROJECT_ROOT / "venv"` literal on the update path).
- windows.ps1 names the actual shim path in the lock-timeout message.
- Tests: extend the real-git ZIP guard test with the `.venv` case (red
before this commit); the holder-guard test now uses a kernel-runner child
whose cmdline lacks `hermes_cli.main`, so only the venv-prefix arm can match
it (red on origin/main); drop the `process.platform`-override vitest case,
which exercised the same resolver as the `.venv` case with a different
directory string.
Co-authored-by: fangliquanflq <fangliquan@qq.com>
Registry connections (SSH, remote URL, cloud) resolve their backend through
connectRegistryBackend(), which never registered the profile's WSL path
bridge state. isWslBridgeActive() then fell back to its default (true), so
POSIX paths from a remote Linux host hit resolveDefaultWslDistro() and
spawned wsl.exe - visible as a flashing black console window on machines
where WSL has no distro installed (and an install-prompt risk on fully
WSL-less machines).
The v1 path (ensureBackend) already disables the bridge for remote
connections at all three of its return sites. Mirror that for pooled
registry backends: SSH and remote/cloud connections are always
remote-hosted, so their profiles must be bridge-inactive.
Refs #102868, mirrors the #66433/#66447 guard for the primary connection.
* test(desktop): cover access-proxy headers on OAuth login URLs
Connections extra headers must apply to /login and OAuth-partition
requests, not only an exact remembered WebSocket URL.
* fix(desktop): send extra gateway headers on OAuth login
Cloudflare Access service tokens never reached the OAuth sign-in
window because they were injected only on defaultSession and v1
remote config. Attach them on OAuth partitions and loadURL too.
* fix(desktop): strip CR/LF from remote gateway header values
The PR this builds on sanitized header values in remote-ws-headers.ts, on
the resolution path only. That left the sibling consumers of
decryptRemoteHeaders untouched: buildRemoteConnection and both
connection-test paths still handed raw values to setHeader.
Sanitize on the canonical normalizer instead, where header NAMES are
already validated against REMOTE_HEADER_NAME_RE, so every consumer
inherits it. normalizeRemoteHeaders only trim()'d, which strips a
trailing newline but leaves an EMBEDDED CR/LF intact — the actual
request-splitting vector.
* refactor(desktop): drop the duplicate header normalizer from remote-ws-headers
Header values now arrive sanitized from decryptRemoteHeaders, so the
second sanitizeRemoteHeaderValue/sanitizeRemoteHeaderMap pair here was a
duplicate of the canonical one in connection-config.
The routing test keeps its original job (Connections headers reach
/login, longest base URL wins); the CR/LF assertions move to
connection-config.test.ts where the stripping now lives.
* perf(desktop): memoize decrypted remote header sources
onBeforeSendHeaders is now attached to every OAuth partition as well as
defaultSession, and headersForRemoteRequest decrypts EVERY registry
connection's headers. A safeStorage-encoded value costs a keychain
round-trip per read, so that was N decryptions per subresource request
where main previously did at most one behind a modeIsRemoteLike gate.
Key the collected sources off the connection config and registry cache
objects, which both readers already refresh on mtime change.
---------
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
fetchJson/fetchPublicJson only reached the redirect diagnostic when the 3xx
carried an HTML body or text/html content-type; an empty-body 302/307 (the
common reverse-proxy / forward-auth shape) still resolved null through the
earlier empty-body check. http.request never follows redirects, so classify
on status first and reject every 3xx with the redirect error regardless of
body.
Pass res.headers.location through so the message says where the request was
sent, and stop blaming credentials when the Location differs from the
requested URL only by scheme or trailing slash -- that is a saved-URL
mismatch, not an authentication proxy. Drop the 404 mention from the
docstring/test: both callers reject >= 400 before this branch, so only the
2xx leg carries the endpoint-missing capability wording.
Part of #112072
createMediaProtocolHandler() set only the session token / bearer on the
/api/files/stream request. The descriptor it resolves now carries the
connection's extra gateway headers, but the handler never read them, so
attachments in session history from a header-gated remote still bounced off
the access proxy even though every fetchJsonForBackend() call got through.
Merge connection.headers into the media request before auth, letting the
forwarded range/cache negotiation headers win, and pin it on both the
token and the OAuth cookie-session legs.
Part of #112072
The JSON guard in fetchJson/fetchPublicJson blamed every HTML reply on a
missing backend endpoint. A 3xx HTML body is an access proxy redirecting
to its login page: the endpoint exists, the credentials never arrived.
Besides misleading the user, the wording is the capability signal that
isMissingHealthEndpointError and the renderer's gateway-rpc predicate key
on, so an auth redirect was silently classified as "endpoint missing" and
routed onto compatibility paths instead of surfacing as an error.
Build the error in api-transport.ts (where the sibling httpStatusError
lives) and pick the hint by status: 3xx names the redirect and points at
the saved token/extra headers; everything else keeps the endpoint-missing
wording the predicates rely on.
Part of #112072
createPrimaryRemoteConnection() rebuilt the primary remote descriptor
field by field and left out `headers`, so every REST call routed through
fetchJsonForBackend() (Settings profiles/config, session history) reached
the gateway without the configured extra headers while chat, the Test
button and the readiness probe -- which read the exact-URL WebSocket header
store or the resolved route directly -- kept working. Behind an access
proxy (e.g. a service-token gate) those REST calls came back as a 302 to
the login page.
Carry `headers` through the descriptor like every other rebuild site
(buildRemoteConnection, the registry pool path and both ensureRegistryBackend
reuse branches already spread it), and pin it with an invariant test on the
existing primary-descriptor seam.
Fixes#112072
Co-authored-by: plluviera <plluviera@users.noreply.github.com>
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
Drop the extra assertLocalProfileCanStart call added ahead of the slot
queue: the deleted-profile fence already runs at the spawn boundary below
and is not the mechanism behind the #111338 retry storm.
createMainWindow walked the entire renderer generation twice before it
could call loadWindowUrl:
const rendererIndex = DEV_SERVER ? null : resolveRendererIndex()
const tornAssets = rendererIndex ? missingRendererAssets(rendererIndex) : []
resolveRendererIndex already computes exactly that list while choosing the
copy — it needs it to decide whether a copy is torn — and then throws it
away. missingRendererAssets is a BFS that readFileSync's every present
chunk whole and regex-scans it for the inline __vite__mapDeps table, so on
a release tree it is not a stat walk: measured against the real
apps/desktop/dist (252 chunks, 28.7 MiB of JS), one walk is 162
readFileSync calls reading 28.23 MiB, 576 existsSync calls, and 56.6 ms
median (min 55.8, 9 reps, warm page cache, darwin-arm64). Both walks run
synchronously on the main thread before the window gets its URL.
Return the list alongside the index. resolveRendererIndexWithMissing()
carries the existing body and hands back { index, missing }; the
path-only resolveRendererIndex() stays as a one-line wrapper so the nine
other call sites are untouched. The primary-window path takes one
resolution.
Semantics are unchanged in every branch: the same candidate is chosen, the
same log lines are emitted, and the missing list always describes the copy
actually returned. The all-copies-torn branch now reuses the first
candidate's list, captured on the first loop iteration, rather than
recomputing it for present[0] — recomputing there would have reintroduced
the second walk in exactly the case that matters most, and using the
loop's last value would have described a bundle we do not load.
Net effect on every primary-window boot: one fewer full walk, so 162 fewer
readFileSync calls, 28.23 MiB less synchronous reading, 288 fewer
existsSync calls, and ~57 ms of main-thread blocking removed before
loadURL. The win lands on packaged and --prod launches; DEV_SERVER skips
the walk entirely, so `vite dev` is unaffected.
The ghost-prune loop in reconcileUnifiedDesktopHalves used existsSync on
the marker's source, which answers false for EACCES/EPERM as well as
ENOENT, so a mode-000 / ACL-denied package folder was treated as an
uninstall and its materialized half rm -rf'd with no warning.
Distinguish a genuinely missing source (ENOENT/ENOTDIR) from one the app
is not allowed to stat: keep the half and warn. materializeDesktopHalf
now also warns on a non-ENOENT stat failure instead of swallowing it.
Part of #111804
`reconcileUnifiedDesktopHalves` let a stat/copy failure on one package
(`EPERM: lstat` on Windows in #111804; EACCES on a mode-000 file here)
reject the whole pass. The `hermes:fs:desktopPluginsRoot` IPC runs that
reconcile before returning the root, so the renderer never got a root and
every disk desktop plugin silently stopped loading. Warn about the one
package and keep materializing the siblings.
Part of #111804
The zsh login-shell legs in remote-lifecycle.test.ts and
ssh-connection.test.ts silently returned when zsh was missing, and the
js-tests runner image ships no zsh, so the #111949 coverage never ran on
CI and a wrapper regression stayed green.
- js-tests.yml: install zsh on the Linux runner before the checks.
- Both legs now report vitest skips ('zsh not installed') instead of
passing; the ssh-connection leg is its own test so the skip is visible.
- Docs: note the zsh degraded mode (no process-group kill for a hung
probe's grandchildren) in the SSH connection guide.
The user-visible failure was the ownership capability probe reporting a
current remote as unsupported when sshd ran it under a non-interactive zsh.
Pin the probe itself, not only the wrapper: run remoteSupportsSshOwnership
through `zsh -c` against a fixture CLI that advertises both flags; skipped
where no zsh is installed. Fails on the pre-fix wrapper (empty capture).
Co-authored-by: the-repeter <50600051+the-repeter@users.noreply.github.com>
Fold the salvaged per-call ternaries in api/config.ts into a single
scopedDialPriority(scope) helper on api/client.ts and apply it to the
Capabilities scope selector's cold-start reads (getSkills / getToolsets /
getMcpCatalog), which hit the same background-capped pool queue when the
selector targets a stopped profile.
Drop the salvaged main.ts source-text change-detector test; keep only the
string update the existing #90812 wiring test needs. The renderer seam test
(hermes-capability-scope) is the invariant: an explicit scope carries
priority 'foreground', the ambient path stays untagged.
Part of #111651 (salvage #111672)
In connect()'s post-spawn catch, a cleanupStale rejection (indeterminate
ownership probe) replaced the boot failure's message and kind. Catch it,
attach it as error.cleanupCause, and rethrow the original error.