DSH plugin: VS Code open-in-app 502 from a leaked env var
On Windows desktop, clicking "Open in app" on the right of a session header (slot conversation.session.header.utilities, registrant open-in-app) and choosing VS Code produces no workspace window: the launched process exits on its own after about 40 ms, the UI says "Failed to open, please retry", and the host route POST /open-in-app/open {"app":"vscode","path":"<workspace>"} returns 502 {"code":"launch-failed","message":"failed to launch vscode"}. The root cause is that the desktop host process itself carries ELECTRON_RUN_AS_NODE=1 (the desktop shell spawns the core host in Electron's Node mode) and the GUI launcher launchDetachedApp inherits it verbatim into VS Code (#8049). So Code.exe does not open the workspace; it runs require('H:\Harness') in Node mode, reports Cannot find module, exits 1, and the launcher judges it a failure. The easiest thing to step on is the fix itself: a global delete process.env.ELECTRON_RUN_AS_NODE in the host also breaks all command execution via the Windows Job runner — this article covers that counterexample along with the three correct fixes. The issue is still independently reproduced on 0.2.0-rc.2.
Triage first: understand what 502 and 200 each mean
In one sentence: this launcher judges both "the child exited 0" and "still running when the watch window expires" as success, so 200 does not mean the GUI actually opened; this case explicitly gets a 502, belonging to the "non-zero exit within the watch window" branch.
launchDetachedApp has two resolve conditions:
① the child process exits 0
② when the watch window launchWatchMs expires, the child is still running
Two corollaries follow, saving a lot of misjudgment:
- 200 ≠ the window appeared. It only means "judged as launched". Whether the GUI actually opened must be observed separately.
- 502 = a definite failure within the watch window. This case's 40 ms exit belongs to that branch — not a timeout, not queueing, not a permission dialog.
Triage can coarsely screen by elapsed time first:
| Symptom | Elapsed | Meaning |
|---|---|---|
Process exits instantly + 502 launch-failed | ~40–95 ms | This case: the target app starts in the wrong mode and ends immediately |
| 502 after a long unresponsive wait | near the watch window | more likely the target app is slow to start or popped a dialog |
| 200 but no window | normal return | judged as launched, but the GUI did not really open (a different class of problem) |
Reproducing it and the root cause: a three-step minimal reproduction and a five-step chain
A three-step minimal reproduction: visible without DSH
The reporter's strongest move was peeling the problem out of DSH and reproducing it on plain Windows — which directly rules out interference from "DSH-specific logic".
1. Minimal reproduction (plain Windows)
In an environment with the variable set:
set ELECTRON_RUN_AS_NODE=1
"%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe" "H:\Harness"
echo %ERRORLEVEL%
:: → 1, elapsed about 40 ms
stderr:
Error: Cannot find module 'H:\Harness'
at Module._resolveFilename (node:internal/modules/cjs/loader:1524:15)
...
at node:electron/js2c/node_init:2:18159
Note the stack shows node:electron/js2c/node_init — it is running Electron's Node initialization path, not VS Code's GUI initialization path. The argument H:\Harness was treated as a module name.
2. Environment on/off control
Using an archived script env-toggle-proof.mjs with the target defaulting to a newly created empty directory (to eliminate interference from an executable entry inside the directory), both runs use the same target and change only the environment variable:
node env-toggle-proof.mjs "%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe"
# ELECTRON_RUN_AS_NODE=1 -> exit 1 (FAILED) ← Node mode
# ELECTRON_RUN_AS_NODE unset -> exit 0 / still running at expiry ← launch judged OK
3. What the variable does (cross-confirmation)
"%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe" --version
:: → v24.18.1 ← Node's version, not VS Code's
"%LOCALAPPDATA%\Programs\Microsoft VS Code\bin\code.cmd" --version
:: → 1.137.0 / 645f29cc3176500b4b5762ba887cf2a7f0ffdf2c / x64
On the same machine and the same product, Code.exe reports the Node version while code.cmd reports the VS Code version — the most direct evidence that "the variable turns Code.exe into Node".
4. UI reproduction path
Open any workspace session on desktop → click "Open in app" on the right of the session header → choose VS Code from the menu.
The root-cause chain: five steps, none can be missing
In one sentence: the host really carries the variable → the launcher inherits it verbatim → the scrub function does not care about it → the non-zero exit is judged a failure → and VS Code happens to be resolved by the catalog as a "directly launch the exe" entry, so it is hit in full.
Step 1: the host process itself carries the variable (measured, not inferred)
The desktop shell spawns the core host in Electron's Node mode: the asar root lib/main.js's desktopNodeEnvironment(executable, bin, environment) returns
{ ...environment, ELECTRON_RUN_AS_NODE: "1", ... } // the comment is literally "Environment for a Node-mode child process"
Read inside the host process (the one listening on 127.0.0.1:19387, PID 70924 on that machine), the measured value was:
{ "pid": 70924, "action": "removed", "before": "1", "after": null }
(That record only proves the state at that moment, not in real time. The second verifier read an equally ELECTRON_RUN_AS_NODE=1-bearing environment from a tool child process in their desktop session and traced the source to desktopNodeEnvironment in apps/desktop/src/node-environment.ts.)
Step 2: the GUI launcher inherits it verbatim
@deepseek-ai/dsh-host-open-in-app's runtime entry is lib/index.js (package.json's exports["."].default); launchDetachedApp is inlined in that file at line 348, and line 8 imports scrubbedParentEnv:
const env = { ...scrubbedParentEnv(), ...options.env };
The result of scrubbedParentEnv() is spread in wholesale, and it still carries ELECTRON_RUN_AS_NODE.
Step 3: the scrub function does not strip the Electron mode variable
@deepseek-ai/dsh-subprocess/lib/index.js:
// line 32
const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i;
// line 52: the loop also drops the DSH_* prefix and handles proxy variables
ELECTRON_RUN_AS_NODE contains no sensitive keyword and is not a DSH_* prefix, so it passes through the scrub layer untouched.
Step 4: a launch failure is judged an error
In the same function, child.on('exit', ...) rejects on a non-zero exit within the watch window; the open route then does:
sendJson(res, 502, { code: "launch-failed", ... })
Step 5: why VS Code is hit in full
On that machine VS Code is resolved by the catalog as:
spec(appPaths('Code.exe'), installRecord('Microsoft Visual Studio Code', 'Code.exe'), file([...]))
That is, launching the exe directly (not going through a wrapper like code.cmd that handles the environment itself). On that machine HKCU\...\App Paths\Code.exe exists and Code.exe exists — the resolution itself is healthy; the problem is not resolution but the variable inherited at launch.
The fix boundary and three fixes: scrub-then-merge, runner declaration, four assertions
The fix boundary: why you cannot "just delete the variable in the host"
This is the most important section of the article. The reporter's initial workaround was "delete process.env.ELECTRON_RUN_AS_NODE in the host", and it broke command execution via the Windows Job runner.
Scope caveat: the following conclusion applies to this kind of Electron Node-mode host + the current Windows Job runner implementation; it does not cover directly invoking other executables, other providers, or different host startup methods.
The source chain (each spot verified):
| Package · file | Location | Content |
|---|---|---|
dsh-subprocess-local/lib/runner-launch-B2zsQ1Dz.js | 1311–1316 | the Windows runner launches with process.execPath (i.e. the desktop Electron executable) + the runner script |
| same | 694–695 | childEnv() → scrubbedParentEnv() |
| same | 1343–1345 | runnerEnvironment() takes childEnv() and does not explicitly put ELECTRON_RUN_AS_NODE back |
dsh-subprocess-local/lib/index.js | 601 | env: runnerEnvironment(WINDOWS_RUNNER_SELECTION, invocation) |
| same | 629 | subprocess-local: Windows Job runner exited with ${status} before proving its managed range empty |
Measured consequence (within the same session after deleting the variable): both the pwsh tool and the ripgrep calls inside the grep tool via that runner failed, both returning the line at 629 above — exit code 0, no leftover process, consistent with "the runner starts via the GUI entry and exits without fulfilling the IPC protocol". (Other invocations were not individually verified, so no claim is made about them.)
The second verifier added the mechanism side of the explanation: the Windows sandbox runner's argv prefix is [process.execPath, runner.js] (windowsAclRunnerInvocation() in packages/sandbox/sandbox-local/src/index.ts), and ConfinedArgv carries no environment — on desktop it depends on inheriting this variable to start in Node mode. The repository already has a precedent for "declaring per boundary": ptc-runtime-node strips the variable for its own child processes in packages/ptc-runtime/ptc-runtime-node/src/index.ts (with a matching assertion in host-failures.spec.ts).
Conclusion: this variable is a necessity for "the host running a Node runner", and can only be cleared at the boundary of "the target app being launched".
Fix one (the direct fix for this issue): scrub first, merge second, at the launch boundary
Key point: clear the inherited value first, then merge the adapter's explicit value — reverse the order and you will also strip a deliberate opt-in like the GitHub Desktop cli.js adapter.
launchDetachedApp (lib/index.js:348, and its source file in sync):
const inherited = scrubbedParentEnv();
for (const key of Object.keys(inherited)) {
if (key.toUpperCase() === 'ELECTRON_RUN_AS_NODE') delete inherited[key];
}
const env = { ...inherited, ...options.env };
All three details are intentional:
- Compare via
toUpperCase()— Windows environment keys are case-insensitive, so comparing the original name directly can miss spellings likeelectron_run_as_node. - Only delete the "inherited" copy —
options.envis explicitly declared by the adapter, and...options.envis merged after, so GitHub Desktopcli.js's Node-mode opt-in is unaffected. process.envitself is not modified — the host's own environment is unchanged, so thepwsh/greprunner chain is unharmed.
On where to change it:
lib/index.jsandlib/types/resolver.jsare generated artifacts (the latter a copy of the same implementation). They are cited only to locate the affected artifacts; the correct answer is to change the source and rebuild, not to ask maintainers to hand-maintain two copies of generated code.
Fix two (independent hardening): make the internal runner declare Node mode explicitly
Even after fix one lands, it is still advisable to explicitly provide this variable in runnerEnvironment() for the "start a Node runner with the Electron executable" path — so the runner no longer depends on "happening to have inherited it".
But three boundaries must be noted, or new problems will be introduced:
- Windows environment keys are case-insensitive; when explicitly providing the value, be careful not to create duplicate keys;
- Confirm this bootstrap setting is not carried into the target app's environment (it should affect only the runner);
runnerEnvironment()also serves other launch paths, so this Windows counterexample alone is not enough to declare "unconditional value provision" fully verified.
Fix three (regression requirements): ship four assertions with the fix
It is advisable to make these four the acceptance criteria for the fix, and the fourth is precisely the regression test for this counterexample:
- The GUI child process environment is scrubbed — the launcher no longer receives this variable;
- The adapter's explicit opt-in is preserved — the GitHub Desktop
cli.jspath still runs in Node mode; - The host's own environment is not modified —
process.envdoes not change because an app was launched; - When the host lacks this variable, the runner's handshake and the target command still succeed (this covers whether fix two is truly self-consistent).
Someone has implemented and verified fix one locally: vitest run packages/host/open-in-app/tests (4 files / 64 cases) passes, tsc -b passes for that package, oxlint (with type-aware rules) reports 0 error / 0 warning; and new regression assertions cover "inherited values in both casings do not reach the launched app, while the adapter's explicitly declared value still does".
Do not paper over it by tuning launchWatchMs
In one sentence: enlarging the watch window only covers more "late-occurring failures" and cannot fix an exit caused by Node mode; shortening it may judge success early, which likewise does not mean the GUI opened.
This case's failure happens at ~42 ms, which any reasonable watch window covers — the problem is not "judged too early" but "started in the wrong mode". Tuning the window neither cures the disease nor fails to pollute the semantics of 200/502.
Scope and current state: who is affected, and 0.2.0-rc.2 still reproduces
Scope: who is affected, who is exempt, what is unverified
Per the narrowed wording in the discussion:
- Locally measured: VS Code always fails on Windows desktop.
- Possibly affected in mechanism (not individually measured): any Electron app that is "launched directly by the catalog as
App Paths\*.exeand that itself respects this variable" — VS Code Insiders, Cursor, and Windsurf in the catalog are of this shape. - Exempt in mechanism: an Electron app that has the
runAsNodefuse disabled ignores this variable (Electron's official docs state this), so no claim of "all Electron apps fail" is made. - Unverified: non-Electron entries (JetBrains, Sublime, Windows Terminal,
shell-openviaexplorer.exe) are not directly affected by this mechanism but were not individually measured; macOS / Linux were not verified.
Current state: still reproduces on 0.2.0-rc.2
Two verifiers on different machines confirmed the same thing:
- One ran DSH Desktop 0.2.0-rc.2 on Windows 11: the host process has
process.env.ELECTRON_RUN_AS_NODE === '1';launchDetachedAppis stillenv: { ...scrubbedParentEnv(), ...options.env };scrubbedParentEnvstill filters only/KEY|PASSWORD|SECRET|TOKEN/iandDSH_*;Code.exe <dir>still ends withCannot find module '<dir>'and exit code 1 → 502launch-failed; clearing the variable with the same command on the same machine gives exit 0 and the window opens normally. So the issue is not fixed with 0.2.0-rc.2. - The other independently verified on another machine with the same conclusion, and additionally provided a control launching
Code.exe <empty dir>with spawn parameters identical item-by-item tolaunchDetachedApp(detached: true,stdio: 'ignore',env = scrubbedParentEnv() + adapter env): inheriting the variable →exit=1, about 95 ms, stderrCannot find module '<dir>'; deleting only this one variable, touching nothing else in the environment → still running after 1 s, counted as launched per the product semantics, workspace window opens normally.
Appendix: why the "external wrapper" road does not work
Another workaround the reporter tried was pointing HKCU\...\App Paths\Code.exe at a wrapper that "clears the variable then starts the real Code.exe" — every local implementation failed, and that registry entry was restored to the real Code.exe (with a backup on file).
Three independently checkable facts:
- A
.NET Process.Startdirectly in the wrapper fails: even with the wrapper having clearedELECTRON_RUN_AS_NODE, all four configurations ofProcess.Start(Code.exe, [dir])(UseShellExecutetrue/false ×CreateNoWindowtrue/false, including settingWorkingDirectory) still make Code exit0x80000003(STATUS_BREAKPOINT), with no workspace window observed. - Adding a new console does not solve it either: switching to
CreateProcessW+CREATE_UNICODE_ENVIRONMENT | CREATE_NEW_CONSOLEwithbInheritHandles = FALSE, launching an intermediatenode.exethat then detachedly launchesCode.exe, tried both hidden and visible consoles, still exits0x80000003. - A successful control exists on the same machine at the same time: launching directly from
pwshvianode(with the child environment a copy with the variable removed),cmd /c start "" Code.exe <dir>, andcode.cmd <dir>all succeed.
The difference between the wrapper chain and the successful controls has not been located (candidates: Job Object, inherited handle table, window station/desktop, an early Chromium CHECK), but two things are clear: the wrapper's 0x80000003 is not caused by "still being in Node mode" (it is a different phenomenon from Node mode's exit 1); and without an exception stack, no specific trigger point should be named.
One possibly related new data point: a third verifier found that launching Code.exe the same way (with the variable already deleted) inside a DSH restricted session (a process constrained by the tool sandbox) reliably exits 0x80000003 (about 90 ms, empty stderr) — when the parent process is already constrained by a Job / restricted token, Chromium's early initialization fails; the same command in an unrestricted process works immediately. This matches the wrapper chain's phenomenon, hinting the difference may come from the process constraints of one link in the chain, not from the variable itself (an observation only; no trigger point is named).
What this means for upstream: this set of experiments shows that "an external wrapper as a fallback" is not viable on this machine, but does not constitute a rebuttal of the root-cause judgment — the main basis remains the "environment on/off control" and the launcher source; the formal fix should live at the application startup boundary, clearing only the environment the target child process inherits.
Troubleshooting notes
- Look at elapsed time first. Exiting within 40–95 ms and returning 502
launch-failedbasically pins down "the target app starts in the wrong mode and ends immediately". - Do not read 200 as "the window opened". The launcher counts both "exit 0" and "still running at watch expiry" as success; its semantics are broader than the words suggest.
- Use
--versionto tell whom the target app is running as.Code.exe --versionprinting the Node version whilecode.cmd --versionprints the VS Code version is the cheapest criterion. - Peel the problem out of DSH and reproduce it. On plain Windows,
set ELECTRON_RUN_AS_NODE=1then launchCode.exe <dir>and you seeCannot find moduledirectly. - Do environment controls as "same target, change one variable only". Use a newly created empty directory to avoid interference from an executable entry inside the directory.
- Never globally delete this variable in the host. It breaks command execution via the Windows Job runner (
pwshand the ripgrep insidegrepwill both fail). - Fix at the application startup boundary, and "scrub inherited, then merge explicit". Reversing the order strips a deliberate opt-in like GitHub Desktop
cli.js. - Change the source, not the generated artifacts.
lib/index.jsandlib/types/resolver.jsare build outputs; hand-maintaining two copies will drift sooner or later. - Tuning the watch window is not a fix. It only affects "when it is judged", not "in what mode it starts".
- Include the fuse condition when defining the scope. Electron apps with the
runAsNodefuse disabled are exempt, so the conclusion must read "depends on configuration", not "all Electron apps fail".
Sources
- #8049 — [Windows] The desktop host passes ELECTRON_RUN_AS_NODE to VS Code, breaking "Open in app" on a workspace (502 launch-failed) (the five-step root-cause chain, the three-step minimal reproduction, the fix-boundary counterexample, the wrapper experiments, and the 0.2.0-rc.2 re-verification and local patch verification all come from this discussion)
- deepseek-ai/deepseek-harness (source locations such as
dsh-host-open-in-app/lib/index.js,dsh-subprocess/lib/index.js,dsh-subprocess-local/lib/runner-launch-*.js,packages/sandbox/sandbox-local/src/index.ts,packages/ptc-runtime/ptc-runtime-node/src/index.ts) - Electron environment variables (ELECTRON_RUN_AS_NODE)
The genuinely universal lesson from this case is: an environment switch meant to "run Node code with the same Electron executable" can, several layers down, turn the GUI app a user just clicked into a Node process. If you also write host code that spawns child processes, do two things now: explicitly scrub every environment variable that should "only hold for a Node-mode child" (not just ELECTRON_RUN_AS_NODE) at the boundary of launching third-party apps, and scrub inherited first, merge explicit second; and add a regression test for "the runner still works when the host lacks this variable" — otherwise you may well break command execution on the very day you fix "open in VS Code". When diagnosing this kind of "open failure", first align the field with the installed list and system logs in DSH Plugin Hub.

FAQ
Look at two traits: one, it is **extremely fast** (exiting after about 40–95 ms, not a long wait); two, the host route POST /open-in-app/open {"app":"vscode","path":"<workspace>"} returns **502** with body {"code":"launch-failed","message":"failed to launch vscode"}. To confirm one step further, run "%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe" "<dir>" directly in an environment with ELECTRON_RUN_AS_NODE=1 — if stderr is Error: Cannot find module '<dir>', it is the same thing.
Because with this variable set, Code.exe is not running VS Code at all; it is running the **embedded Node**. ELECTRON_RUN_AS_NODE=1 makes an Electron executable start in pure Node mode, so it treats the path argument that follows as **a module to load**, and --version naturally prints Node's version. That is exactly how "open this workspace" fails: VS Code does not receive an "open this directory" intent; it tries to require('<dir>'). The control is code.cmd --version, which prints 1.137.0 / <commit> / x64.
**Absolutely not.** The reporter tried delete process.env.ELECTRON_RUN_AS_NODE in the host and **it broke command execution via the Windows Job runner** — within the same session both the pwsh tool and the ripgrep calls inside the grep tool failed with subprocess-local: Windows Job runner exited with exit code 0 before proving its managed range empty. The reason is that the Windows runner launches with process.execPath (the desktop Electron executable) plus the runner script and depends on inheriting this variable to enter Node mode, while ConfinedArgv carries no environment. The fix must live at the **application startup boundary**, clearing only the environment the target child process inherits.
Per the narrowed wording in that discussion: locally measured, **VS Code always fails on Windows desktop**; in mechanism, **possibly** affected too are Electron apps that are launched directly by the catalog as App Paths\*.exe and that themselves respect this variable — such as VS Code Insiders, Cursor, and Windsurf in the same catalog. In mechanism, **exempt** are Electron apps that have the runAsNode fuse disabled (Electron's docs state that with the fuse off it ignores this variable). Non-Electron entries (JetBrains, Sublime, Windows Terminal, and shell-open going through explorer.exe) are not directly affected by this mechanism but were not individually measured; macOS / Linux were not verified.
Per the re-verification in the discussion, **0.2.0-rc.2 still reproduces it**, with the chain identical to the first post. Someone implemented and verified fix one locally: in launchDetachedApp when building the launch environment, first scrubbedParentEnv(), then case-insensitively strip ELECTRON_RUN_AS_NODE, and finally layer the adapter's explicit env on top. Verification: vitest run packages/host/open-in-app/tests (4 files / 64 cases) passes, tsc -b passes for that package, oxlint reports 0 error / 0 warning, plus regression assertions that "inherited values in both casings cannot reach the launched app, while the adapter's explicitly declared value still can".
Related Terms
- ELECTRON_RUN_AS_NODE
- An Electron environment variable switch: when set to `1`, the Electron executable does not start the GUI but starts as a pure Node.js runtime. Desktop apps commonly use it to "run Node code with the same executable". The danger is that child processes inherit it verbatim — turning a GUI app into Node mode as well, so the target app treats its path argument as a module to load.— https://github.com/deepseek-ai/deepseek-harness/discussions/8049
- launchDetachedApp / launchWatchMs
- The detached launcher at `@deepseek-ai/dsh-host-open-in-app/lib/index.js:348`. It has two resolve conditions: ① the child process **exits 0**; ② when the watch window `launchWatchMs` expires the child is **still running**. So an HTTP **200 only means "judged as launched", not that the GUI actually opened**; this case explicitly receives a 502. Tuning `launchWatchMs` cannot fix an exit caused by Node mode.— https://github.com/deepseek-ai/deepseek-harness/discussions/8049
- scrubbedParentEnv()
- A "scrub the parent environment" function provided by `@deepseek-ai/dsh-subprocess`, used to construct the environment for child processes. It drops sensitive entries by `SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i` and additionally drops the `DSH_*` prefix and handles proxy variables — but it does **not** strip `ELECTRON_RUN_AS_NODE`. This is exactly why the variable leaks all the way from the host to the GUI app.— https://github.com/deepseek-ai/deepseek-harness/discussions/8049
- runAsNode fuse
- An Electron fuse settable at packaging time. When disabled, that app **ignores** `ELECTRON_RUN_AS_NODE`, so it will not enter Node mode merely because it inherited the variable. This is why "not every Electron app necessarily fails", and it is a reminder that the affected scope must be defined by fuse configuration rather than blanket statements.— https://github.com/deepseek-ai/deepseek-harness/discussions/8049