Files
NetMesh/electron/bridges/ai/sessionShellKind.test.cjs

876 lines
30 KiB
JavaScript
Raw Permalink Normal View History

const test = require("node:test");
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const { existsSync } = require("node:fs");
const {
isConfirmedShellKind,
PROBE_OUTPUT_MARKER,
WINDOWS_NO_DEFAULT_SHELL_MARKER,
classifyShellKindFromRemotePath,
buildRemoteLoginShellProbeCommand,
buildRemoteWindowsLoginShellProbeCommand,
parseRemoteLoginShellProbeOutput,
parseRemoteWindowsLoginShellProbeOutput,
isWindowsOpenSshRemote,
createSshConnExecProbe,
createSessionExecProbe,
ensureSessionShellKind,
ensureSessionShellKindForExec,
} = require("./sessionShellKind.cjs");
const {
buildWrappedCommand,
resolveEffectiveShellKind,
} = require("./ptyExecHelpers.cjs");
test("classifies remote login shell paths", () => {
assert.equal(classifyShellKindFromRemotePath("/usr/bin/fish"), "fish");
assert.equal(classifyShellKindFromRemotePath("/usr/local/bin/fish"), "fish");
assert.equal(classifyShellKindFromRemotePath("fish"), "fish");
assert.equal(classifyShellKindFromRemotePath("/bin/bash"), "posix");
assert.equal(classifyShellKindFromRemotePath("/bin/zsh"), "posix");
assert.equal(classifyShellKindFromRemotePath("/usr/bin/pwsh"), "powershell");
assert.equal(classifyShellKindFromRemotePath("/bin/cmd.exe"), "cmd");
assert.equal(
classifyShellKindFromRemotePath(
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
),
"powershell",
);
assert.equal(
classifyShellKindFromRemotePath("C:\\Windows\\System32\\cmd.exe"),
"cmd",
);
assert.equal(classifyShellKindFromRemotePath("/usr/bin/nu"), null);
assert.equal(classifyShellKindFromRemotePath(""), null);
});
test("isWindowsOpenSshRemote matches OpenSSH_for_Windows banners", () => {
assert.equal(isWindowsOpenSshRemote("OpenSSH_for_Windows_9.5"), true);
assert.equal(isWindowsOpenSshRemote("SSH-2.0-OpenSSH_for_Windows_8.1"), true);
assert.equal(isWindowsOpenSshRemote("OpenSSH_9.6"), false);
assert.equal(isWindowsOpenSshRemote(""), false);
assert.equal(isWindowsOpenSshRemote(undefined), false);
});
test("Windows login-shell probe uses reg query for DefaultShell", () => {
const command = buildRemoteWindowsLoginShellProbeCommand();
assert.match(command, /reg query/i);
assert.match(command, /HKLM\\SOFTWARE\\OpenSSH/i);
assert.match(command, /DefaultShell/);
// Force cmd.exe so ERRORLEVEL works under powershell DefaultShell too.
assert.match(command, /cmd\.exe/i);
// Missing-value marker only after confirming the OpenSSH key is readable
// (`if not errorlevel 1`), not on every reg failure (access denied / missing
// key under a readable parent). Parent SOFTWARE readability must not imply
// OpenSSH absence — ACL is per-key.
assert.match(command, /if errorlevel 1/i);
assert.match(command, /if not errorlevel 1/i);
assert.doesNotMatch(command, /HKLM\\SOFTWARE(?!\\OpenSSH)/);
assert.match(command, new RegExp(WINDOWS_NO_DEFAULT_SHELL_MARKER));
// Missing DefaultShell diagnostics may still land on stderr; redirect keeps
// REG_SZ success lines visible when hosts split streams.
assert.match(command, /2>&1/);
});
test("parseRemoteWindowsLoginShellProbeOutput reads DefaultShell and missing-key default", () => {
assert.equal(
parseRemoteWindowsLoginShellProbeOutput(
"\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH\r\n DefaultShell REG_SZ C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\r\n",
),
"powershell",
);
assert.equal(
parseRemoteWindowsLoginShellProbeOutput(
"\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH\r\n DefaultShell REG_SZ C:\\Windows\\System32\\cmd.exe\r\n",
),
"cmd",
);
// Locale-independent marker from ERRORLEVEL (preferred path): OpenSSH key
// readable, DefaultShell value absent.
assert.equal(
parseRemoteWindowsLoginShellProbeOutput(
`错误: 系统找不到指定的注册表项或值。\r\n${WINDOWS_NO_DEFAULT_SHELL_MARKER}\r\n`,
),
"cmd",
);
assert.equal(
parseRemoteWindowsLoginShellProbeOutput(
`${WINDOWS_NO_DEFAULT_SHELL_MARKER}\r\n`,
),
"cmd",
);
// English diagnostic kept as fallback for older fixtures / probe output.
assert.equal(
parseRemoteWindowsLoginShellProbeOutput(
"ERROR: The system was unable to find the specified registry key or value.\r\n",
),
"cmd",
);
// Localized text alone must not classify — that was the P2 hang risk.
assert.equal(
parseRemoteWindowsLoginShellProbeOutput("错误: 系统找不到指定的注册表项或值。\r\n"),
null,
);
// Access denied / policy blocks must stay unclassified (no missing-value
// marker). Treating them as cmd permanently pins the wrong wrapper on
// PowerShell DefaultShell hosts.
assert.equal(
parseRemoteWindowsLoginShellProbeOutput("ERROR: Access is denied.\r\n"),
null,
);
assert.equal(
parseRemoteWindowsLoginShellProbeOutput("错误: 拒绝访问。\r\n"),
null,
);
assert.equal(parseRemoteWindowsLoginShellProbeOutput(""), null);
assert.equal(parseRemoteWindowsLoginShellProbeOutput("reg: command not found\n"), null);
});
test("parseRemoteLoginShellProbeOutput reads classifiable probe output lines", () => {
assert.equal(
parseRemoteLoginShellProbeOutput(`\n${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`),
"fish",
);
assert.equal(
parseRemoteLoginShellProbeOutput(` ${PROBE_OUTPUT_MARKER}/bin/bash\r\n`),
"posix",
);
assert.equal(
parseRemoteLoginShellProbeOutput(`SHELL=/bin/bash\n${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`),
"fish",
);
assert.equal(parseRemoteLoginShellProbeOutput("SHELL=/bin/bash\n"), null);
assert.equal(parseRemoteLoginShellProbeOutput(" \n"), null);
});
test("probe command is fish-parseable and forces POSIX sh", () => {
const command = buildRemoteLoginShellProbeCommand();
// Outer form: fish and bash both accept `exec sh -c '...'` when sshd
// routes the remote command through the login shell.
assert.match(command, /^exec sh -c '/);
assert.match(command, /getent passwd/);
assert.match(command, new RegExp(PROBE_OUTPUT_MARKER));
// ${SHELL:-} lives inside the single-quoted sh script body, not as an
// outer-shell expansion — fish must not see it unquoted.
assert.match(command, /\$\{SHELL:-\}/);
assert.equal(command.startsWith("exec sh -c '"), true);
assert.equal(command.endsWith("'"), true);
});
test("isConfirmedShellKind covers wrapper kinds only", () => {
assert.equal(isConfirmedShellKind("fish"), true);
assert.equal(isConfirmedShellKind("posix"), true);
assert.equal(isConfirmedShellKind("unknown"), false);
assert.equal(isConfirmedShellKind(undefined), false);
assert.equal(isConfirmedShellKind(""), false);
});
test("ensureSessionShellKind short-circuits confirmed kinds without probing", async () => {
let probes = 0;
const session = { shellKind: "posix", protocol: "ssh" };
const kind = await ensureSessionShellKind(session, {
execProbe: async () => {
probes += 1;
return `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`;
},
});
assert.equal(kind, "posix");
assert.equal(probes, 0);
});
test("ensureSessionShellKind does not probe local unknown shells", async () => {
let probes = 0;
const session = { shellKind: "unknown", protocol: "local", type: "local" };
const kind = await ensureSessionShellKind(session, {
execProbe: async () => {
probes += 1;
return `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`;
},
});
assert.equal(kind, "unknown");
assert.equal(probes, 0);
});
test("ensureSessionShellKind probes fish once but does not pin it as active shell", async () => {
// Login shell = fish must not permanently set session.shellKind (Codex P2).
// Soft hint still selects the fish wrapper for the common fish-login case.
let probes = 0;
const session = { protocol: "ssh" };
const probe = async () => {
probes += 1;
return `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`;
};
const first = await ensureSessionShellKind(session, { execProbe: probe });
const second = await ensureSessionShellKind(session, { execProbe: probe });
assert.equal(first, undefined);
assert.equal(second, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "fish");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 1);
assert.equal(
resolveEffectiveShellKind(session.shellKind, "", { loginShellHint: session._loginShellKind }),
"fish",
);
});
test("ensureSessionShellKind shares one in-flight probe across concurrent callers", async () => {
let probes = 0;
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const session = { protocol: "ssh" };
const probe = async () => {
probes += 1;
await gate;
return `${PROBE_OUTPUT_MARKER}/bin/zsh\n`;
};
const p1 = ensureSessionShellKind(session, { execProbe: probe });
const p2 = ensureSessionShellKind(session, { execProbe: probe });
release();
const [a, b] = await Promise.all([p1, p2]);
// Posix login shells are not pinned on session.shellKind (see below).
assert.equal(a, undefined);
assert.equal(b, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 1);
});
test("probed posix login shell does not block live PowerShell prompt override (Codex P2)", async () => {
// Login shell is bash/zsh, but the user may have entered pwsh interactively
// (or startup files exec'd it). Previously unset shellKind let
// resolveEffectiveShellKind honor PS ...> prompts (#841). Pinning posix
// permanently would type the bash wrapper into PowerShell.
let probes = 0;
const session = { protocol: "ssh" };
const probe = async () => {
probes += 1;
return `${PROBE_OUTPUT_MARKER}/bin/bash\n`;
};
await ensureSessionShellKind(session, { execProbe: probe });
await ensureSessionShellKind(session, { execProbe: probe });
assert.equal(probes, 1, "posix probe should settle without re-probing");
assert.equal(session.shellKind, undefined);
assert.equal(session._shellKindProbeSettled, true);
// Live PowerShell prompt still wins when shellKind is unset.
assert.equal(
resolveEffectiveShellKind(session.shellKind, "PS C:\\Users\\alice>", {
loginShellHint: session._loginShellKind,
}),
"powershell",
);
// Soft posix hint → native posix wrapper (evaluated by interactive bash/zsh,
// NOT routed through /bin/sh / dash).
assert.equal(
resolveEffectiveShellKind(session.shellKind, "alice@host:~$", {
loginShellHint: session._loginShellKind,
}),
"posix",
);
const marker = "__NCMCP_POSIX_NATIVE__";
const wrapped = buildWrappedCommand("echo native-posix", "posix", marker);
assert.doesNotMatch(wrapped, /\bsh\s+-c\b/);
assert.doesNotMatch(wrapped, /posix_sh/);
assert.match(wrapped, new RegExp(`${marker}=0;`));
assert.match(wrapped, new RegExp(`${marker}_cmd=`));
});
test("probed fish login shell is a soft hint, not a permanent pin (Codex P2)", async () => {
const session = { protocol: "ssh" };
await ensureSessionShellKind(session, {
execProbe: async () => `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`,
});
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "fish");
// Soft hint selects fish wrapper for the common case.
assert.equal(
resolveEffectiveShellKind(session.shellKind, "root@host ~# ", {
loginShellHint: session._loginShellKind,
}),
"fish",
);
// PS prompt still overrides the fish login hint.
assert.equal(
resolveEffectiveShellKind(session.shellKind, "PS C:\\Users\\alice>", {
loginShellHint: session._loginShellKind,
}),
"powershell",
);
});
test("ensureSessionShellKind allows retry after a failed probe", async () => {
let probes = 0;
const session = { protocol: "ssh" };
const failThenSucceed = async () => {
probes += 1;
if (probes === 1) return null;
return `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`;
};
const first = await ensureSessionShellKind(session, {
execProbe: failThenSucceed,
});
assert.equal(first, undefined);
assert.equal(session.shellKind, undefined);
const second = await ensureSessionShellKind(session, {
execProbe: failThenSucceed,
});
assert.equal(second, undefined);
assert.equal(session._loginShellKind, "fish");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 2);
});
test("ensureSessionShellKind uses a session-level exec probe when provided", async () => {
let probes = 0;
const session = {
protocol: "mosh",
_shellKindExecProbe: async () => {
probes += 1;
return `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`;
},
};
const kind = await ensureSessionShellKind(session);
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "fish");
assert.equal(probes, 1);
});
test("ensureSessionShellKind soft-hints powershell login shells without pinning", async () => {
const session = { protocol: "ssh" };
await ensureSessionShellKind(session, {
execProbe: async () => `${PROBE_OUTPUT_MARKER}/usr/bin/pwsh\n`,
});
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "powershell");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(
resolveEffectiveShellKind(session.shellKind, "", {
loginShellHint: session._loginShellKind,
}),
"powershell",
);
// Live cmd prompt overrides a PowerShell DefaultShell soft hint.
assert.equal(
resolveEffectiveShellKind(session.shellKind, "C:\\Users\\alice>", {
loginShellHint: session._loginShellKind,
}),
"cmd",
);
// Live POSIX prompt (WSL) overrides a PowerShell soft hint.
assert.equal(
resolveEffectiveShellKind(session.shellKind, "user@host:~$", {
loginShellHint: session._loginShellKind,
}),
"posix",
);
});
test("ensureSessionShellKind uses Windows DefaultShell probe for OpenSSH_for_Windows", async () => {
// Issue #2959: Unix `exec sh -c` probes never classify Windows OpenSSH, so AI
// fell through to a posix wrapper, hung, and Stop/Ctrl+C tore down the tab.
const probed = [];
const session = {
protocol: "ssh",
remoteSshVersion: "OpenSSH_for_Windows_9.5",
};
const kind = await ensureSessionShellKind(session, {
execProbe: async (command) => {
probed.push(command);
return (
"\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH\r\n" +
" DefaultShell REG_SZ C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\r\n"
);
},
});
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "powershell");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probed.length, 1);
assert.match(probed[0], /reg query/i);
assert.doesNotMatch(probed[0], /getent passwd/);
assert.equal(
resolveEffectiveShellKind(session.shellKind, "", {
loginShellHint: session._loginShellKind,
}),
"powershell",
);
});
test("ensureSessionShellKind soft-hints cmd when Windows OpenSSH has no DefaultShell value", async () => {
const session = {
protocol: "ssh",
remoteSshVersion: "OpenSSH_for_Windows_8.1",
};
const kind = await ensureSessionShellKind(session, {
execProbe: async () =>
`错误: 系统找不到指定的注册表项或值。\r\n${WINDOWS_NO_DEFAULT_SHELL_MARKER}\r\n`,
});
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "cmd");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(
resolveEffectiveShellKind(session.shellKind, "", {
loginShellHint: session._loginShellKind,
}),
"cmd",
);
// Live PowerShell prompt overrides a cmd DefaultShell soft hint.
assert.equal(
resolveEffectiveShellKind(session.shellKind, "PS C:\\Users\\alice>", {
loginShellHint: session._loginShellKind,
}),
"powershell",
);
});
test("ensureSessionShellKind does not pin cmd when Windows reg probe is access-denied", async () => {
// Codex P2: access denied must not share the missing-value → cmd path.
let probes = 0;
const session = {
protocol: "ssh",
remoteSshVersion: "OpenSSH_for_Windows_9.5",
};
const kind = await ensureSessionShellKind(session, {
execProbe: async () => {
probes += 1;
// Live probe no longer echoes WINDOWS_NO_DEFAULT_SHELL_MARKER here.
return "ERROR: Access is denied.\r\n";
},
});
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 1);
});
test("ensureSessionShellKind settles Windows OpenSSH without pinning when reg probe is empty", async () => {
let probes = 0;
const session = {
protocol: "ssh",
remoteSshVersion: "OpenSSH_for_Windows_9.5",
};
const kind = await ensureSessionShellKind(session, {
execProbe: async () => {
probes += 1;
return "";
},
});
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 1);
// Settled: do not re-probe on the next AI exec.
await ensureSessionShellKind(session, {
execProbe: async () => {
probes += 1;
return "";
},
});
assert.equal(probes, 1);
});
test("ensureSessionShellKind retries Windows OpenSSH probe after null/timeout", async () => {
// Codex P1: timeout/channel failure must not settleWithoutKind — otherwise
// later AI execs permanently use the POSIX wrapper on Windows.
let probes = 0;
const session = {
protocol: "ssh",
remoteSshVersion: "OpenSSH_for_Windows_9.5",
};
const failThenSucceed = async () => {
probes += 1;
if (probes === 1) return null;
return (
"\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH\r\n" +
" DefaultShell REG_SZ C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\r\n"
);
};
const first = await ensureSessionShellKind(session, {
execProbe: failThenSucceed,
});
assert.equal(first, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._shellKindProbeSettled, undefined);
assert.equal(session._shellKindProbePromise, null);
const second = await ensureSessionShellKind(session, {
execProbe: failThenSucceed,
});
assert.equal(second, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "powershell");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 2);
});
test("ensureSessionShellKind falls back to Windows reg probe when Unix probe yields nothing", async () => {
const probed = [];
const session = { protocol: "ssh" };
const kind = await ensureSessionShellKind(session, {
execProbe: async (command) => {
probed.push(command);
if (/reg query/i.test(command)) {
return (
"HKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH\n" +
" DefaultShell REG_SZ C:\\Windows\\System32\\cmd.exe\n"
);
}
return "no marker here\n";
},
});
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "cmd");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probed.length, 2);
assert.match(probed[0], /getent passwd|exec sh -c/);
assert.match(probed[1], /reg query/i);
});
test("ensureSessionShellKind settles completed unclassifiable Windows fallback without re-probing", async () => {
// Codex P2: when remoteSshVersion is missing, Unix probe returns non-marker
// bytes, and Windows reg returns access-denied, settle so later AI execs do
// not re-run both probes forever. Null/timeout still retries.
let probes = 0;
const session = { protocol: "ssh" };
const kind = await ensureSessionShellKind(session, {
execProbe: async (command) => {
probes += 1;
if (/reg query/i.test(command)) {
return "ERROR: Access is denied.\r\n";
}
return "no marker here\n";
},
});
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, undefined);
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 2);
await ensureSessionShellKind(session, {
execProbe: async () => {
probes += 1;
return "should not run\n";
},
});
assert.equal(probes, 2);
});
test("ensureSessionShellKind retries Windows fallback after null/timeout when banner missing", async () => {
let probes = 0;
const session = { protocol: "ssh" };
const first = await ensureSessionShellKind(session, {
execProbe: async (command) => {
probes += 1;
if (/reg query/i.test(command)) return null;
return "no marker here\n";
},
});
assert.equal(first, undefined);
assert.equal(session._shellKindProbeSettled, undefined);
assert.equal(session._shellKindProbePromise, null);
assert.equal(probes, 2);
const second = await ensureSessionShellKind(session, {
execProbe: async (command) => {
probes += 1;
if (/reg query/i.test(command)) {
return (
"HKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH\n" +
" DefaultShell REG_SZ C:\\Windows\\System32\\cmd.exe\n"
);
}
return "no marker here\n";
},
});
assert.equal(second, undefined);
assert.equal(session._loginShellKind, "cmd");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 4);
});
test("ensureSessionShellKindForExec cancels when Stop fires during the probe", async () => {
// Codex P2 on #2061: probe can take up to the timeout before execViaPty
// registers a real marker. Pending marker must latch cancel so the command
// is not typed after the probe resolves.
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const session = { protocol: "ssh" };
const activePtyExecs = new Map();
const probe = async () => {
await gate;
return `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`;
};
const pending = ensureSessionShellKindForExec(session, {
execProbe: probe,
trackForCancellation: activePtyExecs,
chatSessionId: "chat-cancel-probe",
});
// Wait until the pending marker is registered.
for (let i = 0; i < 20 && activePtyExecs.size === 0; i += 1) {
await new Promise((r) => setTimeout(r, 0));
}
assert.equal(activePtyExecs.size, 1);
const [marker, entry] = [...activePtyExecs.entries()][0];
assert.match(marker, /^__NCMCP_SK_PENDING_/);
assert.equal(entry.chatSessionId, "chat-cancel-probe");
// Simulate cancelPtyExecsForSession during the probe window.
entry.cancel();
release();
const result = await pending;
assert.equal(result.ok, false);
assert.equal(result.cancelled, true);
assert.equal(result.error, "Cancelled");
assert.equal(result.exitCode, 130);
assert.equal(activePtyExecs.size, 0, "pending marker cleaned up after probe");
// Login fish is recorded but not pinned as active shellKind.
assert.equal(session._loginShellKind, "fish");
assert.equal(session.shellKind, undefined);
});
test("ensureSessionShellKindForExec proceeds when not cancelled", async () => {
const session = { protocol: "ssh" };
const activePtyExecs = new Map();
const result = await ensureSessionShellKindForExec(session, {
execProbe: async () => `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`,
trackForCancellation: activePtyExecs,
chatSessionId: "chat-ok",
});
assert.equal(result.ok, true);
assert.equal(result.shellKind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "fish");
assert.equal(activePtyExecs.size, 0);
});
test("ensureSessionShellKind times out a hanging session-level exec probe", async () => {
let probes = 0;
const session = {
protocol: "mosh",
_shellKindExecProbe: async () => {
probes += 1;
return new Promise(() => {});
},
};
const kind = await ensureSessionShellKind(session, { timeoutMs: 1 });
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._shellKindProbePromise, null);
assert.equal(probes, 1);
});
test("createSshConnExecProbe returns stdout from conn.exec", async () => {
let seenCommand = "";
const conn = {
exec(command, cb) {
seenCommand = command;
const listeners = new Map();
const stream = {
on(event, fn) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event).push(fn);
return stream;
},
stderr: { on() { return this; } },
close() {},
};
// Deliver data after the probe has subscribed (next tick).
queueMicrotask(() => {
for (const fn of listeners.get("data") || []) {
fn(Buffer.from("/usr/bin/fish\n"));
}
for (const fn of listeners.get("close") || []) {
fn(0);
}
});
cb(null, stream);
},
};
const probe = createSshConnExecProbe(conn);
const command = buildRemoteLoginShellProbeCommand();
assert.equal(await probe(command, 1000), "/usr/bin/fish\n");
assert.equal(seenCommand, command);
});
test("createSshConnExecProbe includes stderr so missing DefaultShell is classifiable", async () => {
// Codex P1: reg.exe writes the missing-value error only on stderr. Dropping
// it made Windows OpenSSH probes settle without a kind and hang on POSIX
// wrappers when the interactive prompt was unrecognized.
// Codex P2: the live probe also echoes WINDOWS_NO_DEFAULT_SHELL_MARKER via
// ERRORLEVEL so non-English hosts do not depend on localized stderr text.
const { EventEmitter } = require("node:events");
const conn = {
exec(_command, cb) {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
queueMicrotask(() => {
stream.stderr.emit(
"data",
Buffer.from("错误: 系统找不到指定的注册表项或值。\r\n"),
);
stream.emit("data", Buffer.from(`${WINDOWS_NO_DEFAULT_SHELL_MARKER}\r\n`));
stream.emit("close", 1);
});
cb(null, stream);
},
};
const probe = createSshConnExecProbe(conn);
const output = await probe(buildRemoteWindowsLoginShellProbeCommand(), 1000);
assert.match(output, new RegExp(WINDOWS_NO_DEFAULT_SHELL_MARKER));
assert.equal(parseRemoteWindowsLoginShellProbeOutput(output), "cmd");
});
test("createSshConnExecProbe closes a channel that arrives after timeout", async () => {
let execCallback;
let closed = false;
const conn = {
exec(_command, cb) {
execCallback = cb;
},
};
const probe = createSshConnExecProbe(conn);
const result = await probe(buildRemoteLoginShellProbeCommand(), 1);
assert.equal(result, null);
const stream = {
on() { return stream; },
stderr: { on() { return this; } },
close() {
closed = true;
},
};
execCallback(null, stream);
assert.equal(closed, true);
});
test("createSessionExecProbe prefers session.conn over companions", () => {
const session = {
conn: { exec() {} },
moshStatsConn: { exec() {} },
};
const probe = createSessionExecProbe(session);
assert.equal(typeof probe, "function");
// Prefer primary conn: a probe built only from moshStatsConn is a different
// function identity; we just need a usable probe here.
assert.equal(createSessionExecProbe({}), null);
});
// --- Real fish binary: wrapper must produce markers (issue #1854) -----------
function resolveFishBinary() {
const candidates = [
process.env.FISH_PATH,
"/opt/homebrew/bin/fish",
"/usr/local/bin/fish",
"/usr/bin/fish",
].filter(Boolean);
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
const which = spawnSync("which", ["fish"], { encoding: "utf8" });
if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
return null;
}
const fishBinary = resolveFishBinary();
test(
"fish wrapper runs under real fish and emits start/end markers",
{ skip: !fishBinary ? "fish binary not available" : false },
() => {
const marker = "__NCMCP_FISHTEST__";
const wrapped = buildWrappedCommand("echo hello-fish-wrapper", "fish", marker);
// fish -c runs the wrapper as a script body (same grammar as interactive
// command line for this single-line form).
const result = spawnSync(
fishBinary,
["--no-config", "-c", wrapped.trim()],
{ encoding: "utf8", timeout: 10000 },
);
assert.equal(result.error, undefined, result.stderr || result.error);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, new RegExp(`${marker}_S`));
assert.match(result.stdout, /hello-fish-wrapper/);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
},
);
test(
"posix wrapper fails under real fish (regression guard for #1854)",
{ skip: !fishBinary ? "fish binary not available" : false },
() => {
const marker = "__NCMCP_FISHTEST__";
const wrapped = buildWrappedCommand("echo should-not-run", "posix", marker);
const result = spawnSync(
fishBinary,
["--no-config", "-c", wrapped.trim()],
{ encoding: "utf8", timeout: 10000 },
);
// fish rejects `VAR=0` assignment syntax.
assert.notEqual(result.status, 0);
assert.match(
`${result.stdout}\n${result.stderr}`,
/Unsupported use of '='|Unknown command/,
);
},
);
test(
"after ensureSessionShellKind(fish login), fish wrapper succeeds under real fish",
{ skip: !fishBinary ? "fish binary not available" : false },
async () => {
// Soft login hint selects fish wrapper without pinning session.shellKind.
const session = { protocol: "ssh" };
await ensureSessionShellKind(session, {
execProbe: async () => `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`,
});
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "fish");
const marker = "__NCMCP_FISHTEST__";
const effective = resolveEffectiveShellKind(session.shellKind, "root at host # ", {
loginShellHint: session._loginShellKind,
});
assert.equal(effective, "fish");
const wrapped = buildWrappedCommand("printf 'ok\\n'", effective, marker);
const result = spawnSync(
fishBinary,
["--no-config", "-c", wrapped.trim()],
{ encoding: "utf8", timeout: 10000 },
);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /ok/);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
},
);