Files
software-mansion__argent/packages/tool-server/test/electron-env.test.ts
Ignacy Łątka 2cefe552d3 fix(argent-lens): strip ELECTRON_RUN_AS_NODE from spawned Electron env (#471)
## Problem

QA report: **Argent Lens preview window never launches** —
`propose_variant` / `await_user_selection` stage variants correctly, but
the native window never appears and `await_user_selection` hangs. The
tool-server log shows the child crashing at launch:

```
dist/preview-window/main.cjs:28
import_electron.app.setName("Argent Lens");
                    ^
TypeError: Cannot read properties of undefined (reading 'setName')
    ...
    at c._load (node:electron/js2c/node_init:2:18095)
```

## Root cause (reproduced & proven)

`import_electron.app` being `undefined` while the JS still executes is
the exact signature of the Electron binary booting in **Node mode**. For
a downloaded, non-fused Electron the only trigger is
`ELECTRON_RUN_AS_NODE` in the environment. In Node mode
`require("electron")` returns the executable *path string* (not the
module), so `.app` is `undefined`.

The tool-server routinely runs under an Electron-based MCP host (VS
Code, Cursor, the Codex desktop app), which sets
`ELECTRON_RUN_AS_NODE=1` in the env of the Node children it spawns.
`createPreviewWindowManager.ensureOpen()` spread `{ ...process.env }`
straight into its Electron child, so the flag leaked in and the child
booted headless as Node → crash before any window.

Verified against the real Electron binary + the real `main.cjs`:

| Env | Result |
|-----|--------|
| `ELECTRON_RUN_AS_NODE=1` | Crashes at `main.cjs:28` — **byte-identical
to the report**, incl. the `node:electron/js2c/node_init` frame |
| unset | Boots as a real Electron GUI, gets past `setName`, only fails
later at `loadURL` |

The wrapper-bundle / code-signing theory in the report was a **red
herring** — the reporter never controlled for the spawn *environment*.
The same node-mode surfaces differently depending on whether the `.app`
wrapper built: plain path → `setName` crash (what they saw); wrapper
path → Node rejects the Electron-only `--no-sandbox` flag (`bad option:
--no-sandbox`). One fix cures both.

## Fix

Strip `ELECTRON_RUN_AS_NODE` before spawning, via a shared
`electronGuiChildEnv()` helper, at **both** GUI-Electron spawn sites:
- `preview-window.ts` — the Lens window (the reported bug).
- `boot-electron.ts` — the same latent flaw would boot a headless CDP
app with **no debug port**, timing out `boot-device --electronAppPath`.

## Verification

- **Root cause** reproduced byte-for-byte, both directions, against the
real binary.
- **End-to-end**: ran the real fixed `ensureOpen()` path via ts-node
with `ELECTRON_RUN_AS_NODE=1` leaked into the parent env against the
real Electron binary — child boots as a real GUI app (`VERDICT: PASS`).
Reverting the one-line fix flips it to `FAIL`, confirming the test
discriminates.
- **Unit tests**: added `electron-env.test.ts` (helper contract) + a
regression case in `preview-window-launch-failure.test.ts` asserting the
spawn env strips the var and keeps the override. 21/21 pass.
- `tsc --noEmit` (build) and `tsc --noEmit -p tsconfig.test.json` (CI
"Unit tests" gate) both clean; prettier + eslint clean on all changed
files.
2026-07-07 13:36:27 +02:00

71 lines
2.9 KiB
TypeScript

import { describe, it, expect, afterEach } from "vitest";
import { electronGuiChildEnv } from "../src/utils/electron-env";
describe("electronGuiChildEnv", () => {
const prev = process.env.ELECTRON_RUN_AS_NODE;
afterEach(() => {
if (prev === undefined) delete process.env.ELECTRON_RUN_AS_NODE;
else process.env.ELECTRON_RUN_AS_NODE = prev;
});
it("removes ELECTRON_RUN_AS_NODE inherited from the parent env", () => {
process.env.ELECTRON_RUN_AS_NODE = "1";
expect(electronGuiChildEnv().ELECTRON_RUN_AS_NODE).toBeUndefined();
});
it("is a no-op for that key when the parent never set it", () => {
delete process.env.ELECTRON_RUN_AS_NODE;
expect("ELECTRON_RUN_AS_NODE" in electronGuiChildEnv()).toBe(false);
});
it("layers overrides on top and never lets an override re-introduce Node mode", () => {
process.env.ELECTRON_RUN_AS_NODE = "1";
// The delete runs AFTER the overrides spread, so even an override that
// explicitly re-sets the flag cannot bring Node mode back. This asserts the
// ordering, not just the parent-env strip.
const env = electronGuiChildEnv({
ARGENT_PREVIEW_URL: "http://x/preview/",
ELECTRON_RUN_AS_NODE: "1",
});
expect(env.ARGENT_PREVIEW_URL).toBe("http://x/preview/");
expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined();
});
it("preserves other inherited env vars", () => {
process.env.__ARGENT_ENV_TEST__ = "keep-me";
try {
expect(electronGuiChildEnv().__ARGENT_ENV_TEST__).toBe("keep-me");
} finally {
delete process.env.__ARGENT_ENV_TEST__;
}
});
it("strips the flag regardless of key casing (Windows env is case-insensitive)", () => {
// On Windows the OS environment is case-insensitive, so a host may surface
// the flag under non-canonical casing (e.g. `Electron_Run_As_Node`). The
// plain object spread from process.env preserves that casing, and a
// case-sensitive delete of the canonical name would miss it — leaking the
// flag into the cross-platform boot-electron child, which then boots in Node
// mode with no CDP endpoint.
delete process.env.ELECTRON_RUN_AS_NODE;
process.env.Electron_Run_As_Node = "1";
try {
const env = electronGuiChildEnv();
const leaked = Object.keys(env).filter((k) => k.toLowerCase() === "electron_run_as_node");
expect(leaked).toEqual([]);
} finally {
delete process.env.Electron_Run_As_Node;
}
});
it("does not mutate the caller's process.env", () => {
// The flag must be stripped from a COPY only. Deleting it from the real
// process.env would permanently disable Node mode for the tool-server
// process itself — invisible to every other case here because each restores
// the global in afterEach.
process.env.ELECTRON_RUN_AS_NODE = "1";
electronGuiChildEnv();
expect(process.env.ELECTRON_RUN_AS_NODE).toBe("1");
});
});