Compare commits

...
3 changed files with 490 additions and 52 deletions

No files matched your search

+193 -26
View File
@@ -8,7 +8,7 @@
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { execFileSync, spawnSync } = require("node:child_process");
const EXEC_OPTS = { encoding: "utf8", timeout: 5000, windowsHide: true };
@@ -88,9 +88,189 @@ function regEnumSubkeys(keyPath) {
return [];
}
/**
* Absolute path prefix of the per-user Windows App Execution Aliases
* directory (`%LOCALAPPDATA%\Microsoft\WindowsApps`), lower-cased and
* terminated with a path separator — or `null` when it cannot be
* determined (non-Windows platforms, missing LOCALAPPDATA).
*
* @param {NodeJS.ProcessEnv} [env] Environment to read LOCALAPPDATA from.
* @param {string} [platform] Platform override (tests).
* @returns {string|null}
*/
function windowsAppsAliasPrefix(env = process.env, platform = process.platform) {
if (platform !== "win32") return null;
const localAppData = env.LOCALAPPDATA || "";
if (!localAppData) return null;
// Win32 paths always use backslashes; build the prefix explicitly so the
// check does not depend on the path module of the host OS (tests).
return (
localAppData.replace(/[\\/]+$/, "") +
"\\Microsoft\\WindowsApps\\"
).toLowerCase();
}
/**
* Check whether a candidate path lives in the per-user Windows App
* Execution Aliases directory (e.g. the MSIX/Store `pwsh.exe` alias).
*
* @param {string} candidate Candidate executable path.
* @param {NodeJS.ProcessEnv} [env] Environment to read LOCALAPPDATA from.
* @param {string} [platform] Platform override (tests).
* @returns {boolean}
*/
function isWindowsAppExecutionAlias(candidate, env = process.env, platform) {
if (!candidate || typeof candidate !== "string") return false;
const prefix = windowsAppsAliasPrefix(env, platform);
if (!prefix) return false;
return (
candidate.replace(/\//g, "\\").toLowerCase().startsWith(prefix)
);
}
/**
* Arguments that make a PowerShell-family executable exit immediately
* without loading the user's profile. The probe only needs to prove that
* CreateProcess can resolve the AppExecLink — running the user's normal
* shell initialization would be a surprising side effect (profiles may
* write, hit the network, or block on input, which would stall the main
* process until the probe timeout and then kill the profile midway).
*
* An empty list means "do not probe": there is no argument set that
* guarantees the candidate exits immediately without side effects, so
* callers must never spawn it. See `probeAliasLaunchable`.
*
* @param {string} candidate Executable path.
* @returns {string[]} Extra arguments, or an empty list when the candidate
* must not be probed.
*/
function probeArgsFor(candidate) {
const base = path.win32.basename(String(candidate || "")).toLowerCase();
if (base === "pwsh.exe" || base === "powershell.exe" || base === "pwsh" || base === "powershell") {
return ["-NoProfile", "-NonInteractive", "-Command", "exit"];
}
return [];
}
/**
* Probe whether a WindowsApps alias candidate is actually launchable.
*
* Node cannot read the reparse tag of an AppExecLink, so `statSync` failing
* while `lstatSync` succeeds matches both a live alias and a stale or broken
* reparse point (e.g. after an incomplete package removal). The only reliable
* pure-JS check is to let CreateProcess resolve it: a live alias spawns, a
* stale one fails to spawn.
*
* Only the PowerShell family is probed. `probeArgsFor()` returns
* `-NoProfile -NonInteractive -Command exit` for those, so the probe is
* guaranteed to exit immediately without running the user's profile.
* Any other WindowsApps alias (e.g. `python.exe`, `wt.exe`) is NOT probed:
* spawning an arbitrary alias with no arguments can open a GUI or the
* Microsoft Store, or block the Electron main process until the probe
* timeout. Unknown aliases are therefore treated as not launchable.
*
* @param {string} candidate WindowsApps alias path (AppExecLink reparse point).
* @param {{spawnSync?: typeof import("node:child_process").spawnSync}} [deps] Injectable for tests.
* @returns {boolean} True when the process could be started.
*/
function probeAliasLaunchable(candidate, deps = {}) {
const spawn = deps.spawnSync || spawnSync;
// Restrict the probe to candidates we know launch headlessly and exit
// immediately; anything else would be spawned with no arguments.
const args = probeArgsFor(candidate);
if (args.length === 0) return false;
try {
const result = spawn(candidate, args, {
timeout: 5000,
windowsHide: true,
input: "",
});
if (result.error) {
// ETIMEDOUT means the process ran until we killed it — it launched.
return result.error.code === "ETIMEDOUT";
}
return true;
} catch (_err) {
return false;
}
}
/**
* Pick the first launchable executable path from an ordered candidate list
* (e.g. the lines `where.exe <name>` printed, in PATH order).
*
* Regular files are verified with `fs.existsSync()`. Candidates inside the
* WindowsApps alias directory need special handling: MSIX/Store packages
* expose their executables as AppExecLink reparse points, which Node's
* `fs.statSync()`/`fs.existsSync()` cannot resolve (they fail with EACCES,
* see nodejs/node#36790) even though CreateProcess launches them fine via
* the full alias path. For those, fall back to `fs.lstatSync()`, which
* still sees the reparse point itself, and then confirm launchability with
* a spawn probe — `lstatSync` alone cannot tell a live alias from a stale
* reparse point. Only PowerShell-family aliases are probed (see
* `probeAliasLaunchable`); other aliases are never spawned and are rejected.
* A zero-byte plain file in the same directory (e.g. a
* disabled alias) stats cleanly and is rejected.
*
* Non-alias candidates win over alias candidates so a real install (MSI,
* zip, Chocolatey…) is still preferred when both are on PATH. The launch
* probe for alias candidates is deferred until after the full scan: the
* probe spawns a process (up to a five-second timeout), so it must not run
* while a later regular candidate may make the result moot.
*
* @param {string[]} candidates Ordered candidate paths.
* @param {{fs?: typeof import("node:fs"), env?: NodeJS.ProcessEnv, platform?: string, probe?: (candidate: string) => boolean}} [deps] Injectable for tests.
* @returns {string|null} First launchable path, or `null`.
*/
function selectExecutableCandidate(candidates, deps = {}) {
const fsMod = deps.fs || fs;
const env = deps.env || process.env;
const platform = deps.platform || process.platform;
const probe = deps.probe || probeAliasLaunchable;
let aliasFallback = null;
for (const candidate of candidates) {
if (!candidate) continue;
if (isWindowsAppExecutionAlias(candidate, env, platform)) {
if (aliasFallback) continue;
// existsSync() is false for AppExecLink reparse points, so a plain
// existence check would filter the alias out. Verify via statSync:
// success means a plain (non-launchable) stub; failure means a
// reparse point, confirmed with lstatSync.
try {
fsMod.statSync(candidate);
continue; // Plain zero-byte stub — not an AppExecLink.
} catch (_statErr) {
// Expected for AppExecLink reparse points.
}
try {
fsMod.lstatSync(candidate);
} catch (_lstatErr) {
continue;
}
// Record the reparse-point alias as the fallback candidate, but do
// NOT probe yet — the probe spawns a process and must be deferred
// until we know no regular candidate exists (see after the loop).
aliasFallback = candidate;
continue;
}
if (!fsMod.existsSync(candidate)) continue;
return candidate;
}
// No regular candidate won, so the recorded alias fallback is the last
// option. lstatSync cannot distinguish a live AppExecLink from a stale or
// broken one (both fail statSync with EACCES), so confirm launchability
// with the spawn probe before accepting it.
if (aliasFallback && !probe(aliasFallback)) return null;
return aliasFallback;
}
/**
* Locate an executable on the system PATH using `where.exe`.
* Returns the first valid, non-alias path, or `null` if not found.
* Returns the first valid, launchable path, or `null` if not found.
*
* @param {string} name Executable name, e.g. "pwsh"
* @returns {string|null}
@@ -98,30 +278,12 @@ function regEnumSubkeys(keyPath) {
function findExecutableOnPath(name) {
try {
const result = execFileSync("where.exe", [name], EXEC_OPTS);
const candidates = result
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
for (const candidate of candidates) {
if (!fs.existsSync(candidate)) continue;
// Skip Windows App Execution Aliases (WindowsApps zero-byte stubs).
try {
const localAppData = (process.env.LOCALAPPDATA || "").toLowerCase();
if (
localAppData &&
candidate.toLowerCase().startsWith(
path.join(localAppData, "Microsoft", "WindowsApps").toLowerCase() +
path.sep,
)
) {
continue;
}
} catch (_e) {
// Ignore — just use the candidate.
}
return candidate;
}
return selectExecutableCandidate(
result
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean),
);
} catch (_err) {
// Not found on PATH.
}
@@ -709,5 +871,10 @@ module.exports = {
regQueryValue,
regEnumSubkeys,
findExecutableOnPath,
windowsAppsAliasPrefix,
isWindowsAppExecutionAlias,
selectExecutableCandidate,
probeAliasLaunchable,
probeArgsFor,
mapWslDistroIcon,
};
+258
View File
@@ -0,0 +1,258 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
isWindowsAppExecutionAlias,
probeAliasLaunchable,
probeArgsFor,
selectExecutableCandidate,
windowsAppsAliasPrefix,
} = require("./shellDiscovery.cjs");
const WIN_ENV = { LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local" };
const ALIAS_DIR = "C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps";
const ALIAS_PWSH = ALIAS_DIR + "\\pwsh.exe";
/**
* Build a fake fs module simulating Windows file semantics:
* - "file": regular file — existsSync/statSync/lstatSync all succeed.
* - "appExecLink": AppExecLink reparse point — existsSync/statSync fail
* (EACCES, nodejs/node#36790), lstatSync sees the reparse point.
* - "stub": plain zero-byte file (e.g. disabled alias) — all stat calls
* succeed but it is not launchable.
* Missing paths behave like ENOENT everywhere.
*/
const makeFakeFs = (entries) => {
const kindOf = (p) => entries.get(String(p).toLowerCase());
const stat = (p) => {
const kind = kindOf(p);
if (!kind) {
const err = new Error("ENOENT");
err.code = "ENOENT";
throw err;
}
if (kind === "appExecLink") {
const err = new Error("EACCES");
err.code = "EACCES";
throw err;
}
return { isFile: () => true, size: 0 };
};
return {
existsSync: (p) => {
const kind = kindOf(p);
return !!kind && kind !== "appExecLink";
},
statSync: stat,
lstatSync: (p) => {
if (!kindOf(p)) {
const err = new Error("ENOENT");
err.code = "ENOENT";
throw err;
}
return { isFile: () => true, size: 0 };
},
};
};
const WIN_DEPS = (entries) => ({
fs: makeFakeFs(entries),
env: WIN_ENV,
platform: "win32",
// Default probe: treat every reparse point as a live, launchable alias.
probe: () => true,
});
test("windowsAppsAliasPrefix returns null off Windows", () => {
assert.equal(windowsAppsAliasPrefix(WIN_ENV, "linux"), null);
assert.equal(windowsAppsAliasPrefix(WIN_ENV, "darwin"), null);
});
test("windowsAppsAliasPrefix joins LOCALAPPDATA + WindowsApps on win32", () => {
const prefix = windowsAppsAliasPrefix(WIN_ENV, "win32");
assert.ok(prefix.startsWith("c:\\users\\u\\appdata\\local"));
assert.ok(prefix.endsWith("microsoft\\windowsapps\\"));
});
test("isWindowsAppExecutionAlias matches the per-user alias directory", () => {
assert.equal(isWindowsAppExecutionAlias(ALIAS_PWSH, WIN_ENV, "win32"), true);
assert.equal(
isWindowsAppExecutionAlias(ALIAS_DIR + "\\WindowsPowerShell\\pwsh.exe", WIN_ENV, "win32"),
true,
);
assert.equal(
isWindowsAppExecutionAlias("C:\\Program Files\\PowerShell\\7\\pwsh.exe", WIN_ENV, "win32"),
false,
);
assert.equal(isWindowsAppExecutionAlias(ALIAS_PWSH, WIN_ENV, "linux"), false);
assert.equal(isWindowsAppExecutionAlias(null, WIN_ENV, "win32"), false);
});
test("selectExecutableCandidate returns the first existing regular file", () => {
const deps = WIN_DEPS(new Map([
["c:\\tools\\pwsh.exe", "file"],
]));
assert.equal(
selectExecutableCandidate(["C:\\tools\\pwsh.exe", ALIAS_PWSH], deps),
"C:\\tools\\pwsh.exe",
);
});
test("selectExecutableCandidate skips missing candidates", () => {
const deps = WIN_DEPS(new Map());
assert.equal(selectExecutableCandidate(["C:\\nope\\pwsh.exe"], deps), null);
});
test("selectExecutableCandidate accepts an MSIX app execution alias (issue #3280)", () => {
// winget MSIX PowerShell 7: only the WindowsApps alias exists, and
// existsSync()/statSync() fail on its AppExecLink reparse point.
const deps = WIN_DEPS(new Map([[ALIAS_PWSH.toLowerCase(), "appExecLink"]]));
assert.equal(selectExecutableCandidate([ALIAS_PWSH], deps), ALIAS_PWSH);
});
test("selectExecutableCandidate rejects a plain zero-byte alias stub", () => {
// Disabled alias: statSync succeeds (plain file) → not launchable.
const deps = WIN_DEPS(new Map([[ALIAS_PWSH.toLowerCase(), "stub"]]));
assert.equal(selectExecutableCandidate([ALIAS_PWSH], deps), null);
});
test("selectExecutableCandidate rejects a stale app execution alias", () => {
// Stale reparse point (e.g. after incomplete package removal): statSync
// fails with EACCES just like a live AppExecLink, but the spawn probe
// shows the process cannot be launched.
const deps = {
...WIN_DEPS(new Map([[ALIAS_PWSH.toLowerCase(), "appExecLink"]])),
probe: () => false,
};
assert.equal(selectExecutableCandidate([ALIAS_PWSH], deps), null);
});
test("selectExecutableCandidate skips a stale alias for later candidates", () => {
// A stale alias must not be recorded as the fallback, so a later real
// install is still selected.
const deps = {
...WIN_DEPS(new Map([
["c:\\program files\\powershell\\7\\pwsh.exe", "file"],
[ALIAS_PWSH.toLowerCase(), "appExecLink"],
])),
probe: (candidate) => candidate !== ALIAS_PWSH,
};
assert.equal(
selectExecutableCandidate([ALIAS_PWSH, "C:\\Program Files\\PowerShell\\7\\pwsh.exe"], deps),
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
);
});
test("selectExecutableCandidate prefers a real install over the alias", () => {
const deps = WIN_DEPS(new Map([
["c:\\program files\\powershell\\7\\pwsh.exe", "file"],
[ALIAS_PWSH.toLowerCase(), "appExecLink"],
]));
assert.equal(
selectExecutableCandidate([
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
ALIAS_PWSH,
], deps),
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
);
// Alias is still kept as fallback when it comes first in PATH order.
assert.equal(
selectExecutableCandidate([
ALIAS_PWSH,
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
], deps),
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
);
});
test("selectExecutableCandidate falls back to the alias when no real install exists", () => {
const deps = WIN_DEPS(new Map([
[ALIAS_PWSH.toLowerCase(), "appExecLink"],
]));
assert.equal(
selectExecutableCandidate([
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
ALIAS_PWSH,
], deps),
ALIAS_PWSH,
);
});
test("probeArgsFor suppresses the profile for PowerShell-family aliases", () => {
// The spawn probe must never execute the user's PowerShell profile.
assert.deepEqual(probeArgsFor(ALIAS_PWSH), [
"-NoProfile",
"-NonInteractive",
"-Command",
"exit",
]);
assert.deepEqual(probeArgsFor("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"), [
"-NoProfile",
"-NonInteractive",
"-Command",
"exit",
]);
// Non-PowerShell candidates must not be probed at all (empty args = "do
// not probe"): launching an arbitrary WindowsApps alias with no arguments
// can open a GUI or the Microsoft Store.
assert.deepEqual(probeArgsFor("C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\wt.exe"), []);
assert.deepEqual(probeArgsFor("C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe"), []);
});
test("probeAliasLaunchable never spawns non-PowerShell WindowsApps aliases", () => {
// Probing python.exe/wt.exe with no arguments can open a GUI or the
// Microsoft Store, so unknown aliases must be rejected without spawning.
const spawnCalls = [];
const spawnSync = (...args) => {
spawnCalls.push(args);
return { error: undefined };
};
assert.equal(
probeAliasLaunchable("C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe", { spawnSync }),
false,
);
assert.equal(spawnCalls.length, 0);
});
test("probeAliasLaunchable probes PowerShell-family aliases with -NoProfile", () => {
const spawnCalls = [];
const spawnSync = (...args) => {
spawnCalls.push(args);
return { error: undefined };
};
assert.equal(probeAliasLaunchable(ALIAS_PWSH, { spawnSync }), true);
assert.equal(spawnCalls.length, 1);
assert.deepEqual(spawnCalls[0][1], [
"-NoProfile",
"-NonInteractive",
"-Command",
"exit",
]);
});
test("probeAliasLaunchable treats a probe timeout as launchable", () => {
// ETIMEDOUT means the process ran until we killed it — it launched.
const spawnSync = () => {
const err = new Error("spawn timeout");
err.code = "ETIMEDOUT";
return { error: err };
};
assert.equal(probeAliasLaunchable(ALIAS_PWSH, { spawnSync }), true);
});
test("probeAliasLaunchable rejects aliases that fail to spawn", () => {
const spawnSync = () => {
const err = new Error("spawn failed");
err.code = "ENOENT";
return { error: err };
};
assert.equal(probeAliasLaunchable(ALIAS_PWSH, { spawnSync }), false);
});
test("selectExecutableCandidate ignores empty candidates", () => {
const deps = WIN_DEPS(new Map([["c:\\tools\\pwsh.exe", "file"]]));
assert.equal(
selectExecutableCandidate(["", "C:\\tools\\pwsh.exe"], deps),
"C:\\tools\\pwsh.exe",
);
});
+39 -26
View File
@@ -58,7 +58,10 @@ const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { detectShellKind } = require("./ai/ptyExec.cjs");
const { stripAnsi, trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
const { createZmodemSentry } = require("./zmodemHelper.cjs");
const { discoverShells } = require("./shellDiscovery.cjs");
const {
discoverShells,
selectExecutableCandidate,
} = require("./shellDiscovery.cjs");
const moshHandshake = require("./moshHandshake.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
@@ -741,22 +744,9 @@ function resolvePosixExecutable(name, opts = {}) {
/**
* Find executable path on Windows
*/
function isWindowsAppExecutionAlias(filePath) {
if (!filePath || process.platform !== "win32") return false;
const normalizedPath = path.normalize(filePath).toLowerCase();
const windowsAppsDir = path.join(
process.env.LOCALAPPDATA || "",
"Microsoft",
"WindowsApps",
).toLowerCase();
return !!windowsAppsDir && normalizedPath.startsWith(`${windowsAppsDir}${path.sep}`);
}
function findExecutable(name, opts = {}) {
if (process.platform !== "win32") return name;
const { execFileSync } = require("child_process");
try {
const pathOverride = Object.prototype.hasOwnProperty.call(opts, "pathOverride")
@@ -776,15 +766,17 @@ function findExecutable(name, opts = {}) {
.map((line) => line.trim())
.filter(Boolean);
for (const candidate of candidates) {
if (!fs.existsSync(candidate)) continue;
if (name === "pwsh" && isWindowsAppExecutionAlias(candidate)) continue;
return candidate;
}
// selectExecutableCandidate accepts Windows App Execution Aliases
// (MSIX/Store installs, e.g. winget's MSIX PowerShell 7) as a fallback
// after regular files — ConPTY launches them fine via the full alias
// path even though fs.existsSync() cannot resolve AppExecLink reparse
// points (issue #3280).
const found = selectExecutableCandidate(candidates, { fs, env: process.env });
if (found) return found;
} catch (err) {
console.warn(`Could not find ${name} via where.exe:`, err.message);
}
if (!/^[a-zA-Z0-9._-]+$/.test(name)) return name;
const commonPaths = [];
@@ -793,6 +785,9 @@ function findExecutable(name, opts = {}) {
commonPaths.push(
path.join(process.env.ProgramFiles || "C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
path.join(process.env.ProgramW6432 || "C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
// MSIX/Store (winget) installs expose pwsh only via the per-user
// app execution alias — there is no "Program Files\PowerShell\7".
path.join(process.env.LOCALAPPDATA || "", "Microsoft", "WindowsApps", "pwsh.exe"),
);
}
@@ -815,28 +810,44 @@ function findExecutable(name, opts = {}) {
);
for (const p of commonPaths) {
if (fs.existsSync(p)) return p;
// selectExecutableCandidate also accepts app execution aliases, whose
// AppExecLink reparse points fs.existsSync() cannot see.
if (selectExecutableCandidate([p], { fs, env: process.env })) return p;
}
return name;
}
// Resolved default Windows shell for this app session (undefined = not yet
// resolved). Resolution can run a spawn probe for MSIX app-execution aliases
// (see findExecutable → selectExecutableCandidate), which blocks the Electron
// main process for up to the probe timeout, so the result is memoized and
// never recomputed per terminal.
let cachedDefaultLocalShell;
function getDefaultLocalShell() {
if (process.platform !== "win32") {
return process.env.SHELL || "/bin/bash";
}
if (cachedDefaultLocalShell !== undefined) {
return cachedDefaultLocalShell;
}
const pwsh = findExecutable("pwsh");
if (pwsh && pwsh.toLowerCase() !== "pwsh") {
cachedDefaultLocalShell = pwsh;
return pwsh;
}
const powershell = findExecutable("powershell");
if (powershell && powershell.toLowerCase() !== "powershell") {
cachedDefaultLocalShell = powershell;
return powershell;
}
return "powershell.exe";
cachedDefaultLocalShell = "powershell.exe";
return cachedDefaultLocalShell;
}
function getLocalShellArgs(shellPath) {
@@ -885,7 +896,6 @@ const applyLocaleDefaults = (env) => {
*/
function startLocalSession(event, payload) {
const sessionId = payload?.sessionId || randomUUID();
const defaultShell = getDefaultLocalShell();
// payload.shell may be a discovered shell ID (e.g., "wsl-ubuntu") — resolve it
let resolvedShell = payload?.shell;
let resolvedArgs = payload?.shellArgs;
@@ -898,7 +908,10 @@ function startLocalSession(event, payload) {
resolvedArgs = resolvedArgs ?? match.args;
}
}
const shell = normalizeExecutablePath(resolvedShell) || defaultShell;
// Resolve the platform default shell lazily — only when the payload did not
// name a usable shell — so terminal creation never pays for default-shell
// resolution (which may probe MSIX aliases on Windows) when it isn't needed.
const shell = normalizeExecutablePath(resolvedShell) || getDefaultLocalShell();
const shellArgs = resolvedArgs ?? getLocalShellArgs(shell);
const shellKind = detectShellKind(shell);
const { buildTerminalProcessEnv } = require("./httpNetworkProxyBridge.cjs");