Every Windows x64 MSIX chat smoke since the Blacksmith migration (91fae5f8cb)
failed: first on the GitHub-hosted guard, and since that guard was removed
(272adc7b6f) with Playwright's "Process failed to launch!", on Electron 40 and
44 alike. The same builds' arm64 MSIX, on a GitHub-hosted runner, chats; the
last green x64 smoke (Oct 14) ran on windows-latest-32-core. Move the x64 leg
back there, as f1b290e773 did for the install/update GUI legs.
The failure itself carried no evidence: when the app exits before its debugger
line, Playwright's Electron.launch leaves sibling stderr-watcher rejections
unhandled, Node dies on them, and the smoke's catch never writes its receipt or
Playwright's call log. The driver now logs those rejections (still failing the
run), so the receipt records the app's exit code and stderr, and the Windows
smoke saves Application-log errors from the run on failure.
Builds on #142306 (Maciej, @c2h5oh). That PR taught the desktop smoke that the
listener Desktop dials can be the canonical gateway named in the home's
gateway_state.json, and proves that gateway's tree by the cwd `gateway ensure`
starts it in. Install-e2e run 39567335733 (main + this branch) hit two cases it
does not reach:
- Windows: CIM exposes neither the cwd nor the environment of another process.
Every desktop checkpoint that reached the (now startable) detached gateway
failed with "Source backend listener imports a different source tree
(cwd=(unreadable), HERMES_PYTHON_SRC_ROOT=(unset) ...)". The gateway now
vouches for itself there. localBackendProcess keeps the code_sha the gateway
stamped into its own gateway_state.json (gateway/status.py). assertBackendOrigin
accepts the app's reported root for it only when that code_sha is the
installed commit, which both callers already read. A readable cwd is still
the evidence everywhere else, and the child/ledger rules are unchanged.
- All OSes, HEAD -> NEXT app-driven update legs: update-window-chat.mjs called
localBackendProcess without a home, so it never looked for the canonical
gateway ("Expected one Desktop-owned, registered host or canonical gateway
backend listener on port N; found 0"). It now passes the app's own
hermesHome and the installed commit.
An install/update journey runs its phases in separate CI steps. Each step's
mock provider dies with its step, but the gateway Desktop started is detached
on purpose and survives into the next step with the provider URL it read at
boot. The next step started a mock on a fresh port and rewrote config.yaml/.env,
so the surviving gateway kept dialing the dead port: every HEAD -> NEXT desktop
leg on macOS and Windows failed with "The mock must receive this checkpoint
prompt after the send" (run 39523017025: agent.log shows the gateway calling
127.0.0.1:61022 while the new mock listened on 53982; macOS 49478 vs 49533).
Linux passed only because its mock happened to survive between steps.
mock-provider.mjs now takes the prior step's URL and rebinds that port when it
is free (any free port otherwise), and both callers (mock_start and
Start-DesktopJourneyMock) pass it. The config stays byte-identical across
steps, and the chat smoke's commit check still proves the gateway runs the
updated code.
Since #106742 Desktop reaches its local backend through `hermes gateway ensure`,
which attaches to (or starts, detached) the canonical `gateway run` daemon. That
listener is reparented away from Electron and has no `serve` spawn-ledger record,
so localBackendProcess found 0 listeners and every Linux +desktop install/update
leg failed after an otherwise healthy boot.
Accept a third listener kind, `host-gateway`: the pid gateway_state.json names in
the home under test or its machine root, running, kind hermes-gateway, same
incarnation (start_time in gateway.status units), and the actual OS listener on
the connected port. The installed-tree origin check is unchanged and gets no
app-report waiver for it; the gateway proves its tree by the install-root cwd
`gateway ensure` starts it in.
Since #137522 and #137807 a fresh desktop install opens the first-run
onboarding chat. The install/update E2E desktop chat smoke then sent its
checkpoint prompt into that chat instead of a normal one, so the mock
never received it ("The mock must receive this checkpoint prompt after
the send"), and on the Windows GUI installer path the installer-launched
app was mid onboarding turn when the journey closed it, so it did not exit
within 30 s. Seen on v0.22.0 attempt rc.1 in three Windows
desktop-installer@latest (HEAD -> NEXT) legs.
The mock provider writer now sets onboarding.run: false in the root
config, and the Windows GUI install phase seeds the same key before the
installer launches the app. The journeys test install and update, not
onboarding (which has its own desktop e2e spec).
The Microsoft Store variant forced HERMES_GUEST_ONBOARDING=1 into its bundle env,
and commit/channel builds could bake it with --bundle-env. Nothing reads it now.
- scripts/bundles/desktop_inputs.py: the Store bundle env is just the request's.
- bundle_env.py / bundle-env.mjs: the key is no longer allowed for new builds.
- channel-protocol.ts: the key moves to RETIRED_BUNDLE_ENV_KEYS, so preview
manifests already published with it still decode (the HERMES_SKIP_INTRO
precedent).
- BUILDING.md example uses HERMES_PREVIEW_FULL_CONNECTORS.
- Build tests use another allowed key as their sample; both validators reject the
retired key and the decoder drops it.
- msix-virtualization.test.mjs: every generated MSIX manifest (store, release,
canary, channel) keeps filesystem and registry write virtualisation disabled,
and the builder config declares unvirtualizedResources. The machine marker and
the shared %LOCALAPPDATA%\hermes root depend on that.
The v2026.7.1 desktop keeps the composer's model as renderer state and ships
it as the session override on session.create. waitForChatReady only proves the
composer is interactable, so a send that beat the configured model's load went
to the app's default (openrouter/claude-opus-4.6), got a 401, and the mock never
saw the prompt: "The mock must receive this checkpoint prompt after the send".
rc.12-v0.21.7 hit it in 2 of the 5 macOS dmg legs on that baseline.
The smoke now waits (30s cap) for the mock model's name to appear in the page
text. Apps prettify the id ("Mock Model"), so case and separators are folded,
and no selector is assumed. An app that never renders the name proceeds and
the mock-received assertion still decides; the renderer log records it.
The smoke driver created .desktop-smoke-home inside the predicted Hermes
home before checking that the home was empty. Bundles with stamped env
that retained the explicit HERMES_HOME failed before Electron launched.
Resolve and admit the predicted home before any sandbox writes. Keep the
existing-profile refusal and provider seeding intact. Extend the seeding
contract for onboarding defaults and forced home clears.
Verified the old failure and fixed launch boundary on native Windows ARM64
with real driver imports and filesystem writes. Full package install/chat
acceptance still needs a fresh hosted smoke run.
processStartTime read `ps -o lstart` in local time and rebuilt it with a
hand-written month table. In the hour that repeats when DST ends, local
01:30 names two instants and the reconstruction picked the first, putting a
backend started in the second 3600 s off its ledger create_time, so a valid
host backend was rejected. ps now renders lstart with TZ=UTC0 and the value
is parsed as GMT with Date.parse (V8 accepts the exact `ps` format,
including the double-spaced day), which also drops the month table.
appVouchesForBackend tested `ownership !== 'host-ledger'`, so a backend
object that never had its ownership established (the field was optional;
untyped .mjs callers can build one) received the ancestry waiver. The
waiver's premise is ancestry, so it must be stated: localBackendProcess now
returns a BackendProcess whose `ownership` is required, and the waiver
requires `=== 'child'`.
Moves the app-ancestry evidence predicate (process evidence unreadable,
not a ledger-attached host backend, app reported the same tree) and its
comment into a named helper. Same expression, same short-circuit order,
evaluated at the same point; behaviour unchanged. Brings
assertBackendOrigin's CC back under its main cap (24 -> 19).
A host-ledger backend is not an Electron descendant, so the app-reported
root no longer stands in for its tree when process evidence is unreadable
(Windows). Ledger records must match the live process's start time
(create_time, 2 s like the product), and the ledger is read at the machine
root the app resolves for the home, where the product writes it.
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder
Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.
* feat(clarify): one questions[] shape with per-question status and one outcome
The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.
Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.
The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.
* feat(clarify): every surface asks through the one question card
CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.
Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.
Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.
Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.
* fix(clarify): cancelled for a released messaging card, labels win over the skip word
A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.
A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.
The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.
* fix(tui): re-editing a multi-select answer keeps its picks
The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.
Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.
* test(clarify): align three checks with the one-answer confirm and the regenerated keys
The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.
* test(tui): the one-question card header is singular
Since the questions[]-only schema (#95907) every single question is a
one-entry batch on both the tool args and the gateway wire, yet no test
exercised that shape (called out in #98645). Lock the behavior down at
both layers:
- unit: a one-entry batch renders the batch card (not a blank/spinner
single card) both with the wire already parked and when the request
lands after the tool row, and answers with the qid-keyed lock
- e2e: a SINGLE_BATCH trigger drives the real chain (composer -> gateway
-> agent -> clarify tool -> clarify.request -> renderer) through mount,
pick, confirm, and settle for questions.length === 1
The e2e mock's trigger routing also learns to scope its has-tool-result
guard to the answering turn's own question text: the existing any-tool-
result check would false-positive once a second scripted clarify shares
the conversation history.
Adapted to main: the mock server now lives in tests-js/scripts, and a batch
confirm answers with clarify.lock.
The HEAD -> NEXT Windows legs take every git off PATH so the product must
provision its own; readInstallationCommit spawned bare `git` and died with
spawnSync git ENOENT before the chat checkpoint could judge the product.
- tools/browser_tool_install.py: keep pm-clean's frozen old-updater stub; main's
UTF-8 decode fix touched only the npx prefetch body it replaces.
- tests/hermes_cli/test_update_scoped_reconciliation.py: keep pm-clean's test
subset (catch-up rides the PM completion owner) and take main's gateway-less
host evidence (#120740): the updated seed that holds the host at a running
gateway, and the two gateway-less matrices for the source change that merged
cleanly into update_cmd_fleet.py.
Independent-review follow-up for the group room failed-turn fix.
- Group room poll: a retained error newer than the pre-submit snapshot
(turn start replaces it with a fresh started_at) is this turn's failure
and wins over transcript text. The core closer writes no failed-turn row
behind a tool row, so "said X, called a tool, provider 401" left X as the
newest assistant row and the room posted it, dropped the 401 and re-drove
the member to the round cap (live repro on the PR head).
- Both pickers end the turn at a failed_turn row instead of scanning past
it; with the retained error gone (backend restart) the row's notice is
reported through the failed path instead of a silent pass / dropped
stranded marker. The stranded harvest treats a retained error as the
stranded turn's own the same way.
- REST cold-load/paging (/api/sessions/{id}/messages, /messages/around)
type legacy untyped notice rows like session.resume does, via one
read-side helper in agent/turn_failure_copy.py.
- Gateway closer: the fresh-session closure test now asserts the row's
display_kind through a real SessionDB round-trip (red without the
run_turn.py stamp).
- E2E: mock trigger that says text + calls a tool, then 401s; spec asserts
the room reports the error and never posts that text.
The v2026.6.19 Desktop starts its source backend through the host Python.
Windows cannot expose that process environment or working directory. Carry
the app's verified source root into the existing strict provenance check.
A PM update can leave a locked historical executable beside the current
command-file launcher. Settle the updated runtime through the command file and
use verbatim cmd.exe arguments so paths with spaces remain valid.
Verified with the focused Desktop smoke tests, the tests-js typecheck, syntax
checks, and a native Windows ARM command-file launch probe.
Run deferred source dependency and bundle replacement work under the same
sanitized environment used by Electron before attaching Playwright. This
keeps the expected process boundary outside the smoke session.
Merge fallout (my resolution errors, all caught by CI):
- hermes_cli/backup.py + gateway.py: `theirs` on those hunks re-imported clusters HEAD had
already moved to backup_restore.py / kept in the facade. backup.py loses the 349-line
duplicate (main's #110179 fix is ported into backup_restore._import_db_member); the
systemd service-unit cluster returns to gateway.py (PM's _prepare_service_launcher /
_pm_managed_node_dirs / _systemd_command have no home in main's extraction) with main's
utf-8-sig read. gateway_service_unit.py is dropped.
- gateway/run.py: main's plugin-update chore is not profile-scoped (the housekeeping
ordering test pins the scope/drain sequence).
- pyproject + 30 test files: `import yaml` -> `import hermes_yaml as yaml` (pm-clean has no
pyyaml); gateway/config._bundled_platform_manifest_name reads through hermes_yaml.
- tests re-seamed onto pm-clean's shape: residency admission (installed_engine),
supervisor child env (binary is a constructor argument), update import guard
(update_cmd_deps is gone; our probe already scrubs PYTHONPATH — both #115032 invariants
pass), shallow-count git responses (stash path asks `status --porcelain -z`); dropped
tests for retired code (_run_node_bootstrap/_ensure_tui_node, Windows resume demotion).
- tests/tools/test_local_env_blocklist.py: restore the two helpers the suite-reduction
commit dropped and the blocklist import.
Real fixes:
- pm: classify_uv_failure/ResolutionConflict move beside the uv runner (pm.environment,
stdlib-only). pm.workspace imports tomllib at module level and cannot load on the 3.10
bootstrap python that streams uv output in the Docker arm64 image.
- tools/browser_tool.warm_agent_browser_npx_cache: back as a permanent definition — it is on
the frozen old-updater surface, and the revert-scheduled compat pointer does not count.
- hermes_cli/memory_setup: the dashboard's pip row uses pm.environments.
running_from_selected_environment for installed vs restart_required.
- scripts/windows-build-deps.ps1: export DISTUTILS_USE_SDK/MSSdk so setuptools trusts the
primed MSVC environment instead of asking vswhere (`env -i` test runner on win32-arm64
compiling ruamel-yaml-clib); run_tests.sh forwards them.
- tests/pm/test_windows_build_deps.py: start the protocol test from a parent env without the
toolchain variables the runner job already exports.
- tests/conftest.py scrubs HERMES_BUNDLED_PLUGINS (Nix-wrapped hermes on the dev host);
tests/home_io_guard.py treats sys.path site-packages under the real home as the
interpreter's installation (PM-activated developer shell).
- tests-js: four `curly` lint errors from main's new scripts.
Production:
- agent/bedrock_adapter.py, agent/vertex_adapter.py: pm.ensure_import ran at
module import. In any process that imports these modules without a committed
PM selection (CI's build_environment test venv, a fresh checkout) that sync
rebuilt the dependency environment mid-process and replaced sys.path with a
generation missing the caller's own packages (anthropic, aiohttp vanished).
The extra is now ensured at first client build / credential request.
- plugins/platforms/matrix/adapter.py: a complete install needs no
ensure_and_bind round trip; only a partial one syncs.
- tools/browser_tool.py: drop the facade's duplicate warm_agent_browser_npx_cache
shim; the compat pointer already resolves to browser_tool_install.
Test harness:
- tests/home_io_guard.py: PATH-entry probes (shutil.which) and the running
interpreter's own installation (stdlib reads, realpath ancestry, fixture
symlinks into it) are not Hermes state; a patched Path.expanduser must not
crash the guard. run_tests.sh no longer filters PATH — the guard owns it.
- tests/tui_gateway/conftest.py: import hermes_bootstrap before any file opens
a MagicMock hermes_constants window (6 files exited the process at boot).
- tests/hermes_cli/conftest.py probe_root: scratch checkouts the import guard
probes need hermes_bootstrap.py (the launcher imports it).
- tests/pm/_fixtures.py stage_host_python: a copied relocatable python needs
its stdlib beside it (No module named 'encodings' on CI).
- tests/install/e2e-assets/smoke-env.mjs: dependency-free env shaping so the
source-build-env probe runs under bare node (main deleted the Playwright
entry it was imported through).
- adapt main's new tests to branch seams (model_metadata_http, launch
completion tail, CI toolchain exports uv after python, source_launch
hermes_cli stub, systemd_notify single marker).
- check_no_tmp_literals: resolve scratch via tempfile/os.tmpdir; the termux
container mount point is one marked variable per script
- ruff TID251: desktop E2E fixtures may reach PM internals like tests do;
the pm.runtime_stage ban message no longer names a module that never existed
- auth_codex: build the capped httpx stream subclass on first use so importing
hermes_cli.auth_codex no longer forces httpx (the lazy proxy in auth_constants
was defeated by a module-scope base class; broke lanes without httpx)
- desktop-smoke: launchApp is a parameter; the bundle-env test substitutes a
refusing launcher instead of letting Playwright spawn a dying binary
(3 unhandled rejections failed the tests-js lane)
assertBackendOrigin demands evidence of the tree a module-launched backend
imports from. On Windows there is none to read: the venv launcher hands the
interpreter over as a system python, so argv never names the tree, and the
platform exposes neither the process's cwd nor its environment. Every desktop
leg there died with
Source backend listener imports a different source tree
(cwd=(unreadable), HERMES_PYTHON_SRC_ROOT=(unset),
executable=C:\hostedtoolcache\...\python.exe, ...)
while the backend was in fact the installation's own: its app log says
`[backend] `serve` supported for Hermes at <root> (venv: <root>/venv)` and it
came up and served.
The two facts that matter are already established before the assertion runs:
localBackendProcess only returns a listener that is a descendant of the app
process the driver launched, and the driver asserts the root that app reported
resolving against options.root. So pass that reported root through as evidence
and accept it -- but only when the platform supplied no process evidence at all
(no cwd, no PYTHONPATH, no VIRTUAL_ENV), which keeps the platforms that can read
one exactly as strict as they are today.
Verified: tsc -p tests-js/tsconfig.json --noEmit clean; the new invariant test
covers the Windows shape, its control row (nothing readable and no report is
still a different tree), a report of some other tree, and the case that matters
most -- readable evidence naming another tree is NOT rescued by the app's report.
The only failing test in that file is the pre-existing headless Electron launch.
The backend-origin assertion accepts a command that names the installation
root, which is the LINUX shape. On macOS `<root>/venv/bin/python` is a symlink
to the framework binary and the app resolves it before spawning, so argv names
that binary and never the root; on Windows the install's venv copy is bypassed
for the toolcache interpreter. Twelve macos legs and their windows twins died
with "Source backend listener imports a different source tree" while the
backend was the installation's own: its log reads
`[backend] serve supported for Hermes at <root> (venv: <root>/venv)`, and it
came up and served on a real port.
The app binds that backend to the tree by ENVIRONMENT -- main.ts puts
`[ACTIVE_HERMES_ROOT, process.env.PYTHONPATH]` on the backend's PYTHONPATH and
VIRTUAL_ENV names its venv, which is how `import hermes_cli` resolves from the
installation. macOS reads a process environment through `ps eww`, so the
assertion now also accepts an installation-root entry in PYTHONPATH, or a
VIRTUAL_ENV whose parent is the root. Windows exposes no equivalent reader, so
a Windows backend stays exactly as strict as it was.
Verified: `vitest run tests-js/scripts/desktop-smoke.test.ts` passes including
the new test (macOS shape accepted; control row with no environment evidence
still a different tree; evidence naming another tree still rejected), and
`tsc -p tests-js/tsconfig.json --noEmit` is clean.
The update window's checkpoint submitted the ROOT checkpoint's prompt: the
backend turn for the window's fresh session carried the earlier checkpoint's
exact text, so the witness could never match the prompt the smoke typed. The
app persists its composer draft across launches, so the window boots with the
previous checkpoint's text already in the composer.
Select-all + Delete it and refuse to type until the composer reads empty, so
the checkpoint proves its own input rather than inheriting a draft.
The draft-clear poll passes vacuously: the composer reads '' when the editor
never accepted the keystrokes, so a window whose chat is inert looked exactly
like a window that sent and cleared. Assert the echoed prompt first, so the
checkpoint fails fast and says which half broke.
On failure, also write the renderer's own evidence (`desktop-chat-<phase>-renderer.log`:
timeline-free console lines plus thread/composer DOM state) — from the mock's
side a swallowed send and a send that was never made are identical.
The desktop chat smoke's witness poll timed out on the app-update leg while
`POST /v1/chat/completions` was logged and answered — a request arriving and a
request being *recorded* look identical in mock.log. Log the recorded prompt
text on the two recognized body shapes, and when neither matches log the parsed
lastUserMessage plus the raw body (truncated). One run then names the shape
mismatch instead of another round of theory.
Extracting the shape check also pins that an unrecognized body records nothing,
rather than a half-parsed witness, and describeForLog tolerates an absent
lastUserMessage (`JSON.stringify(undefined)` is not a string).
The driver collects this server's stdout as mock.log, so a request that never
arrived and a request to an endpoint the mock does not serve were both invisible:
"0 requests" was read as evidence the desktop app never sent anything, when the
server was in fact incapable of recording a request at all.
One line per request (method + path), plus an explicit
"NOT IMPLEMENTED <method> <path> → 404" on the fallthrough. The mock serves only
/v1/models, /v1/chat/completions and the witness endpoint, so an app that POSTs
/v1/responses now says so by name instead of failing silently.
Verified against the real server: GET /v1/models → 200, POST /v1/chat/completions
→ 200, POST /v1/responses → "NOT IMPLEMENTED POST /v1/responses → 404".
installer-script+desktop -> hermes-desktop-app-update failed on
"The submitted draft must clear before the idle control proves completion"
(15s predicate), while the failure screenshot shows a healthy, configured app --
Gateway ready, Mock Model selected, and the exchange COMPLETED
("Hello from the mock inference server! The full boot chain is working.") with an
empty composer by capture time.
So the submit was accepted and QUEUED, not rejected: v2026.8.31's composer routes
a submit to its queue while a turn is in flight (its own comment: "busy submit
routes text to the queue instead of a steer"), so the draft legitimately stays
visible past 15s and clears once the queue drains. That is why this leg differs
from its sibling, whose app the capture step builds from HEAD.
The draft-clear poll now uses 90s like the mock-receipt and reply polls beside it;
those still enforce delivery, so a genuinely stuck composer fails there instead.
desktop->desktop failed with "0 deleted, 1 modified" and MODIFIED .env but no
`variables ...` line; the new line/order summary said "no key differs --
comments/order/blanks only", which is reordering. The writer filtered its managed
keys out and re-appended them at the end, so every call moved a journey's own .env
entries while keeping the same keys and values: a byte-level change with nothing
for a value-level diff to name.
Managed keys are now rewritten where they already stand, with only missing ones
appended, and the prior file's trailing blank lines are dropped BEFORE appending
(trimEnd() runs after them and cannot reach a blank line they follow -- the first
version of this fix left one, caught by asserting the writer's exact bytes).
Verified: writeEnvFile leaves
OPENAI_BASE_URL=<url>/v1, "# keep me", OTHER_TEST_VALUE=kept, MOCK_API_KEY=..., OPENAI_API_KEY=...
in that order; tests-js/scripts/desktop-smoke.test.ts is 12 passed | 3 skipped.
The run configures the mock provider through the CLI path, which writes
OPENAI_BASE_URL/OPENAI_API_KEY into .env. tests/install/e2e-assets/desktop-smoke.ts
then calls writeEnvFile(home) with NO url -- and that call filtered the portable
pair out of the file while writing only MOCK_API_KEY. So the user-state snapshot
recorded .env without the endpoint, the upgrade's own post-update .env sync added
it back, and the leg failed as "the upgrade changed the user's own state ... 0
deleted, 1 modified" with the pair reported as ADDITIONS (before: 26403 bytes with
MOCK_API_KEY; after: 26473 bytes with the pair).
Only keys this call actually replaces are dropped now: without a mockUrl the
MOCK_API_KEY line is still rewritten and the portable pair is left untouched.
Proven red on base by the new case (writeEnvFile(home) left
'\nMOCK_API_KEY=e2e-mock-key\n', endpoint gone) and green with the fix.
tests-js/scripts/desktop-smoke.test.ts: 11 passed | 3 skipped.
The diagnostic from the last run named the mismatch:
cwd=.../home/.hermes/.desktop-smoke-home HERMES_PYTHON_SRC_ROOT=(unset)
expected=.../home/.hermes/hermes-agent
command=".../hermes-agent/venv/bin/python" "-m" "hermes_cli.main" "serve" ...
The backend was the installation's own venv interpreter; the check inferred the
import root from the process cwd, which the APP owns (the smoke's home), so a
correct backend read as "imports a different source tree".
A captured HERMES_PYTHON_SRC_ROOT stays authoritative (a launcher that bound a
tree still has to match). Without one, accept the launcher cd'ing into the tree
or a command that names it, and only then fail. Unreadable paths count as no
evidence instead of throwing ENOENT. Invariant test covers all three, including
the foreign interpreter that still fails.
Both failures reached new assertions and reported nothing to inspect. The
source-origin check now names the cwd, HERMES_PYTHON_SRC_ROOT and the command it
saw beside the expected root, and the smoke writes desktop-backend-<phase>.log
BEFORE that assertion instead of after it, so the backend's identity is on disk
when the check fails.
waitForChatReady targeted a bare `[data-slot="composer-root"] textarea`, and the
composer renders an aria-hidden, sr-only <textarea> as assistant-ui's binding
primitive beside the real contentEditable editor. That primitive is "editable",
so it passed toBeEditable and then no trial click could ever hit it -- 225
retries ending in "element is outside of the viewport", which is what every
+desktop leg reported once the app stopped booting behind its onboarding
overlay (see the run with model.base_url in config.yaml: the app now resolves
http://127.0.0.1:<port>/v1 and renders the chat UI).
Require the visible editor, keep a textarea fallback only for a real input
(not aria-hidden, not sr-only), and let the failure explain itself: it reports
the composer-root/contenteditable counts and the composer's outerHTML instead of
a bare Playwright timeout.