merge: sync fork with upstream/main (v1.1.31+)

This commit is contained in:
lengyuqu
2026-06-10 15:42:24 +08:00
173 changed files with 7049 additions and 1530 deletions

View File

@@ -204,12 +204,38 @@ function buildWindowsShellCommandLine(command, args) {
return [command, ...(args || [])].map(quoteWindowsShellArg).join(" ");
}
function resolveWindowsShimToNativeExe(command, platform = process.platform) {
if (platform !== "win32") return null;
const normalized = String(command || "").trim();
if (!normalized) return null;
const ext = path.extname(normalized).toLowerCase();
if (ext !== ".cmd" && ext !== ".bat") return null;
if (!existsSync(normalized)) return null;
try {
const contents = readFileSync(normalized, "utf8");
const shimDir = path.dirname(normalized);
// Match patterns like: "%~dp0\..\node_modules\@anthropic-ai\claude-code\bin\claude.exe" %*
// or: "%~dp0\..\@openai\codex\bin\codex.exe"
const exeRefs = [...contents.matchAll(/"%~dp0\\([^"]+\.exe)"/gi)];
for (const [, relativePath] of exeRefs) {
const candidate = path.resolve(shimDir, relativePath.replace(/\\/g, "/"));
if (existsSync(candidate)) return candidate;
}
} catch {}
return null;
}
function prepareCommandForSpawn(command, args) {
const spawnArgs = Array.isArray(args) ? args : [];
if (!shouldUseShellForCommand(command)) {
return { command, args: spawnArgs, shell: false };
}
const nativeExePath = resolveWindowsShimToNativeExe(command);
if (nativeExePath) {
return { command: nativeExePath, args: spawnArgs, shell: false };
}
return {
command: buildWindowsShellCommandLine(command, spawnArgs),
args: [],
@@ -231,6 +257,15 @@ function resolveClaudeCodeExecutableForSdk(claudeExecutablePath, platform = proc
return packageCliPath;
}
// Native binary check: Claude Code >= 2.1.169 ships as native exe with no cli.js
const nativeExeCandidates = [
path.join(baseDir, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"),
path.join(baseDir, "..", "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"),
];
for (const exePath of nativeExeCandidates) {
if (existsSync(exePath)) return exePath;
}
const shimCandidates = [normalized];
if (!ext) {
shimCandidates.push(`${normalized}.cmd`, `${normalized}.bat`);
@@ -264,6 +299,151 @@ function normalizeClaudeCodeExecutableEnvForSdk(env, platform = process.platform
};
}
const CODEX_WIN32_PLATFORM_PACKAGES = {
x64: { triple: "x86_64-pc-windows-msvc", package: "@openai/codex-win32-x64" },
arm64: { triple: "aarch64-pc-windows-msvc", package: "@openai/codex-win32-arm64" },
};
function resolveCodexNativeExecutableWin32(moduleSearchDirs, arch = process.arch) {
const archKey = arch === "arm64" ? "arm64" : "x64";
const { triple, package: platformPackage } = CODEX_WIN32_PLATFORM_PACKAGES[archKey];
for (const dir of moduleSearchDirs) {
if (!dir) continue;
const candidates = [
path.join(dir, "node_modules", platformPackage, "vendor", triple, "bin", "codex.exe"),
path.join(dir, "node_modules", platformPackage, "vendor", triple, "codex", "codex.exe"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
}
return null;
}
function getCodexNativeSearchDirsForShim(shimDir) {
const dirs = [shimDir];
const parentDir = path.dirname(shimDir);
if (
path.basename(shimDir).toLowerCase() === ".bin" &&
path.basename(parentDir).toLowerCase() === "node_modules"
) {
dirs.push(path.dirname(parentDir));
}
dirs.push(path.join(shimDir, "node_modules", "@openai", "codex"));
return dirs;
}
function getCodexNativePathDirsWin32(nativeExecutablePath) {
const normalized = String(nativeExecutablePath || "").trim();
if (!normalized || path.basename(normalized).toLowerCase() !== "codex.exe") {
return [];
}
const executableDir = path.dirname(normalized);
const packageRoot = path.dirname(executableDir);
const dirs = [];
if (path.basename(executableDir).toLowerCase() === "bin") {
dirs.push(path.join(packageRoot, "codex-path"));
} else if (path.basename(executableDir).toLowerCase() === "codex") {
dirs.push(path.join(packageRoot, "path"));
}
return dirs.filter((dir) => existsSync(dir));
}
function getPathEnvKey(env, platform = process.platform) {
if (platform !== "win32") return "PATH";
const keys = Object.keys(env || {}).filter((key) => key.toLowerCase() === "path");
return keys.includes("Path") ? "Path" : keys.at(-1) || "PATH";
}
function addCodexExecutableEnvForSdk(env, codexExecutablePath, platform = process.platform) {
if (platform !== "win32" || !codexExecutablePath) return env;
const pathDirs = getCodexNativePathDirsWin32(codexExecutablePath);
if (pathDirs.length === 0) return env;
const nextEnv = { ...(env || {}) };
const pathKey = getPathEnvKey(nextEnv, platform);
for (const key of Object.keys(nextEnv)) {
if (key.toLowerCase() === "path" && key !== pathKey) {
delete nextEnv[key];
}
}
const delimiter = platform === "win32" ? ";" : path.delimiter;
const existingEntries = String(nextEnv[pathKey] || "")
.split(delimiter)
.filter((entry) => entry && !pathDirs.includes(entry));
nextEnv[pathKey] = [...pathDirs, ...existingEntries].join(delimiter);
return nextEnv;
}
function resolveCodexExecutableForSdk(codexExecutablePath, platform = process.platform) {
const normalized = String(codexExecutablePath || "").trim();
if (!normalized) return null;
if (platform !== "win32") return normalized;
const ext = path.extname(normalized).toLowerCase();
if (ext === ".exe") return normalized;
const baseDir = path.dirname(normalized);
const moduleSearchDirs = getCodexNativeSearchDirsForShim(baseDir);
if (ext === ".js" && /[\\/]codex\.js$/i.test(normalized)) {
const codexPackageRoot = path.dirname(path.dirname(normalized));
const globalPrefix = path.resolve(codexPackageRoot, "..", "..", "..");
const nativeExe = resolveCodexNativeExecutableWin32([
globalPrefix,
codexPackageRoot,
...moduleSearchDirs,
]);
if (nativeExe) return nativeExe;
}
if (ext && ext !== ".cmd" && ext !== ".bat" && ext !== ".ps1") {
return normalized;
}
const nativeExe = resolveCodexNativeExecutableWin32(moduleSearchDirs);
if (nativeExe) return nativeExe;
const shimCandidates = [normalized];
if (!ext) {
shimCandidates.push(`${normalized}.cmd`, `${normalized}.bat`);
}
for (const shimPath of shimCandidates) {
try {
if (!existsSync(shimPath)) continue;
const contents = readFileSync(shimPath, "utf8");
if (!/@openai[\\/]codex[\\/]bin[\\/]codex\.js/i.test(contents)) {
continue;
}
const resolved = resolveCodexNativeExecutableWin32(moduleSearchDirs);
if (resolved) return resolved;
} catch {
// Fall back to the original executable path below.
}
}
return ext === ".cmd" || ext === ".bat" || ext === ".ps1" ? null : normalized;
}
function resolveSdkBinPath(command, shellEnv, platform = process.platform) {
const raw = resolveCliFromPath(command, shellEnv);
if (!raw) return null;
if (platform !== "win32") return raw;
if (command === "codex") {
return resolveCodexExecutableForSdk(raw, platform);
}
if (command === "claude") {
return resolveClaudeCodeExecutableForSdk(raw, platform);
}
return raw;
}
function resolveCliFromPath(command, shellEnv) {
// Validate command: only allow valid binary names (alphanumeric, hyphens, underscores, dots)
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
@@ -438,8 +618,12 @@ module.exports = {
quoteWindowsShellArg,
buildWindowsShellCommandLine,
prepareCommandForSpawn,
resolveWindowsShimToNativeExe,
resolveClaudeCodeExecutableForSdk,
normalizeClaudeCodeExecutableEnvForSdk,
resolveCodexExecutableForSdk,
addCodexExecutableEnvForSdk,
resolveSdkBinPath,
resolveCliFromPath,
toUnpackedAsarPath,
isPlausibleCliVersionOutput,

View File

@@ -2,6 +2,7 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const {
addCodexExecutableEnvForSdk,
buildWindowsShellCommandLine,
extractTrailingIdlePrompt,
getFreshIdlePrompt,
@@ -9,7 +10,9 @@ const {
isPlausibleCliVersionOutput,
looksLikeIdleAutoLogout,
prepareCommandForSpawn,
resolveWindowsShimToNativeExe,
resolveClaudeCodeExecutableForSdk,
resolveCodexExecutableForSdk,
trackSessionIdlePrompt,
} = require("./shellUtils.cjs");
const fs = require("node:fs");
@@ -149,6 +152,196 @@ test("resolveClaudeCodeExecutableForSdk keeps Windows cmd shim when Claude Code
}
});
test("resolveClaudeCodeExecutableForSdk maps Windows npm cmd shim to native claude.exe when cli.js is absent", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-native-"));
try {
const shimPath = path.join(tmp, "claude.cmd");
const nativeExe = path.join(tmp, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe");
fs.mkdirSync(path.dirname(nativeExe), { recursive: true });
fs.writeFileSync(nativeExe, "", "utf8");
fs.writeFileSync(
shimPath,
'@ECHO off\r\n"%~dp0\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*\r\n',
"utf8",
);
assert.equal(resolveClaudeCodeExecutableForSdk(shimPath, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveWindowsShimToNativeExe resolves npm .cmd shim to native exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-shim-native-"));
try {
const shimPath = path.join(tmp, "claude.cmd");
const nativeExe = path.join(tmp, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe");
fs.mkdirSync(path.dirname(nativeExe), { recursive: true });
fs.writeFileSync(nativeExe, "", "utf8");
// Single backslashes in the .cmd content (%~dp0 expands to the shim dir)
fs.writeFileSync(
shimPath,
'@ECHO off\r\n"%~dp0\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*\r\n',
"utf8",
);
const resolved = resolveWindowsShimToNativeExe(shimPath, "win32");
assert.equal(resolved, nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("prepareCommandForSpawn resolves Windows cmd shim to native exe with shell:false", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-spawn-native-"));
try {
const shimPath = path.join(tmp, "claude.cmd");
const nativeExe = path.join(tmp, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe");
fs.mkdirSync(path.dirname(nativeExe), { recursive: true });
fs.writeFileSync(nativeExe, "", "utf8");
fs.writeFileSync(
shimPath,
'@ECHO off\r\n"%~dp0\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*\r\n',
"utf8",
);
const result = prepareCommandForSpawn(shimPath, ["--version"]);
if (process.platform === "win32") {
assert.deepEqual(result, {
command: nativeExe,
args: ["--version"],
shell: false,
});
} else {
// On non-Windows, resolveWindowsShimToNativeExe is skipped; verify win32 behavior explicitly.
assert.equal(resolveWindowsShimToNativeExe(shimPath, "win32"), nativeExe);
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
function writeCodexWin32NativeLayout(globalPrefix, arch = process.arch === "arm64" ? "arm64" : "x64") {
const triple = arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc";
const platformPackage = arch === "arm64" ? "@openai/codex-win32-arm64" : "@openai/codex-win32-x64";
const nativeExe = path.join(
globalPrefix,
"node_modules",
platformPackage,
"vendor",
triple,
"bin",
"codex.exe",
);
fs.mkdirSync(path.dirname(nativeExe), { recursive: true });
fs.writeFileSync(nativeExe, "", "utf8");
return nativeExe;
}
test("resolveCodexExecutableForSdk maps Windows npm cmd shim to native codex.exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-shim-"));
try {
const shimPath = path.join(tmp, "codex.cmd");
const nativeExe = writeCodexWin32NativeLayout(tmp);
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%~dp0\\node_modules\\@openai\\codex\\bin\\codex.js" %*\r\n',
"utf8",
);
assert.equal(resolveCodexExecutableForSdk(shimPath, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodexExecutableForSdk maps Windows local npm bin shim to native codex.exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-local-shim-"));
try {
const shimPath = path.join(tmp, "node_modules", ".bin", "codex.cmd");
const nativeExe = writeCodexWin32NativeLayout(tmp);
fs.mkdirSync(path.dirname(shimPath), { recursive: true });
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%~dp0\\..\\@openai\\codex\\bin\\codex.js" %*\r\n',
"utf8",
);
assert.equal(resolveCodexExecutableForSdk(shimPath, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodexExecutableForSdk leaves non-Windows Codex paths unchanged", () => {
assert.equal(
resolveCodexExecutableForSdk("/usr/local/bin/codex", "darwin"),
"/usr/local/bin/codex",
);
});
test("resolveCodexExecutableForSdk returns null for Windows cmd shim when native codex.exe is missing", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-missing-native-"));
try {
const shimPath = path.join(tmp, "codex.cmd");
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%~dp0\\node_modules\\@openai\\codex\\bin\\codex.js" %*\r\n',
"utf8",
);
assert.equal(resolveCodexExecutableForSdk(shimPath, "win32"), null);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodexExecutableForSdk maps Windows PowerShell shim to native codex.exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-ps1-shim-"));
try {
const shimPath = path.join(tmp, "codex.ps1");
const nativeExe = writeCodexWin32NativeLayout(tmp);
fs.writeFileSync(
shimPath,
'& "$basedir/node_modules/@openai/codex/bin/codex.js" $args\r\n',
"utf8",
);
assert.equal(resolveCodexExecutableForSdk(shimPath, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodexExecutableForSdk maps codex.js entry to native codex.exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-js-entry-"));
try {
const codexJs = path.join(tmp, "node_modules", "@openai", "codex", "bin", "codex.js");
const nativeExe = writeCodexWin32NativeLayout(tmp);
fs.mkdirSync(path.dirname(codexJs), { recursive: true });
fs.writeFileSync(codexJs, "", "utf8");
assert.equal(resolveCodexExecutableForSdk(codexJs, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("addCodexExecutableEnvForSdk prepends bundled Codex path dir on Windows", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-env-path-"));
try {
const nativeExe = writeCodexWin32NativeLayout(tmp);
const pathDir = path.join(path.dirname(path.dirname(nativeExe)), "codex-path");
fs.mkdirSync(pathDir, { recursive: true });
const env = addCodexExecutableEnvForSdk({ Path: "C:\\Windows\\System32" }, nativeExe, "win32");
assert.equal(env.Path, `${pathDir};C:\\Windows\\System32`);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("tracks PowerShell idle prompt after SSH output", () => {
const session = {};

View File

@@ -31,6 +31,8 @@ const {
normalizeCliPathForPlatform,
prepareCommandForSpawn,
normalizeClaudeCodeExecutableEnvForSdk,
addCodexExecutableEnvForSdk,
resolveSdkBinPath,
resolveCliFromPath,
isPlausibleCliVersionOutput,
getShellEnv,
@@ -636,6 +638,8 @@ function createHandlerContext(ipcMain) {
normalizeCliPathForPlatform,
prepareCommandForSpawn,
normalizeClaudeCodeExecutableEnvForSdk,
addCodexExecutableEnvForSdk,
resolveSdkBinPath,
resolveCliFromPath,
probeClaudeAuth,
probeCopilotAuth,

View File

@@ -101,16 +101,19 @@ function createAgentCliHelpers(ctx) {
if (cached && now - cached.checkedAt < maxAgeMs) return cached;
const shellEnv = await getShellEnv();
const codexPath = resolveCliFromPath("codex", shellEnv);
if (!codexPath) {
const rawCodexPath = resolveCliFromPath("codex", shellEnv);
if (!rawCodexPath) {
const result = { ok: false, checkedAt: now, error: "codex binary not found", code: "ENOENT" };
setCodexValidationCache(result);
return result;
}
const codexPath = resolveSdkBinPath("codex", shellEnv);
try {
// Minimal read-only probe turn through the SDK to confirm auth works.
const { Codex } = await import("@openai/codex-sdk");
const codex = new Codex({ codexPathOverride: codexPath, env: shellEnv });
const codexOptions = { env: addCodexExecutableEnvForSdk(shellEnv, codexPath) };
if (codexPath) codexOptions.codexPathOverride = codexPath;
const codex = new Codex(codexOptions);
const thread = codex.startThread({ skipGitRepoCheck: true });
const { events } = await thread.runStreamed("ping", { sandbox: "read-only" });
let failed = null;

View File

@@ -173,7 +173,7 @@ function registerSdkStreamHandlers(ctx) {
const claudeSettings = normalizedAgentEnv.NETCATTY_CLAUDE_SETTINGS;
delete normalizedAgentEnv.NETCATTY_CLAUDE_SETTINGS;
const env = buildSdkAgentEnv({
let env = buildSdkAgentEnv({
shellEnv,
requestedAgentEnv: normalizedAgentEnv,
withCliDiscoveryEnv,

View File

@@ -76,6 +76,7 @@ function createSessionOpsApi(ctx) {
async function getSessionPwd(event, payload) {
const { sessionId } = payload;
const allowHomeFallback = payload?.allowHomeFallback !== false;
const session = sessions.get(sessionId);
if (!session || !session.conn) {
@@ -98,12 +99,13 @@ function createSessionOpsApi(ctx) {
// 2. Follows foreground child shells only, which covers bash->fish
// without mistaking background shell scripts for the active shell.
// 3. Reads /proc/<pid>/cwd via readlink.
// 4. Falls back to the user's home directory if anything fails.
// 4. Falls back to the user's home directory if the caller allows it.
//
// `exec` makes sh replace the user's login shell (fish/bash/...)
// so sh keeps the same PID and $PPID = sshd. Starting another shell
// without exec would make $PPID point at the intermediate shell instead.
const posixScript = `SELF=$$
ALLOW_FALLBACK=${allowHomeFallback ? "1" : "0"}
# Find the user's interactive shell on this SSH connection.
# Prefer the one attached to a controlling tty (the user's shell): probe exec
# channels like this one have no tty ("?"), and ps output is unsorted, so
@@ -193,11 +195,12 @@ function createSessionOpsApi(ctx) {
# this unprivileged exec channel cannot read a su'd / sudo'd shell owned by
# another user. Fall back to the same-uid login shell's cwd before giving up
# to the home directory (#1065 review).
if [ -z "$cwd" ] && [ "$pid" != "$login" ]; then
if [ -z "$cwd" ] && [ "$pid" != "$login" ] && [ "$ALLOW_FALLBACK" = "1" ]; then
cwd=$(readlink /proc/$login/cwd 2>/dev/null)
fi
[ -n "$cwd" ] && printf '%s\\n' "$cwd" && exit 0
fi
[ "$ALLOW_FALLBACK" = "1" ] || exit 1
emit_home() {
case "$1" in
/*) printf '%s\\n' "$1"; exit 0 ;;

View File

@@ -59,8 +59,11 @@ function createPreloadApi(ctx) {
execCommand: async (options) => {
return ipcRenderer.invoke("netcatty:ssh:exec", options);
},
getSessionPwd: async (sessionId) => {
return ipcRenderer.invoke("netcatty:ssh:pwd", { sessionId });
getSessionPwd: async (sessionId, options) => {
return ipcRenderer.invoke("netcatty:ssh:pwd", {
sessionId,
allowHomeFallback: options?.allowHomeFallback,
});
},
getSessionRemoteInfo: async (sessionId) => {
return ipcRenderer.invoke("netcatty:ssh:remoteInfo", { sessionId });