Real catalog pins from the 2026-09-20 intake batch scored on text that cannot run on
the installing host:
1. `.github/workflows/*.yml` — a CI step's own `os.environ['RUNNER_TEMP']` read scored
`python_os_environ/high` and made a clean plugin `caution` (remarkable). A workflow
runs on the forge's runner; it now takes the README prose cap (one step down,
agent-facing shapes like `curl | sh` keep full severity).
2. "pip install" inside a user-facing message literal (`"... no pip install is needed"`,
image-utils) scored `unpinned_pip_install/medium`; mid-literal, non-command position,
no exec verb on the line → low. `"pip install x"`, `python -m pip install`, `uv pip`,
`subprocess.run("pip install …")` keep medium.
3. `desktop_surface_findings()` was being run by batch tooling over every `*.js`/`*.mjs`
in a repo and flagged a Node sidecar's lazy `import('jszip')` (remarkable). The
product check was already scoped to `desktop/`; expose that scope as
`is_desktop_surface()` / `desktop_surface_hits()` so tooling shares it.
4. `127.0.0.1:<port>` (README, .mcp.json, client defaults) scored `hardcoded_ip_port` as
network egress; a line whose every IP:port is loopback → low. A routable address on
the line keeps medium.
Every finding stays in the report. PLUGIN_SCANNER_VERSION → plugin-guard-v8 so cached
verdicts on quarantined pins are re-evaluated.
The cherry-picked fixture landed above TestNodeRuntimeNpmResolution's
docstring, which turned the docstring into a bare string expression;
restore the order. With the class-level autouse stub in place the
per-test `with patch(warm_agent_browser_npx_cache)` in
test_node_failure_returns_failed_labels_and_warns guards nothing extra,
so one seam owns the stub for the whole class.
previewFileTarget() (apps/desktop/electron/main.ts) resolved a preview
target strictly against the agent's working directory (resolveHermesCwd()
/ process.cwd()). Attachment references stored in chat history are
frequently HOME-relative instead ("AppData/Local/hermes/attachments/
foo.xlsx" on Windows, or similarly under HERMES_HOME on macOS/Linux),
so the primary resolution never finds them: the attachment card shows
"File not found" and the Download button is dead, even though the file
is present on disk under HERMES_HOME/attachments.
Fix: when the primary resolution finds neither a file nor a directory,
retry against the user home directory and the HERMES_HOME/attachments
directory (basename fallback) before giving up. Additive only — a
successful primary resolution is never touched, and only relative,
non-file: targets are retried (absolute paths and URLs already had
their one real attempt).
The candidate-generation logic is extracted into a pure, exported
homeRelativeAttachmentCandidates(raw, home, hermesHome) in
electron/hardening.ts (which already owns every other IPC path-resolution
helper: resolveRequestedPathForIpc, resolveReadableFileForIpc,
rejectSensitiveFilePath) rather than inlined in main.ts. main.ts's
previewFileTarget is an unexported function inside an 18k-line facade
with Electron app/window side effects at import time, so it cannot be
unit-tested directly; the pure fallback logic can, DI'd with home/
hermesHome instead of reaching for app.getPath('home') / a module
constant.
Unlike the issue's proposed patch (hardcoded "AppData/Local/hermes/
attachments", Windows-only), this uses the module's existing
cross-platform HERMES_HOME constant (apps/desktop/electron/main.ts,
resolveHermesHome() — already resolves correctly to %LOCALAPPDATA%\
hermes on Windows and ~/.hermes on macOS/Linux per install.ps1/
install.sh), so the fallback works on every platform, not only Windows.
Closes#115609.
Testing:
- 6 new unit tests for homeRelativeAttachmentCandidates: the two real
candidates in order, backslash normalization, empty-array on an
absolute path / file: URL / empty input, and the basename-only
fallback when a ref lost its directory prefix entirely
- Proven red on unmodified main via git stash (all 6 new tests fail
with "homeRelativeAttachmentCandidates is not a function") and green
on the fix
- npx vitest run electron/hardening.test.ts -- 51/51 passed (45
existing + 6 new)
- npx vitest run electron/{preview-reach,preview-capture,
preview-guest-preload,gateway-file-download,gateway-file-download.fs}.test.ts
-- 58/58 passed, no regressions
- npx tsc -p tsconfig.electron.json --noEmit -- clean, no new errors
React re-dispatches an event fired in a portal along the REACT tree, not the DOM
tree. DialogContent portals into <body>, but in the React tree the session
rename dialog is a child of the session row (SessionActionsMenu renders inside
the row's actions slot), so a pointerdown inside the dialog's input still reached
the row shell's own onPointerDown with a target outside the row. The shell's only
guard walks the DOM (target.closest('[data-reorder-handle], [data-row-actions]')),
which cannot match anything mounted at <body>, so the handler fell through to
startSessionDrag(...) — the shared drag session's threshold is 4px, and any mouse
text selection crosses it — and to the forwarded dnd-kit pointer activator:
selecting the session title with the mouse lifted the row, lit every drop target
and armed the reorder.
Gate each shell's own onPointerDown on shellOwnsPress() (a press that started
inside the row's own DOM) before the existing marker exclusion: the session row,
the project row and the gateway group header. The keyboard side of the same leak
was already fixed in #115333.
Coordinates are untouched; the grabber keeps the full dnd-kit handle, so a press
on the row itself still runs both drags off one gesture.
Fixes#116080
Radix feeds `collisionPadding` into the hide (`referenceHidden`) middleware,
which INSETS the trigger's clip box: a trigger whose whole box lies within that
padding of a clipping edge reads as scrolled out, and its bubble mounts into
`visibility: hidden` — no bubble, no error, just a mark that looks dead on
hover. The rail's `.thread-timeline-tick` buttons are 7px tall and drawn flush
against the top and bottom edge of the strip they scroll in, so exactly the
first and last marks qualified. Probed on the middleware: 7px and 11px triggers
hidden at that edge, 13px and 24px visible — the 12px `collisionPadding`.
Rails keep `hideWhenDetached` off from now on. Their ticks unmount when the
strip scrolls them away, so the middleware has nothing to hide there; the
placements whose triggers stay put inside scrolling lists still get it.
Fixes#115723
Fixes#115538
ScrollToBottomButton always requested a full jump-to-bottom, including
while labeled "Approval Needed". PendingApprovalStack is decoupled from
the message that requested it and can end up above newer transcript
content, so a bottom jump can overshoot it and leave the user with
nothing to approve at the destination.
Tag PendingApprovalStack with the owning session id and, when an
approval is pending, scroll directly to that element (scoped by
session so a split view can't jump into a sibling pane's approval)
instead of requesting a bottom jump.
tests/tui_gateway/test_tui_gateway_server.py swaps agent.title_generator in
sys.modules for a bare ModuleType; the autouse sweep then called
wait_for_title_upgrades on the stub and errored every test in that file at
teardown. A stub spawned no threads, so a missing helper means nothing to
join.
tests/gateway/test_timestamp_sidecar_replay.py crashed the interpreter on CI
(native fault, green on rerun) on unrelated PRs. Root cause: every
run_conversation turn in its fixture spawns the auto-title upgrade daemon
thread (title_generator.maybe_auto_title). That thread outlives the test,
fails its model call (no provider under CI), and then writes the derived
title into the fixture's SessionDB after the fixture closed it, which
reopens sqlite on the daemon thread (_reopen_after_close_locked) and prints
the auxiliary-failure warning after pytest capture teardown. At the end of
the file the threads are still in native sqlite while the interpreter
finalizes: the check_same_thread=False-at-shutdown SIGSEGV shape of
#113186. Locally the thread finishes in ~250 ms so the race never shows;
on a loaded runner it lands on finalization.
Fix the class, not the file:
- tests/conftest.py: the autouse SessionDB leak sweep now joins the
auto-title upgrade threads (bounded, agent.title_generator.
wait_for_title_upgrades) before closing stores, so no title worker
outlives its test in any file (5 other files spawn them today).
- tests/gateway/test_timestamp_sidecar_replay.py: titling is not under
test; the fixture no-ops maybe_auto_title (same as
tests/agent/test_tool_call_incremental_persistence.py), so its own
db.close() no longer races a worker either.
- tests/hermes_state/test_session_db_leak_sweep.py: handoff pair pinning
the invariant (a slow upgrade thread started in one test is dead by the
next); red on base, green with the fix.
Proof (scratch plugin delaying the title model call by 1 s):
base: 2 auto-title threads alive at interpreter exit, every thread
"SessionDB reopened after close() on thread auto-title"; fixed: no thread
spawned / none alive at exit in this file and the other five.
The previous commit only exempted multi-select in one direction (type, then
the picks survive). Picking a choice still ran `setDraft('')` / `draft: ''`
unconditionally, so typing the custom answer first and then clicking a choice
silently discarded the typed text and submitted only the picks (probe: type
'something else', click 'staging' -> Other empties; answer ["staging"]).
Guard the draft reset with `!multiSelect` in `selectChoice` and `moveActive`
and keep `stage.draft` in the batch card's `toggleChoice` for multi-select
questions, mirroring the existing `onDraftChange` / `draftFor` exemption.
Single-select stays mutually exclusive.
In a multi-select clarify card, typing into the "Other" free-text field
wiped every previously picked choice, because the choice/text mutual
exclusion built for single-select cards was applied unconditionally. For
multi-select, the typed text is an additional answer, not a replacement.
Single-question card: onDraftChange and the Other field's onFocus only
clear selectedChoices when the card is not multi-select; the submitted
answer now merges the staged choices with the trimmed draft.
Batch card: draftFor keeps a question's staged choices when it is
multi-select; stagedAnswer merges them with the trimmed draft the same
way.
Fixes#115044
openSession's finally block only cleared $gatewaySwapTarget when
generation === openSessionGeneration, so a wake superseded before its
hydration wait finished (or timed out) skipped the clear entirely. The
two other set/clear paths (activateGatewayForProfile, the agent
activation path) clear unconditionally in their own finally, but any
later open that never sets the target itself (e.g. a paint-first open
without awaitHydration) has nothing to clear it, leaving the "Waking
up..." overlay stuck forever even though the session underneath is
fully functional.
Track which generation actually set the target and key the clear off
that instead, so a superseded wake still runs its own cleanup while a
newer wake's overlay can't be clobbered by an older one's finally.
Fixes#115844.
updateGroupChat persists the whole room map to localStorage on every
write and a failed setItem is swallowed, so a room past the origin quota
silently stops persisting every room. Measured on the real persist path
(scriptedStorage over appendGroupChatEntry): 400 uncapped 8k bodies
serialise to 3.25M chars, 400 64k pastes to 25.6M — well past the ~5M
char quota. Stored bodies are now cut to the same 8,000-char excerpt the
turn prompt renders (at append and in trimGroupChatLog, so remote merges
are bounded too) and the retained log is head-trimmed to a 256k-char
budget (8x the turn window) with watermarks kept consistent.
Wording: the window is bounded in String.length code units; comments and
the docs now say characters instead of KB.
Invariant test: a 400 x 64k room persists under GROUP_CHAT_LOG_RETAIN_CHARS
with the newest entry kept, marked, and its watermark intact.
A member's turn prompt rendered only the last 24 room messages since its
last turn, so a busy room routinely lost the head of an exchange (#114341
made the cut visible; this makes it rare). The window is now bounded by
size instead of a small count: up to 200 entries within a 32 KB character
budget, oldest dropped first, the omission marker naming the exact count.
One oversized body is cut to 8 KB with the existing '… [truncated]' mark
rather than evicting the messages around it. The local room log retains
twice the window so a member that skipped a whole window still gets an
exact count instead of a clamped watermark.
Docs: one sentence in the Bot Mode guide. Tests: the existing window
tests now cover the byte bound (exact omitted count, newest kept), a
150-entry delta that fits, and the single-paste truncation.
Replace the bare skipif(os.name == "nt") on the two deleted-cwd tests with
@pytest.mark.linux_only. The repo convention gates OS-specific tests through
the declared markers (linux_only/macos_only/windows_only in pyproject) so the
OS matrix stays greppable; the witness only needs a host that lets a process
remove its own cwd, which the Linux lane provides.
The cron bot-chat delivery child inherited the scheduler's cwd; when that
directory had been removed (a kanban worker whose scratch workspace was reaped
by completion cleanup) the child died in `hermes_cli/_startup_fast.py::
ensure_project_root_on_path` — a relative `sys.path` entry goes through
`os.getcwd()` inside `realpath`, which raises FileNotFoundError — before it
could parse argv, and the finished job was booked `delivery_failed`.
- `_run_bot_chat_turn` pins the child's `cwd` to the target home the lane has
already verified exists (`env["HERMES_HOME"]`).
- `ensure_project_root_on_path` resolves entries through a `realpath` that
tolerates a gone cwd, so any `hermes` invocation from a dead directory still
starts (the second half of #102941).
Salvaged from #102967 (@686f6c61), resolved onto the report-driven Popen lane
(#113608); tests drive the CLI entry point and the delivery spawn seam from a
deleted cwd.
Cached verdicts are keyed on the scanner version; without the bump a plugin or
skill already scanned "dangerous" for an env-var NAME constant would keep its
cached verdict and stay blocked.
The generic hardcoded_secret pattern fired on constants whose value is the
NAME of the credential environment variable (an ENV_PASSWORD-style constant
holding the string "MYPLUGIN_" + "APP_PASSWORD"): a line-oriented regex
cannot tell a reference to a secret from the secret itself, so one critical
finding made such plugins uninstallable with no --force override (#116221).
Both copies of the generic pattern (the shared threat-pattern library and
the skill/plugin guard table the installer actually walks) now skip a value
that is itself a SHOUTY_SNAKE environment-variable name (at least two
underscore-separated segments). The carve-out is scoped case-sensitive
because both tables compile with IGNORECASE: a lowercase snake value is the
passphrase shape, and requiring an underscore segment keeps
underscore-free all-caps credentials (AWS access key IDs, base32 secrets)
matched. Prefixed provider tokens (sk-, ghp_, ...) and the dedicated
provider-signature patterns are unaffected.
Regression tests pin both directions: the env-var-name line no longer
flags, and the passphrase / AKIA / base32 / prefixed-token shapes still do.
The shared cua-driver install/refresh path reached _repair_cua_driver_autostart_windows, which spawned powershell.exe without CREATE_NO_WINDOW and without -NonInteractive. Under a windowless parent (Desktop backend, detached gateway, logon task) that child allocated its OWN console: a blank PowerShell window parked on the desktop for as long as the elevated -Verb RunAs -Wait child lived, with no interactive prompt it could unwind from. Measured live on Windows 11 (pythonw parent): production kwargs open a visible console/Terminal window, the same spawn with CREATE_NO_WINDOW opens none.
A failed repair also failed the whole install, so a compatible, working Computer Use toolset read as broken and the install was re-attempted on the next run. It now degrades instead: warn plus the elevated 'cua-driver autostart enable' hint.
Fixes#115017
Line 1687 documents 'runs via shlex.split, shell=False', which after the
preceding commit is no longer the whole story on Windows: the bare script paths
every example on this page use are routed through their interpreter there. Left
unstated, the page keeps describing the behaviour that caused the bug.
A hook declared as `command: "~/.hermes/agent-hooks/x.sh"` — the shape every
example in website/docs/user-guide/features/hooks.md uses — cannot start on
Windows. _spawn() shlex.splits the command and Popen()s it with shell=False, so
the kernel reads the shebang on POSIX but CreateProcess on Windows receives a
text file and answers WinError 193. The hook then reports no returncode, which
a fail_closed gate treats as a failure and every other consumer silently skips.
Route a first argument that is an existing file with a mapped suffix through its
interpreter, reusing tools.environments.local._find_bash() so the resolution
keeps the ordering that avoids WSL's bash.exe (#115124) and surfaces Git-for-
Windows' own guidance when it is absent. POSIX argv is untouched. Suffixes we
cannot resolve an interpreter for still fail, but the diagnostic now names the
remedy instead of the OS's localized complaint.
Repairs five of this file's tests that have been red on native Windows
(TestCallbackSubprocess x4, hooks TestHooksTest::test_fires_real_subprocess_and_parses_block);
the two that stay red are drive-letter/`~` tokenization, which #68508 owns.
Verified on Windows 11 26200 / cp936 with real subprocesses: bare .sh, .bash and
.py hooks execute and carry their exit code; a missing path still reads
"command not found"; `git --version` is unaffected.
Fold the four salvaged tests into the two invariants that matter (#116376):
a Windows quit past its deadline forces exactly one hard exit and never arms
off Windows; a completed quit cancels the fallback and it never re-arms.
Also the §E one-liner from the report: pick the quit prompt's parent from the
VISIBLE windows. With a turn in flight and the main window already gone,
getAllWindows()[0] could be a hidden aux window, so the "Quit Anyway" dialog
was invisible and the held quit unanswerable.
Preserve the local backend startup gate after the Windows cmd wrapper exits successfully but before the real detached updater claims the marker. Reuse the existing handoff quit state and cover the stale-wrapper interval with a regression test.
Inbox style is a render variant, not a grouping — it rides whichever view
is active. The pinned section call was the one flat-list section that never
received the `card` prop, so with Inbox style on the same sidebar column
mixed 44px two-line inline rows (pinned) directly above 54px three-line
cards (recents), with two different model-name formats breaking exactly at
the section boundary.
Pass `card={cardRows}` to the pinned section, matching recents and the
project overviews. The toggle then governs both sections the same way; an
explicit opt-out for pinned rows (#89308) composes on top of this default.
Tests: the pinned section renders the card geometry when $sidebarCardRows
is on and stays inline when it is off (red on base: pinned row rendered
`min-h-[1.625rem]` inline geometry with Inbox style on).
Refs #116325
``test_video_helpers_read_real_geometry`` renders a clip with ffmpeg and
reads it back with ffprobe. The Linux CI runner ships without either, so
the test errored with FileNotFoundError instead of exercising anything.
Skip it when the binaries are missing (same shape as
tests/gateway/test_voice_command.py); the two mocked tests still cover the
send path everywhere.
Keep the entry-point tests (geometry + thumbnail reach bot.send_video / the
hermes send media path; an unprobeable file still sends) and the real-file
helper check; drop the no-ffmpeg degradation and non-video-extension guards.
`sendVideo` gives back `width=320 height=320 duration=0` with no thumbnail once an
upload is large enough that Telegram skips its own video processing, and clients
then draw the message as a square tile — for portrait reels and 16:9 clips alike,
even though the delivered file itself is correct.
Measured on one 6 s 2560x1440 clip, inspecting the Bot API response: 4.9 MB and
9.8 MB keep `2560x1440` / duration 7 / a 320x180 thumbnail; 14.8 MB, 19.4 MB and
23.0 MB degrade to the square placeholder; the same 23.0 MB file sent with
`width`/`height`/`duration` plus a JPEG `thumbnail` comes back `2560x1440` with a
320x180 thumbnail.
Probe the local file with ffprobe and attach a 320px-wide JPEG frame from it, on
both Telegram send paths: the gateway adapter's `send_video` and the standalone
`hermes send` media sender. Both helpers return nothing when ffmpeg/ffprobe is
unavailable, which keeps the previous behaviour for hosts without them.
The exit binding matched isAction(key, ch, "d"), which on macOS means Cmd+D:
Ghostty consumes Cmd+D for split panes and literal Ctrl+D never matched, so
the TUI stayed open. Ctrl+D is the terminal EOF convention, not a Cmd
shortcut, so it now also routes through the existing isMacActionFallback seam
(target union gains "d"), and on every platform it exits only when the
composer holds no text, buffered lines or attachments, matching the classic
CLI. Slim redo of #116454.
Co-authored-by: Mohamad Kanso <91088196+MohamadKanso@users.noreply.github.com>
`start "" /min` told cmd's `start` to allocate a NEW console for the
PowerShell hand-off script and only minimize it, so every Desktop update
created a visible ConsoleWindowClass window (#116161). Switching to
`start /b` (previous commit) makes the child share the wrapper's console
instead — but the wrapper was spawned `detached: true`, which libuv maps to
DETACHED_PROCESS: the wrapper has NO console (and the OS ignores
CREATE_NO_WINDOW alongside DETACHED_PROCESS), so under `/b` powershell
would have been forced to allocate its own visible console — the very
failure mode the original `start` layer worked around.
Spawn the wrapper non-detached instead: libuv then honours `windowsHide`
(CREATE_NO_WINDOW), cmd.exe owns one hidden console, and the script runs
inside it. Survival past our own exit does not need `detached` — libuv's
job object is created with JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, so
grandchildren are never members, and Windows does not tie a process's
lifetime to its parent. The recipe carries `detached: false` so the call
site cannot drift back to the detached shape.
Not live-run on Windows; mechanism per the reporter's SetWinEventHook
measurements (CREATE_NO_WINDOW + `start /b` → no window) and libuv's
process.c.
Keep the Use-button case (staged but absent from the spawn-only live
listing is accepted) and the control (never staged stays rejected); the
other five re-asserted the same branch from different angles.
The validation ladder had no llamacpp branch: a switch to a freshly downloaded
local model fell through to the generic live-listing probe, which hard-rejects
when the spawn-only /v1/models listing has not learned the new file yet — so the
Local Models Use button and the composer picker could never succeed for a
non-catalog model. The managed runtime now validates against the staged library
on disk (the source of truth for what the user downloaded), case-insensitively;
ids that were never staged keep the live-listing verdict.
The activate flow's self-heal had the same blind spot: its rescan only ran when
this process supervised the server, but ensure_local_runtime returns None when
another process owns it — precisely the desktop situation after a download job
bounced the router once. Probe the live listing through the persisted endpoint
and bounce the router when it lacks the model.
The skill was moved to the security category as a pure R100 rename
(fdc90346ea), but the eleven install-path strings embedded in its own docs
and scripts kept the pre-move category. On a fresh install the skill lands
under the security category in the skills home, so every copy-pasteable
snippet in the docs and the loader fallbacks in the scripts raise
FileNotFoundError on first use. Rewrite both embedding forms — the
joined-string form and the segmented Path(...) form — and add a CI
contract test that fails when any optional skill references its own
install path under a category that does not match its location.
filter_media_delivery_paths kept only the survivors, so a MEDIA path that did
not exist on the host (or was denied by the delivery policy) vanished with a
host-side warning while the caller got success:true / exit 0 and booked a
delivery that never happened. _validated_delivery_path and
filter_media_delivery_paths take an optional `dropped` list that collects
{path, reason}; send_message_tool passes it and, when anything was dropped,
returns success:false + partial_success:true + media_dropped + an error line,
which hermes send already turns into a non-zero exit. Slim redo of #115913
(@fangliquanflq): same payload shape, out-parameter instead of a wrapper pair.
Co-authored-by: fangliquan <fangliquan@qq.com>
Keying the packaged-dist strip solely on headless_backend also stripped
HERMES_WEB_DIST from the Desktop's own legacy `dashboard --no-open` fallback
(taken when the `serve --help` probe times out on a cold host), sending a
packaged install with no node toolchain into _build_web_ui(fatal=True).
The fallback child is told apart by the per-spawn
HERMES_DASHBOARD_SESSION_TOKEN, which the terminal pane never receives and
the terminal tool env policy strips from agent children. One invariant test,
red on the previous head.
Drop the caller-managed-dist case that only holds with the predicate
rewrite in hermes_cli/main_dashboard.py, which this salvage does not
carry (the substring check is unchanged).
Follow-up on the salvaged commit: try strict UTF-8 before the ANSI code page (ASCII and real
UTF-8 pass, ANSI multi-byte text fails loudly instead of being mis-read by a dense codec such
as GBK), share the localized "access is denied" words between the fallback and elevation
patterns, and drive the tests through `_exec_schtasks` (fake subprocess with the reporter's GBK
bytes on Linux; a real schtasks task with a non-ASCII argument on the Windows lane).
Rebinding session switching to mod+alt+arrows was inert while the
composer held focus: the input gate rejected every combo whose base key
is a navigation key before the primary-modifier carve-out could run, so
an explicitly rebound chord like mod+alt+left never dispatched. Since
every session activation re-focuses the newly shown composer, switching
degraded to one switch per background click.
A chord carrying Alt on top of a primary modifier has no native
text-editing meaning (it is not option+left word-jump or cmd+left
line-start), and the shipped mod+alt+t tab-strip default already
establishes this gesture class. Narrow the base-key rejection to
navigation chords that can be text navigation: single primary modifier
(plus/minus Shift) or bare Alt. The accidental-trap class stays
input-local, so ctrl+arrow and cmd+arrow bindings remain native editing
gestures while typing.
Refs #115980
The setup wizard asked start-now/start-on-login, then called the Windows
installer without forwarding the answers, so the installer asked the same
two questions again. After a UAC hand-off (nothing registered yet in the
parent), the wizard then started the service, which re-entered the install
flow and re-offered the UAC prompt while the elevated child was still
waiting on consent.
Forward both answers into the Windows installer and let the installer own
the start decision: it starts the gateway itself on both the Scheduled
Task and Startup-folder paths, and reports a UAC hand-off as False so the
wizard parent stops instead of starting an unregistered service. Fixes#116550.