Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/specs/standalone.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,20 @@ because the sidecar and the `dor` CLI have opposite console requirements:
`DORMOUSE_NODE` at it. `dor` always runs inside an existing pseudo-console, so
that copy can never cause a stray window.

- **Never leave the GUI node's directory on a pane's PATH.** `cargo run` puts it
there for DLL resolution, so a bare `node` in a dev pane would get a
console-less one and fail silently in both directions (rationale).
`start_sidecar` passes it as `DORMOUSE_GUI_NODE_DIR`; the sidecar strips it
from each pane's PATH.

The byte-flip lives in `standalone/src-tauri/src/pe_subsystem.rs`, shared with
`build.rs`, so the load-bearing PE offsets are in one place; the mechanism is in
the comments at `force_windows_gui_subsystem` and `resolve_dor_node_path`.

Source of truth: `withoutGuiNodeDir` in `standalone/sidecar/pty-core.js`,
pinned by `resolveSpawnConfig drops the GUI node directory from a pane PATH on
win32` in `standalone/sidecar/pty-core.test.js`.

## Sidecar lifecycle

Source of truth: `standalone/sidecar/main.js`.
Expand Down
4 changes: 4 additions & 0 deletions docs/specs/standalone.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

**The two Windows Node variants.** `CREATE_NO_WINDOW`, `DETACHED_PROCESS`, and `STARTUPINFO` hiding all failed to suppress Windows 11's DefTerm handoff from a GUI parent (verified 2026-08): Windows launches Windows Terminal to host the console-subsystem child, flashing a stray WT window behind Dormouse. Should a current spawn-time option suppress it, both variants collapse back to the stock console-subsystem Node under `DORMOUSE_NODE`. `dor`'s opposite requirement comes from running inside a shell's ConPTY, where its stdout/stderr are console handles rather than pipes.

**What the GUI node's directory on PATH cost, and why only dev builds.** Measured in a dev pane (Windows 11 25H2, 2026-09): `node` resolved to the patched `node.exe` in the dev app's `target/debug` at PATH position 4 — `isTTY` undefined, no `setRawMode`, PE subsystem 2 — ahead of pnpm's console-subsystem `node.EXE` at position 8 and the developer's own at 35, both reporting `isTTY` true. "Silent in both directions" is literal: stdin is at EOF so a reader dies at startup, and stdout is unattached so it cannot report why — writing the values to a file was the only way to observe them. The installed app was ruled out rather than assumed: Dormouse Terminal 0.11.0 ships the same GUI-subsystem `node.exe`, but neither its install directory nor any `target/debug` is in the persistent user or machine PATH a shortcut-launched app inherits, and panes add only `DORMOUSE_CLI_BIN` — so `node` there was already the developer's own, and the strip makes a dev pane match an installed one.

**Why the host names the directory instead of the sidecar deriving it.** `path.dirname(process.execPath)` was the first attempt and is wrong twice over: `standalone/scripts/dev-agent-browser.mjs` runs the sidecar under the developer's own node, so the strip would have removed the node it exists to protect, and the same module is the VS Code pty host (`vscode-ext/src/pty-host.js`), where that directory can be `/usr/bin` — dropping it from a pane's PATH would be destructive. Hence an explicit `DORMOUSE_GUI_NODE_DIR` from the side that patched the binary, and a win32-only strip.

## Boot sequence

**Why the peer-surface responder must follow `init()`.** Installed first, its seeding `status` command sits unanswered with no retry — nothing carries the answer back until the adapter has registered its listeners.
Expand Down
2 changes: 1 addition & 1 deletion scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"docs/specs/security-supply-chain.md": 1250,
"docs/specs/security.md": 1900,
"docs/specs/shortcuts.md": 1150,
"docs/specs/standalone.md": 10600,
"docs/specs/standalone.md": 10700,
"docs/specs/terminal-context.md": 1050,
"docs/specs/terminal-escapes.md": 3850,
"docs/specs/terminal-state.md": 2400,
Expand Down
23 changes: 22 additions & 1 deletion standalone/sidecar/pty-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,30 @@ function withPrependedPath(env, dir, platform = process.platform) {
function withoutInternalDormouseEnv(env) {
const next = { ...env };
delete next.DORMOUSE_CLI_BIN;
delete next.DORMOUSE_GUI_NODE_DIR;
delete next.DORMOUSE_SHELL_INTEGRATION_DIR;
return next;
}

// Win32 only. The bundled node.exe is patched to the GUI subsystem
// (docs/specs/standalone.md -> "Windows node subsystem"), and a GUI-subsystem
// binary does not attach to an inherited console: run it inside a pane's
// ConPTY and stdin is already at EOF, there is no `setRawMode`, and output is
// dropped. `cargo run` leaves that node.exe's directory on the dev app's PATH
// for DLL resolution and panes inherit the app's env, so a bare `node` in a
// pane would find it. Never derive the directory from `process.execPath` —
// this module is also the VS Code pty host, and the agent-browser harness runs
// the sidecar under the developer's node (rationale).
function withoutGuiNodeDir(env, platform = process.platform) {
const key = pathEnvKey(env);
if (platform !== 'win32' || !env.DORMOUSE_GUI_NODE_DIR || !env[key]) return env;
// Entries compare case-insensitively, `/` as `\`, trailing separators ignored.
const norm = (entry) => String(entry).replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase();
const target = norm(env.DORMOUSE_GUI_NODE_DIR);
const kept = env[key].split(';').filter((entry) => norm(entry) !== target);
return { ...env, [key]: kept.join(';') };
}

// Win32 only. Git Bash / MSYS `/etc/profile` reconstructs PATH from an
// exported `ORIGINAL_PATH` whenever that variable is already set, and only
// captures the live PATH into it when it is unset. `ORIGINAL_PATH` leaks into
Expand Down Expand Up @@ -294,8 +314,9 @@ function resolveSpawnConfig(options, runtime = {}) {
// Resolve the integration dir from the original env before the internal
// DORMOUSE_* vars are stripped below.
const integrationDir = resolveShellIntegrationDir(env, runtime);
const paneEnv = withoutGuiNodeDir(env, platform);
const envWithCliPath = withoutInheritedMsysOriginalPath(
withoutInternalDormouseEnv(withPrependedPath(env, env.DORMOUSE_CLI_BIN, platform)),
withoutInternalDormouseEnv(withPrependedPath(paneEnv, env.DORMOUSE_CLI_BIN, platform)),
platform,
);
const childEnv = {
Expand Down
56 changes: 56 additions & 0 deletions standalone/sidecar/pty-core.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,62 @@ test('resolveSpawnConfig keeps ORIGINAL_PATH on non-win32', () => {
assert.equal(config.env.ORIGINAL_PATH, '/whatever');
});

test('resolveSpawnConfig drops the GUI node directory from a pane PATH on win32', () => {
// `cargo run` puts the dev app's target dir on PATH, and the node.exe there
// is patched to the GUI subsystem, so it never attaches to the pane's
// inherited console. The target entry is spelled with `/`, a trailing
// separator and a different case, pinning that matching ignores all three;
// `debug\deps` is a sibling, pinning that this is not prefix matching.
const config = resolveSpawnConfig(
{ surfaceId: 'pane-1' },
{
platform: 'win32',
env: {
Path: [
'C:/Repo/Target/Debug\\',
'C:\\repo\\target\\debug\\deps',
'C:\\Windows\\System32',
].join(';'),
DORMOUSE_GUI_NODE_DIR: 'C:\\repo\\target\\debug',
DORMOUSE_CLI_BIN: 'C:\\Dormouse\\dor-cli\\bin',
},
osModule: {
homedir: () => 'C:\\Users\\tester',
tmpdir: () => 'C:\\Temp',
},
},
);

assert.equal(
config.env.Path,
['C:\\Dormouse\\dor-cli\\bin', 'C:\\repo\\target\\debug\\deps', 'C:\\Windows\\System32'].join(';'),
);
assert.equal(config.env.DORMOUSE_GUI_NODE_DIR, undefined);
});

test('resolveSpawnConfig keeps the GUI node directory on non-win32', () => {
// Only the standalone host sets the variable, always to the bundled node's
// directory — but on macOS and Linux the app's directory is never on PATH,
// so the strip would be a no-op at best and is gated off.
const config = resolveSpawnConfig(
{ surfaceId: 'pane-1' },
{
platform: 'linux',
env: {
PATH: '/usr/bin:/bin',
DORMOUSE_GUI_NODE_DIR: '/usr/bin',
},
osModule: {
homedir: () => '/home/tester',
tmpdir: () => '/tmp/fallback',
},
},
);

assert.equal(config.env.PATH, '/usr/bin:/bin');
assert.equal(config.env.DORMOUSE_GUI_NODE_DIR, undefined);
});

test('withPrependedPath preserves Windows Path casing', () => {
const env = withPrependedPath(
{ Path: 'C:\\Windows\\System32' },
Expand Down
4 changes: 4 additions & 0 deletions standalone/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3974,6 +3974,9 @@ fn start_sidecar(app: &AppHandle) -> Result<SidecarState, String> {
let node_path = resolve_node_binary_path()?;
let dor_cli_paths = resolve_dor_cli_paths(&sidecar_path, manifest_dir);
let dor_node_path = resolve_dor_node_path(&node_path, app);
// Our own directory, which holds the GUI-subsystem node. The sidecar drops
// it from every pane's PATH (standalone.md -> "Windows node subsystem").
let gui_node_dir = node_path.parent().unwrap_or(Path::new(""));
let dor_control_token = dor_control_token();
let state_dir = burrow_state_dir(app);
let recovery_dir = recovery_state_dir(app);
Expand Down Expand Up @@ -4004,6 +4007,7 @@ fn start_sidecar(app: &AppHandle) -> Result<SidecarState, String> {
c.arg(&sidecar_path)
.env("DORMOUSE_HOST", "standalone")
.env("DORMOUSE_NODE", &dor_node_path)
.env("DORMOUSE_GUI_NODE_DIR", &gui_node_dir)
.env("DORMOUSE_CLI_BIN", &dor_cli_paths.bin_dir)
.env("DORMOUSE_CLI_JS", &dor_cli_paths.entrypoint)
.env("DORMOUSE_CONTROL_TOKEN", &dor_control_token)
Expand Down
Loading