mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 19:02:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5083a81681 | ||
|
|
2f701c1605 |
No files matched your search
@@ -489,7 +489,7 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
|
||||
'settings.sshDeepLink.title': 'SSH and Telnet links',
|
||||
'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps, plus PuTTY-style launches such as -ssh user@host -P 22 -pw password.',
|
||||
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.',
|
||||
'settings.explorerContextMenu.title': 'Windows Explorer',
|
||||
'settings.explorerContextMenu.enable': 'Show “Open in Netcatty” in folder context menus',
|
||||
'settings.explorerContextMenu.enableDesc': 'Adds a right-click item for folders and folder backgrounds so you can open a local terminal there. Applies immediately; no reinstall needed.',
|
||||
|
||||
@@ -489,7 +489,7 @@ export const esVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transferir el archivo al host activo del otro panel',
|
||||
'settings.sshDeepLink.title': 'Enlaces SSH y Telnet',
|
||||
'settings.sshDeepLink.enable': 'Abrir enlaces ssh:// y telnet:// con Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Permite que Netcatty maneje los enlaces ssh:// y telnet:// de los navegadores y otras aplicaciones, además de lanzamientos al estilo PuTTY como -ssh user@host -P 22 -pw password.',
|
||||
'settings.sshDeepLink.enableDesc': 'Permite que Netcatty maneje los enlaces ssh:// y telnet:// de los navegadores y otras aplicaciones.',
|
||||
'settings.explorerContextMenu.title': 'Explorador de Windows',
|
||||
'settings.explorerContextMenu.enable': 'Mostrar "Abrir en Netcatty" en los menús contextuales de carpetas',
|
||||
'settings.explorerContextMenu.enableDesc': 'Agrega un elemento de clic derecho para las carpetas y los fondos de carpeta, para que puedas abrir una terminal local allí. Se aplica de inmediato; no es necesario reinstalar.',
|
||||
|
||||
@@ -525,7 +525,7 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
|
||||
'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки',
|
||||
'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений, а также запуски в стиле PuTTY, например -ssh user@host -P 22 -pw password.',
|
||||
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.',
|
||||
'settings.explorerContextMenu.title': 'Проводник Windows',
|
||||
'settings.explorerContextMenu.enable': 'Показывать «Open in Netcatty» в контекстном меню папок',
|
||||
'settings.explorerContextMenu.enableDesc': 'Добавляет пункт в меню папок и фона папки, чтобы открыть локальный терминал в этом каталоге. Применяется сразу, переустановка не нужна.',
|
||||
|
||||
@@ -131,7 +131,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
|
||||
'settings.sshDeepLink.title': 'SSH 和 Telnet 链接',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接',
|
||||
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接,以及堡垒机常用的 PuTTY 启动参数(例如 -ssh user@host -P 22 -pw password)。',
|
||||
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。',
|
||||
'settings.explorerContextMenu.title': 'Windows 资源管理器',
|
||||
'settings.explorerContextMenu.enable': '在文件夹右键菜单中显示 “Open in Netcatty”',
|
||||
'settings.explorerContextMenu.enableDesc': '为文件夹和文件夹空白处添加右键项,可直接在该目录打开本地终端。开关立即生效,无需重装。',
|
||||
|
||||
@@ -131,7 +131,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '將檔案傳輸到另一窗格的使用中主機',
|
||||
'settings.sshDeepLink.title': 'SSH 和 Telnet 連結',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 和 telnet:// 連結',
|
||||
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結,以及堡壘機常用的 PuTTY 啟動參數(例如 -ssh user@host -P 22 -pw password)。',
|
||||
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結。',
|
||||
'settings.explorerContextMenu.title': 'Windows 檔案總管',
|
||||
'settings.explorerContextMenu.enable': '在資料夾右鍵選單中顯示 “Open in Netcatty”',
|
||||
'settings.explorerContextMenu.enableDesc': '為資料夾與資料夾空白處加入右鍵項目,可直接在該目錄開啟本機終端機。開關立即生效,無需重裝。',
|
||||
|
||||
@@ -272,15 +272,19 @@ function resolveWindowsShimToNativeExe(command, platform = process.platform) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function prepareCommandForSpawn(command, args) {
|
||||
function prepareCommandForSpawn(command, args, options = {}) {
|
||||
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 };
|
||||
// Cursor's Windows installer .cmd launches node.exe + index.js. Unwrapping
|
||||
// to the first quoted .exe would drop the script and run node with CLI args.
|
||||
if (options.unwrapNativeExe !== false) {
|
||||
const nativeExePath = resolveWindowsShimToNativeExe(command);
|
||||
if (nativeExePath) {
|
||||
return { command: nativeExePath, args: spawnArgs, shell: false };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -242,6 +242,45 @@ test("resolveWindowsShimToNativeExe resolves npm .cmd shim to native exe", () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("prepareCommandForSpawn can skip native exe unwrap for node+script shims", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-spawn-no-unwrap-"));
|
||||
try {
|
||||
const shimPath = path.join(tmp, "cursor-agent.cmd");
|
||||
const nodeExe = path.join(tmp, "versions", "2026.06.01-abc", "node.exe");
|
||||
fs.mkdirSync(path.dirname(nodeExe), { recursive: true });
|
||||
fs.writeFileSync(nodeExe, "", "utf8");
|
||||
fs.writeFileSync(
|
||||
shimPath,
|
||||
'@ECHO off\r\n"%~dp0\\versions\\2026.06.01-abc\\node.exe" "%~dp0\\versions\\2026.06.01-abc\\index.js" %*\r\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.equal(resolveWindowsShimToNativeExe(shimPath, "win32"), nodeExe);
|
||||
|
||||
const unwrapped = prepareCommandForSpawn(shimPath, ["status", "--format", "json"]);
|
||||
const wrapped = prepareCommandForSpawn(shimPath, ["status", "--format", "json"], {
|
||||
unwrapNativeExe: false,
|
||||
});
|
||||
if (process.platform === "win32") {
|
||||
assert.deepEqual(unwrapped, {
|
||||
command: nodeExe,
|
||||
args: ["status", "--format", "json"],
|
||||
shell: false,
|
||||
});
|
||||
assert.deepEqual(wrapped, {
|
||||
command: buildWindowsShellCommandLine(shimPath, ["status", "--format", "json"]),
|
||||
args: [],
|
||||
shell: true,
|
||||
});
|
||||
} else {
|
||||
assert.equal(unwrapped.shell, false);
|
||||
assert.equal(wrapped.shell, false);
|
||||
}
|
||||
} 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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ const { existsSync, readFileSync } = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { resolveCursorCliSpawnSpec } = require("./cursorCliSpawn.cjs");
|
||||
|
||||
function defaultFileExists(p) {
|
||||
try { return existsSync(p); } catch { return false; }
|
||||
@@ -156,12 +157,15 @@ function defaultResolveCursorCliBinary(name, env) {
|
||||
}
|
||||
|
||||
function defaultRunCursorStatus(binPath, env) {
|
||||
const spec = resolveCursorCliSpawnSpec(binPath, ["status", "--format", "json"]);
|
||||
try {
|
||||
const stdout = execFileSync(binPath, ["status", "--format", "json"], {
|
||||
const stdout = execFileSync(spec.command, spec.args, {
|
||||
encoding: "utf8",
|
||||
timeout: 8000,
|
||||
env: env || process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: spec.shell,
|
||||
windowsHide: true,
|
||||
});
|
||||
return { exitCode: 0, stdout: String(stdout || ""), stderr: "" };
|
||||
} catch (err) {
|
||||
@@ -173,19 +177,34 @@ function defaultRunCursorStatus(binPath, env) {
|
||||
}
|
||||
}
|
||||
|
||||
function extractFirstJsonObject(text) {
|
||||
const raw = String(text || "").replace(/^\uFEFF/, "").trim();
|
||||
if (!raw) return null;
|
||||
const candidates = [raw];
|
||||
const start = raw.indexOf("{");
|
||||
const end = raw.lastIndexOf("}");
|
||||
if (start >= 0 && end > start) {
|
||||
const sliced = raw.slice(start, end + 1);
|
||||
if (sliced !== raw) candidates.push(sliced);
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
if (parsed && typeof parsed === "object") return parsed;
|
||||
} catch { /* try next */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCursorStatusJson(stdout) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(stdout || "").trim());
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
// Real Cursor status always exposes isAuthenticated and/or status.
|
||||
// Reject unrelated CLIs that accept unknown flags or emit other JSON.
|
||||
if (typeof parsed.isAuthenticated !== "boolean" && typeof parsed.status !== "string") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
const parsed = extractFirstJsonObject(stdout);
|
||||
if (!parsed) return null;
|
||||
// Real Cursor status always exposes isAuthenticated and/or status.
|
||||
// Reject unrelated CLIs that accept unknown flags or emit other JSON.
|
||||
if (typeof parsed.isAuthenticated !== "boolean" && typeof parsed.status !== "string") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,4 +279,6 @@ module.exports = {
|
||||
CURSOR_CLI_BINARY_CANDIDATES,
|
||||
defaultRunSecurity,
|
||||
defaultRunGhAuthStatus,
|
||||
parseCursorStatusJson,
|
||||
resolveCursorCliSpawnSpec,
|
||||
};
|
||||
@@ -2,7 +2,10 @@ const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
probeClaudeAuth, probeCopilotAuth, probeCodexAuth, probeCodebuddyAuth, probeCursorCliAuth, probeGrokAuth,
|
||||
parseCursorStatusJson, resolveCursorCliSpawnSpec,
|
||||
} = require("./agentAuthProbes.cjs");
|
||||
const { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
|
||||
const { resolveCursorCliSpawnSpec: resolveSharedCursorCliSpawnSpec } = require("./cursorCliSpawn.cjs");
|
||||
|
||||
test("probeClaudeAuth: env ANTHROPIC_API_KEY -> authenticated env", () => {
|
||||
const r = probeClaudeAuth({
|
||||
@@ -271,3 +274,40 @@ test("probeCursorCliAuth: status command failure -> not authenticated", () => {
|
||||
});
|
||||
assert.equal(r.authenticated, false);
|
||||
});
|
||||
|
||||
test("probeCursorCliAuth: extracts authenticated JSON wrapped in cmd noise", () => {
|
||||
const r = probeCursorCliAuth({
|
||||
resolveBinary: () => "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd",
|
||||
runStatus: () => ({
|
||||
exitCode: 0,
|
||||
stdout: "Starting...\r\n{\"status\":\"authenticated\",\"isAuthenticated\":true,\"userInfo\":{\"email\":\"user@example.com\"}}\r\n",
|
||||
}),
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "cli-login");
|
||||
assert.equal(r.email, "user@example.com");
|
||||
});
|
||||
|
||||
test("parseCursorStatusJson accepts BOM and surrounding text", () => {
|
||||
const parsed = parseCursorStatusJson(
|
||||
"\uFEFFnoise\n{\"isAuthenticated\":true,\"status\":\"authenticated\"}\n",
|
||||
);
|
||||
assert.equal(parsed.isAuthenticated, true);
|
||||
assert.equal(parsed.status, "authenticated");
|
||||
});
|
||||
|
||||
test("resolveCursorCliSpawnSpec re-exports the shared native launch helper", () => {
|
||||
const shim = "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd";
|
||||
const args = ["status", "--format", "json"];
|
||||
assert.deepEqual(
|
||||
resolveCursorCliSpawnSpec(shim, args),
|
||||
resolveSharedCursorCliSpawnSpec(shim, args),
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveSharedCursorCliSpawnSpec(shim, args, {
|
||||
exists: () => false,
|
||||
readFile: () => { throw new Error("missing"); },
|
||||
}),
|
||||
prepareCommandForSpawn(shim, args, { unwrapNativeExe: false }),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Windows launch helper for the Cursor Agent installer shim.
|
||||
*
|
||||
* `%LOCALAPPDATA%\cursor-agent\cursor-agent.cmd` is a batch file that runs
|
||||
* `versions\<id>\node.exe` + `versions\<id>\index.js`. Node cannot spawn .cmd
|
||||
* directly (EINVAL). Routing the full turn prompt through cmd.exe is also
|
||||
* unsafe: 8191-char limit, %VAR% expansion, and broken quote escaping.
|
||||
*
|
||||
* Prefer the native node+script argv so prompts stay out of a shell.
|
||||
*/
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
|
||||
|
||||
function defaultExists(filePath) {
|
||||
try { return fs.existsSync(filePath); } catch { return false; }
|
||||
}
|
||||
|
||||
function defaultReadFile(filePath) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function defaultReaddir(dirPath) {
|
||||
return fs.readdirSync(dirPath);
|
||||
}
|
||||
|
||||
function defaultStat(filePath) {
|
||||
return fs.statSync(filePath);
|
||||
}
|
||||
|
||||
function expandWindowsShimPath(raw, shimDir) {
|
||||
const dp0 = /[\\/]$/.test(shimDir) ? shimDir : `${shimDir}${path.sep}`;
|
||||
let resolved = String(raw || "").replace(/%~dp0/gi, dp0);
|
||||
// Installer shims use Windows separators; normalize so existsSync works
|
||||
// when this helper is unit-tested on POSIX.
|
||||
resolved = resolved.replace(/\\/g, path.sep);
|
||||
if (!path.isAbsolute(resolved) && !path.win32.isAbsolute(resolved)) {
|
||||
resolved = path.resolve(shimDir, resolved);
|
||||
}
|
||||
return path.normalize(resolved);
|
||||
}
|
||||
|
||||
function parseCursorAgentCmdLaunch(shimPath, { exists, readFile } = {}) {
|
||||
const existsFn = exists || defaultExists;
|
||||
const readFn = readFile || defaultReadFile;
|
||||
let contents;
|
||||
try {
|
||||
contents = readFn(shimPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const match = String(contents || "").match(/"([^"\r\n]*node\.exe)"\s+"([^"\r\n]*index\.js)"/i);
|
||||
if (!match) return null;
|
||||
const shimDir = path.dirname(shimPath);
|
||||
const nodeExe = expandWindowsShimPath(match[1], shimDir);
|
||||
const script = expandWindowsShimPath(match[2], shimDir);
|
||||
if (!existsFn(nodeExe) || !existsFn(script)) return null;
|
||||
return { nodeExe, script };
|
||||
}
|
||||
|
||||
function resolveCursorAgentVersionsLaunch(installDir, { exists, readdir, stat } = {}) {
|
||||
const existsFn = exists || defaultExists;
|
||||
const readdirFn = readdir || defaultReaddir;
|
||||
const statFn = stat || defaultStat;
|
||||
const versionsDir = path.join(installDir, "versions");
|
||||
if (!existsFn(versionsDir)) return null;
|
||||
let names;
|
||||
try {
|
||||
names = readdirFn(versionsDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const candidates = [];
|
||||
for (const name of names) {
|
||||
const dir = path.join(versionsDir, name);
|
||||
const nodeExe = path.join(dir, "node.exe");
|
||||
const script = path.join(dir, "index.js");
|
||||
if (!existsFn(nodeExe) || !existsFn(script)) continue;
|
||||
let mtime = 0;
|
||||
try { mtime = Number(statFn(dir)?.mtimeMs) || 0; } catch { /* ignore */ }
|
||||
candidates.push({ name: String(name), nodeExe, script, mtime });
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
candidates.sort((a, b) => b.mtime - a.mtime || b.name.localeCompare(a.name));
|
||||
return { nodeExe: candidates[0].nodeExe, script: candidates[0].script };
|
||||
}
|
||||
|
||||
function resolveCursorAgentNativeLaunch(binPath, io = {}) {
|
||||
const normalized = String(binPath || "").trim();
|
||||
if (!normalized) return null;
|
||||
const ext = path.extname(normalized).toLowerCase();
|
||||
if (ext !== ".cmd" && ext !== ".bat") return null;
|
||||
|
||||
const fromShim = parseCursorAgentCmdLaunch(normalized, io);
|
||||
if (fromShim) return fromShim;
|
||||
|
||||
return resolveCursorAgentVersionsLaunch(path.dirname(normalized), io);
|
||||
}
|
||||
|
||||
function resolveCursorCliSpawnSpec(binPath, args, io = {}) {
|
||||
const command = String(binPath || "").trim();
|
||||
const spawnArgs = Array.isArray(args) ? args : [];
|
||||
const native = resolveCursorAgentNativeLaunch(command, io);
|
||||
if (native) {
|
||||
return {
|
||||
command: native.nodeExe,
|
||||
args: [native.script, ...spawnArgs],
|
||||
shell: false,
|
||||
};
|
||||
}
|
||||
// Last resort for unknown shims / short probes (status, models). Turns with
|
||||
// a long prompt should have resolved the official versions/ layout above.
|
||||
return prepareCommandForSpawn(command, spawnArgs, { unwrapNativeExe: false });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
expandWindowsShimPath,
|
||||
parseCursorAgentCmdLaunch,
|
||||
resolveCursorAgentNativeLaunch,
|
||||
resolveCursorAgentVersionsLaunch,
|
||||
resolveCursorCliSpawnSpec,
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
|
||||
const {
|
||||
expandWindowsShimPath,
|
||||
parseCursorAgentCmdLaunch,
|
||||
resolveCursorAgentNativeLaunch,
|
||||
resolveCursorAgentVersionsLaunch,
|
||||
resolveCursorCliSpawnSpec,
|
||||
} = require("./cursorCliSpawn.cjs");
|
||||
|
||||
function writeCursorAgentInstall(root, version = "2026.06.01-abc") {
|
||||
const versionDir = path.join(root, "versions", version);
|
||||
fs.mkdirSync(versionDir, { recursive: true });
|
||||
const nodeExe = path.join(versionDir, "node.exe");
|
||||
const script = path.join(versionDir, "index.js");
|
||||
fs.writeFileSync(nodeExe, "", "utf8");
|
||||
fs.writeFileSync(script, "", "utf8");
|
||||
const shimPath = path.join(root, "cursor-agent.cmd");
|
||||
fs.writeFileSync(
|
||||
shimPath,
|
||||
`@ECHO off\r\n"%~dp0\\versions\\${version}\\node.exe" "%~dp0\\versions\\${version}\\index.js" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
return { shimPath, nodeExe, script, versionDir };
|
||||
}
|
||||
|
||||
test("expandWindowsShimPath expands %~dp0 relative to the shim directory", () => {
|
||||
const shimDir = path.join("C:", "Users", "me", "AppData", "Local", "cursor-agent");
|
||||
const resolved = expandWindowsShimPath("%~dp0\\versions\\2026.06.01-abc\\node.exe", shimDir);
|
||||
assert.equal(
|
||||
path.normalize(resolved),
|
||||
path.normalize(path.join(shimDir, "versions", "2026.06.01-abc", "node.exe")),
|
||||
);
|
||||
});
|
||||
|
||||
test("parseCursorAgentCmdLaunch reads node.exe + index.js from the installer shim", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-shim-"));
|
||||
try {
|
||||
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
|
||||
const launch = parseCursorAgentCmdLaunch(shimPath);
|
||||
assert.deepEqual(launch, { nodeExe, script });
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCursorAgentVersionsLaunch picks the newest version directory", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-versions-"));
|
||||
try {
|
||||
const older = writeCursorAgentInstall(tmp, "2026.01.01-old");
|
||||
const newer = writeCursorAgentInstall(tmp, "2026.08.01-new");
|
||||
const olderTime = new Date("2026-01-01T00:00:00Z");
|
||||
const newerTime = new Date("2026-08-01T00:00:00Z");
|
||||
fs.utimesSync(older.versionDir, olderTime, olderTime);
|
||||
fs.utimesSync(newer.versionDir, newerTime, newerTime);
|
||||
|
||||
const launch = resolveCursorAgentVersionsLaunch(tmp);
|
||||
assert.deepEqual(launch, { nodeExe: newer.nodeExe, script: newer.script });
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCursorAgentNativeLaunch prefers the shim's node+script over a lone exe unwrap", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-native-"));
|
||||
try {
|
||||
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
|
||||
const launch = resolveCursorAgentNativeLaunch(shimPath);
|
||||
assert.deepEqual(launch, { nodeExe, script });
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCursorCliSpawnSpec puts the prompt on argv, not a cmd.exe line", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-spawn-"));
|
||||
try {
|
||||
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
|
||||
const prompt = 'do "%USERPROFILE%" and `whoami` then say hello';
|
||||
const spec = resolveCursorCliSpawnSpec(shimPath, ["--print", "--trust", prompt]);
|
||||
assert.deepEqual(spec, {
|
||||
command: nodeExe,
|
||||
args: [script, "--print", "--trust", prompt],
|
||||
shell: false,
|
||||
});
|
||||
assert.equal(spec.command.includes("cmd.exe"), false);
|
||||
assert.equal(spec.args.includes(prompt), true);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCursorCliSpawnSpec falls back to the cmd shim when versions/ is missing", () => {
|
||||
const shim = "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd";
|
||||
const args = ["status", "--format", "json"];
|
||||
const spec = resolveCursorCliSpawnSpec(shim, args, {
|
||||
exists: () => false,
|
||||
readFile: () => { throw new Error("missing"); },
|
||||
});
|
||||
assert.deepEqual(spec, prepareCommandForSpawn(shim, args, { unwrapNativeExe: false }));
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const { spawn } = require("node:child_process");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { resolveCursorCliSpawnSpec } = require("../cursorCliSpawn.cjs");
|
||||
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
|
||||
|
||||
const DEFAULT_CURSOR_CLI_MODEL = "auto";
|
||||
@@ -56,6 +57,15 @@ function stripCursorApiKeyFromEnv(env) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function spawnCursorCliProcess(spawnImpl, cliPath, args, options = {}) {
|
||||
const spawnFn = spawnImpl || spawn;
|
||||
const spawnSpec = resolveCursorCliSpawnSpec(cliPath, args);
|
||||
return spawnFn(spawnSpec.command, spawnSpec.args, {
|
||||
...options,
|
||||
shell: spawnSpec.shell,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCursorCliModel(model) {
|
||||
const raw = String(model || "").trim();
|
||||
return raw || DEFAULT_CURSOR_CLI_MODEL;
|
||||
@@ -520,7 +530,6 @@ async function runCursorCliTurn({
|
||||
failed: false,
|
||||
};
|
||||
|
||||
const spawnFn = spawnImpl || spawn;
|
||||
let child = null;
|
||||
let settled = false;
|
||||
|
||||
@@ -529,7 +538,7 @@ async function runCursorCliTurn({
|
||||
};
|
||||
|
||||
try {
|
||||
child = spawnFn(cliPath, args, {
|
||||
child = spawnCursorCliProcess(spawnImpl, cliPath, args, {
|
||||
cwd: effectiveCwd,
|
||||
env: childEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
@@ -667,7 +676,6 @@ async function listCursorCliModels({
|
||||
if (abortSignal?.aborted) return { currentModelId: null, models: [] };
|
||||
|
||||
const childEnv = stripCursorApiKeyFromEnv(env || process.env);
|
||||
const spawnFn = spawnImpl || spawn;
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
let stdout = "";
|
||||
@@ -690,7 +698,7 @@ async function listCursorCliModels({
|
||||
|
||||
let child;
|
||||
try {
|
||||
child = spawnFn(cliPath, ["models"], {
|
||||
child = spawnCursorCliProcess(spawnImpl, cliPath, ["models"], {
|
||||
env: childEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
@@ -771,8 +779,10 @@ module.exports = {
|
||||
resetMcpMergeRefcountsForTests,
|
||||
resolveCursorCliExecMode,
|
||||
resolveCursorCliModel,
|
||||
resolveCursorCliSpawnSpec,
|
||||
resolveCursorCliWorkspaceCwd,
|
||||
runCursorCliTurn,
|
||||
spawnCursorCliProcess,
|
||||
stripCursorApiKeyFromEnv,
|
||||
translateCursorCliEvent,
|
||||
};
|
||||
@@ -13,8 +13,10 @@ const {
|
||||
resetMcpMergeRefcountsForTests,
|
||||
resolveCursorCliExecMode,
|
||||
resolveCursorCliModel,
|
||||
resolveCursorCliSpawnSpec,
|
||||
resolveCursorCliWorkspaceCwd,
|
||||
runCursorCliTurn,
|
||||
spawnCursorCliProcess,
|
||||
stripCursorApiKeyFromEnv,
|
||||
translateCursorCliEvent,
|
||||
} = require("./cursorCliDriver.cjs");
|
||||
@@ -587,6 +589,83 @@ test("runCursorCliTurn closes open reasoning before done", async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("resolveCursorCliSpawnSpec keeps a native exe on argv without a shell", () => {
|
||||
const exePath = process.platform === "win32"
|
||||
? "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.exe"
|
||||
: "/usr/local/bin/cursor-agent";
|
||||
const args = ["--print", "--trust"];
|
||||
const exe = resolveCursorCliSpawnSpec(exePath, args);
|
||||
assert.equal(exe.shell, false);
|
||||
assert.equal(exe.command, exePath);
|
||||
assert.deepEqual(exe.args, args);
|
||||
});
|
||||
|
||||
test("spawnCursorCliProcess launches the installer node+script with the prompt on argv", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-turn-spawn-"));
|
||||
try {
|
||||
const versionDir = path.join(tmp, "versions", "2026.06.01-abc");
|
||||
fs.mkdirSync(versionDir, { recursive: true });
|
||||
const nodeExe = path.join(versionDir, "node.exe");
|
||||
const script = path.join(versionDir, "index.js");
|
||||
fs.writeFileSync(nodeExe, "", "utf8");
|
||||
fs.writeFileSync(script, "", "utf8");
|
||||
const shimPath = path.join(tmp, "cursor-agent.cmd");
|
||||
fs.writeFileSync(
|
||||
shimPath,
|
||||
`@ECHO off\r\n"%~dp0\\versions\\2026.06.01-abc\\node.exe" "%~dp0\\versions\\2026.06.01-abc\\index.js" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const prompt = 'review "%TEMP%" then run whoami';
|
||||
const calls = [];
|
||||
spawnCursorCliProcess(
|
||||
(command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return { stdout: { on() {} }, stderr: { on() {} }, on() {}, kill() {} };
|
||||
},
|
||||
shimPath,
|
||||
["--print", "--trust", prompt],
|
||||
{ windowsHide: true },
|
||||
);
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].command, nodeExe);
|
||||
assert.deepEqual(calls[0].args, [script, "--print", "--trust", prompt]);
|
||||
assert.equal(calls[0].options.shell, false);
|
||||
assert.equal(String(calls[0].command).includes("cmd.exe"), false);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("spawnCursorCliProcess forwards shell from resolveCursorCliSpawnSpec", () => {
|
||||
const calls = [];
|
||||
const fakeChild = {
|
||||
stdout: { on() {} },
|
||||
stderr: { on() {} },
|
||||
stdin: null,
|
||||
on() {},
|
||||
kill() {},
|
||||
};
|
||||
const cliPath = "/usr/local/bin/cursor-agent";
|
||||
const child = spawnCursorCliProcess(
|
||||
(command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return fakeChild;
|
||||
},
|
||||
cliPath,
|
||||
["models"],
|
||||
{ cwd: "/repo", windowsHide: true },
|
||||
);
|
||||
assert.equal(child, fakeChild);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].command, cliPath);
|
||||
assert.deepEqual(calls[0].args, ["models"]);
|
||||
assert.equal(calls[0].options.cwd, "/repo");
|
||||
assert.equal(calls[0].options.windowsHide, true);
|
||||
assert.equal(calls[0].options.shell, false);
|
||||
});
|
||||
|
||||
test("listCursorCliModels parses agent models output and prefers auto", async () => {
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
parsePuttyCommandLine,
|
||||
redactPuttyCommandLinePasswords,
|
||||
} = require("./puttyCommandLine.cjs");
|
||||
|
||||
const SSH_DEEP_LINK_CHANNEL = "netcatty:deepLink:ssh";
|
||||
const TELNET_DEEP_LINK_CHANNEL = "netcatty:deepLink:telnet";
|
||||
@@ -48,23 +44,6 @@ function collectJmsDeepLinkUrls(argv) {
|
||||
return collectDeepLinkUrls(argv, JMS_PROTOCOL);
|
||||
}
|
||||
|
||||
function collectPuttyStyleDeepLinkUrls(argv) {
|
||||
if (
|
||||
collectSshDeepLinkUrls(argv).length > 0
|
||||
|| collectTelnetDeepLinkUrls(argv).length > 0
|
||||
|| collectJmsDeepLinkUrls(argv).length > 0
|
||||
) {
|
||||
return { ssh: [], telnet: [] };
|
||||
}
|
||||
|
||||
const parsed = parsePuttyCommandLine(argv);
|
||||
if (!parsed?.url) return { ssh: [], telnet: [] };
|
||||
if (parsed.protocol === TELNET_PROTOCOL) {
|
||||
return { ssh: [], telnet: [parsed.url] };
|
||||
}
|
||||
return { ssh: [parsed.url], telnet: [] };
|
||||
}
|
||||
|
||||
function registerProtocolClient({
|
||||
app,
|
||||
protocol,
|
||||
@@ -379,10 +358,8 @@ module.exports = {
|
||||
applyJmsProtocolClientPreference,
|
||||
applySshProtocolClientPreference,
|
||||
collectJmsDeepLinkUrls,
|
||||
collectPuttyStyleDeepLinkUrls,
|
||||
collectSshDeepLinkUrls,
|
||||
collectTelnetDeepLinkUrls,
|
||||
redactPuttyCommandLinePasswords,
|
||||
isJmsDeepLinkUrl,
|
||||
isSshDeepLinkUrl,
|
||||
isTelnetDeepLinkUrl,
|
||||
|
||||
@@ -7,7 +7,6 @@ const {
|
||||
applyJmsProtocolClientPreference,
|
||||
applySshProtocolClientPreference,
|
||||
collectJmsDeepLinkUrls,
|
||||
collectPuttyStyleDeepLinkUrls,
|
||||
collectSshDeepLinkUrls,
|
||||
collectTelnetDeepLinkUrls,
|
||||
isJmsDeepLinkUrl,
|
||||
@@ -44,34 +43,6 @@ test("collectSshDeepLinkUrls extracts ssh URLs from process arguments", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("collectPuttyStyleDeepLinkUrls converts PuTTY argv when no ssh:// token is present", () => {
|
||||
assert.deepEqual(
|
||||
collectPuttyStyleDeepLinkUrls([
|
||||
String.raw`C:\Program Files\Netcatty\Netcatty.exe`,
|
||||
"-ssh",
|
||||
"alice@10.0.0.8",
|
||||
"-P",
|
||||
"2222",
|
||||
"-pw",
|
||||
"s3cret",
|
||||
]),
|
||||
{ ssh: ["ssh://alice:s3cret@10.0.0.8:2222"], telnet: [] },
|
||||
);
|
||||
});
|
||||
|
||||
test("collectPuttyStyleDeepLinkUrls leaves ssh:// tokens to the existing collector", () => {
|
||||
assert.deepEqual(
|
||||
collectPuttyStyleDeepLinkUrls([
|
||||
"Netcatty.exe",
|
||||
"-url",
|
||||
"ssh://alice@example.com",
|
||||
"-ssh",
|
||||
"ignored@host",
|
||||
]),
|
||||
{ ssh: [], telnet: [] },
|
||||
);
|
||||
});
|
||||
|
||||
test("applySshProtocolClientPreference registers or removes ssh and telnet handlers", () => {
|
||||
const calls = [];
|
||||
const app = {
|
||||
|
||||
+4
-26
@@ -72,10 +72,8 @@ const {
|
||||
applyJmsProtocolClientPreference,
|
||||
applySshProtocolClientPreference,
|
||||
collectJmsDeepLinkUrls,
|
||||
collectPuttyStyleDeepLinkUrls,
|
||||
collectSshDeepLinkUrls,
|
||||
collectTelnetDeepLinkUrls,
|
||||
redactPuttyCommandLinePasswords,
|
||||
isJmsDeepLinkUrl,
|
||||
isSshDeepLinkUrl,
|
||||
isTelnetDeepLinkUrl,
|
||||
@@ -582,18 +580,8 @@ async function createAndShowMainWindow() {
|
||||
}
|
||||
|
||||
let sshDeepLinkEnabled = readSshDeepLinkEnabledPreference({ app });
|
||||
const puttyStyleDeepLinks = sshDeepLinkEnabled
|
||||
? collectPuttyStyleDeepLinkUrls(process.argv)
|
||||
: { ssh: [], telnet: [] };
|
||||
const pendingSshDeepLinkUrls = sshDeepLinkEnabled
|
||||
? [...collectSshDeepLinkUrls(process.argv), ...puttyStyleDeepLinks.ssh]
|
||||
: [];
|
||||
const pendingTelnetDeepLinkUrls = sshDeepLinkEnabled
|
||||
? [...collectTelnetDeepLinkUrls(process.argv), ...puttyStyleDeepLinks.telnet]
|
||||
: [];
|
||||
if (sshDeepLinkEnabled) {
|
||||
redactPuttyCommandLinePasswords(process.argv);
|
||||
}
|
||||
const pendingSshDeepLinkUrls = sshDeepLinkEnabled ? collectSshDeepLinkUrls(process.argv) : [];
|
||||
const pendingTelnetDeepLinkUrls = sshDeepLinkEnabled ? collectTelnetDeepLinkUrls(process.argv) : [];
|
||||
const pendingOpenTerminalPaths = resolveOpenTerminalPathsFromArgs(process.argv);
|
||||
let flushingSshDeepLinks = false;
|
||||
let flushingTelnetDeepLinks = false;
|
||||
@@ -994,18 +982,8 @@ if (!gotLock) {
|
||||
|
||||
app.on("second-instance", (_event, argv, workingDirectory) => {
|
||||
const jmsDeepLinkUrls = collectJmsDeepLinkUrls(argv);
|
||||
const puttyStyleDeepLinks = collectPuttyStyleDeepLinkUrls(argv);
|
||||
const telnetDeepLinkUrls = [
|
||||
...collectTelnetDeepLinkUrls(argv),
|
||||
...puttyStyleDeepLinks.telnet,
|
||||
];
|
||||
const sshDeepLinkUrls = [
|
||||
...collectSshDeepLinkUrls(argv),
|
||||
...puttyStyleDeepLinks.ssh,
|
||||
];
|
||||
if (jmsDeepLinkUrls.length > 0 || sshDeepLinkUrls.length > 0 || telnetDeepLinkUrls.length > 0) {
|
||||
redactPuttyCommandLinePasswords(argv);
|
||||
}
|
||||
const telnetDeepLinkUrls = collectTelnetDeepLinkUrls(argv);
|
||||
const sshDeepLinkUrls = collectSshDeepLinkUrls(argv);
|
||||
if (jmsDeepLinkUrls.length > 0) {
|
||||
if (jmsDeepLinkEnabled) {
|
||||
jmsDeepLinkUrls.forEach(queueJmsDeepLink);
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
const SSH_PROTOCOL = "ssh";
|
||||
const TELNET_PROTOCOL = "telnet";
|
||||
|
||||
const PROTOCOL_FLAGS = new Map([
|
||||
["-ssh", SSH_PROTOCOL],
|
||||
["-telnet", TELNET_PROTOCOL],
|
||||
]);
|
||||
const UNSUPPORTED_PROTOCOL_FLAGS = new Set([
|
||||
"-raw", "-rlogin", "-serial", "-ssh-connection", "-supdup",
|
||||
]);
|
||||
|
||||
const VALUE_FLAGS = new Set([
|
||||
"-P", "-p", "-l", "-pw", "-pwfile",
|
||||
"-load", "-i", "-m", "-cmd",
|
||||
"-L", "-R", "-D", "-nc",
|
||||
"-hostkey", "-loghost",
|
||||
"-sercfg", "-sessionlog", "-sshlog", "-sshrawlog", "-proxycmd",
|
||||
"-newtab", "-title", "-url",
|
||||
]);
|
||||
const REJECT_VALUE_FLAGS = new Set(["-pwfile", "-i", "-hostkey", "-loghost"]);
|
||||
const PORT_FLAGS = new Set(["-P", "-p"]);
|
||||
|
||||
const SKIP_FLAGS = new Set([
|
||||
"-1", "-2", "-4", "-6", "-ipv4", "-ipv6",
|
||||
"-A", "-a",
|
||||
"-C",
|
||||
"-N",
|
||||
"-T", "-t",
|
||||
"-X", "-x",
|
||||
"-v",
|
||||
"-agent", "-pagent", "-pageant",
|
||||
"-noagent", "-nopagent", "-nopageant",
|
||||
"-share", "-noshare",
|
||||
"-batch",
|
||||
"-nethack",
|
||||
"-pgpfp",
|
||||
"-cleanup",
|
||||
"-help",
|
||||
"-no-antispoof",
|
||||
"-sanitise-stderr", "-sanitise-stdout",
|
||||
"-no-sanitise-stderr", "-no-sanitise-stdout",
|
||||
"-restrict-acl", "-restrict_acl", "-restrictacl",
|
||||
]);
|
||||
|
||||
function parsePort(raw) {
|
||||
if (typeof raw !== "string" || !raw.trim()) return null;
|
||||
const port = Number(raw.trim());
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
||||
return port;
|
||||
}
|
||||
|
||||
function looksLikeIpv4(value) {
|
||||
return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value);
|
||||
}
|
||||
|
||||
function looksLikeIpv6(value) {
|
||||
return (value.match(/:/g) || []).length >= 2;
|
||||
}
|
||||
|
||||
function isElectronExecutableName(arg) {
|
||||
if (typeof arg !== "string") return false;
|
||||
const base = arg.replace(/^.*[/\\]/, "").toLowerCase();
|
||||
return base === "electron" || base === "electron.exe";
|
||||
}
|
||||
|
||||
function isElectronNoiseArg(arg, index, argv) {
|
||||
if (typeof arg !== "string") return true;
|
||||
if (index === 0) return true;
|
||||
if (arg === "." || arg === "--") return true;
|
||||
if (arg.startsWith("--")) return true;
|
||||
// Dev argv is [electron, main.cjs|app.asar, ...]; packaged argv is [Netcatty.exe, ...user args].
|
||||
if (
|
||||
index === 1
|
||||
&& isElectronExecutableName(argv?.[0])
|
||||
&& /\.(?:js|cjs|mjs|asar)$/i.test(arg)
|
||||
&& !arg.includes("@")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if ((arg.includes("/") || arg.includes("\\")) && !arg.includes("@")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseHostSpec(raw) {
|
||||
if (typeof raw !== "string") return null;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || trimmed.startsWith("-")) return null;
|
||||
|
||||
let username;
|
||||
let hostPart = trimmed;
|
||||
const at = trimmed.lastIndexOf("@");
|
||||
if (at >= 0) {
|
||||
username = trimmed.slice(0, at).trim();
|
||||
hostPart = trimmed.slice(at + 1).trim();
|
||||
if (!username) username = undefined;
|
||||
}
|
||||
if (!hostPart) return null;
|
||||
|
||||
let hostname;
|
||||
let port;
|
||||
if (hostPart.startsWith("[")) {
|
||||
const end = hostPart.indexOf("]");
|
||||
if (end < 0) return null;
|
||||
hostname = hostPart.slice(1, end).trim();
|
||||
const rest = hostPart.slice(end + 1);
|
||||
if (rest) {
|
||||
if (!rest.startsWith(":")) return null;
|
||||
port = parsePort(rest.slice(1));
|
||||
if (port === null) return null;
|
||||
}
|
||||
} else if (!looksLikeIpv6(hostPart) && hostPart.includes(":")) {
|
||||
const colon = hostPart.lastIndexOf(":");
|
||||
hostname = hostPart.slice(0, colon).trim();
|
||||
port = parsePort(hostPart.slice(colon + 1));
|
||||
if (port === null) return null;
|
||||
} else {
|
||||
hostname = hostPart.replace(/^\[(.*)\]$/, "$1").trim();
|
||||
}
|
||||
|
||||
if (!hostname) return null;
|
||||
return {
|
||||
...(username ? { username } : {}),
|
||||
hostname,
|
||||
...(port ? { port } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function hostCandidateScore(spec) {
|
||||
if (!spec) return 0;
|
||||
let score = 1;
|
||||
if (spec.username) score += 4;
|
||||
if (spec.port) score += 2;
|
||||
if (looksLikeIpv4(spec.hostname) || looksLikeIpv6(spec.hostname)) score += 3;
|
||||
else if (spec.hostname.includes(".")) score += 2;
|
||||
return score;
|
||||
}
|
||||
|
||||
function encodeUserInfo(username, password) {
|
||||
if (username) {
|
||||
const user = encodeURIComponent(username);
|
||||
return password !== undefined ? `${user}:${encodeURIComponent(password)}` : user;
|
||||
}
|
||||
if (password !== undefined) return `:${encodeURIComponent(password)}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatHostnameForUrl(hostname) {
|
||||
return hostname.includes(":") ? `[${hostname}]` : hostname;
|
||||
}
|
||||
|
||||
function toDeepLinkUrl({ protocol, username, password, hostname, port }) {
|
||||
const auth = encodeUserInfo(username, password);
|
||||
const host = formatHostnameForUrl(hostname);
|
||||
const portPart = port ? `:${port}` : "";
|
||||
return `${protocol}://${auth ? `${auth}@` : ""}${host}${portPart}`;
|
||||
}
|
||||
|
||||
function hasPuttyLaunchSignal(argv) {
|
||||
if (!Array.isArray(argv)) return false;
|
||||
return argv.some((arg) => {
|
||||
if (typeof arg !== "string") return false;
|
||||
return PROTOCOL_FLAGS.has(arg) || arg === "-pw" || arg === "-pwfile" || arg === "-l" || PORT_FLAGS.has(arg);
|
||||
});
|
||||
}
|
||||
|
||||
function parsePuttyCommandLine(argv) {
|
||||
if (!Array.isArray(argv) || !hasPuttyLaunchSignal(argv)) return null;
|
||||
|
||||
let protocol = SSH_PROTOCOL;
|
||||
let username;
|
||||
let password;
|
||||
let port;
|
||||
const positionals = [];
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (typeof arg !== "string" || !arg) continue;
|
||||
if (isElectronNoiseArg(arg, index, argv)) continue;
|
||||
|
||||
const protocolFromFlag = PROTOCOL_FLAGS.get(arg);
|
||||
if (protocolFromFlag) {
|
||||
protocol = protocolFromFlag;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (UNSUPPORTED_PROTOCOL_FLAGS.has(arg)) return null;
|
||||
|
||||
if (SKIP_FLAGS.has(arg)) continue;
|
||||
|
||||
if (VALUE_FLAGS.has(arg)) {
|
||||
const value = argv[index + 1];
|
||||
if (typeof value !== "string") return null;
|
||||
index += 1;
|
||||
if (REJECT_VALUE_FLAGS.has(arg)) return null;
|
||||
if (PORT_FLAGS.has(arg)) {
|
||||
const parsedPort = parsePort(value);
|
||||
if (parsedPort === null) return null;
|
||||
port = parsedPort;
|
||||
continue;
|
||||
}
|
||||
if (arg === "-l") {
|
||||
const nextUser = value.trim();
|
||||
if (!nextUser) return null;
|
||||
username = nextUser;
|
||||
continue;
|
||||
}
|
||||
if (arg === "-pw") {
|
||||
if (value === "") return null;
|
||||
password = value;
|
||||
continue;
|
||||
}
|
||||
if (!value.trim()) return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("-")) return null;
|
||||
|
||||
const positionalPort = parsePort(arg);
|
||||
if (positionalPort !== null && /^\d+$/.test(arg.trim())) {
|
||||
if (port === undefined) port = positionalPort;
|
||||
continue;
|
||||
}
|
||||
|
||||
const spec = parseHostSpec(arg);
|
||||
if (spec) {
|
||||
positionals.push(spec);
|
||||
}
|
||||
}
|
||||
|
||||
if (positionals.length === 0) return null;
|
||||
|
||||
const hostSpec = positionals.reduce((best, candidate) => (
|
||||
hostCandidateScore(candidate) > hostCandidateScore(best) ? candidate : best
|
||||
));
|
||||
|
||||
const resolvedUsername = (username || hostSpec.username || "").trim() || undefined;
|
||||
const resolvedPort = port ?? hostSpec.port;
|
||||
const hostname = hostSpec.hostname;
|
||||
if (!hostname) return null;
|
||||
|
||||
const url = toDeepLinkUrl({
|
||||
protocol,
|
||||
username: resolvedUsername,
|
||||
password,
|
||||
hostname,
|
||||
port: resolvedPort,
|
||||
});
|
||||
|
||||
return {
|
||||
protocol,
|
||||
url,
|
||||
hostname,
|
||||
...(resolvedUsername ? { username: resolvedUsername } : {}),
|
||||
...(password !== undefined ? { password } : {}),
|
||||
...(resolvedPort ? { port: resolvedPort } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function redactPuttyCommandLinePasswords(argv) {
|
||||
if (!Array.isArray(argv)) return argv;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] !== "-pw") continue;
|
||||
const next = argv[index + 1];
|
||||
if (typeof next !== "string") continue;
|
||||
argv[index + 1] = "*".repeat(Math.min(next.length, 8)) || "********";
|
||||
}
|
||||
return argv;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parsePuttyCommandLine,
|
||||
redactPuttyCommandLinePasswords,
|
||||
};
|
||||
@@ -1,327 +0,0 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
parsePuttyCommandLine,
|
||||
redactPuttyCommandLinePasswords,
|
||||
} = require("./puttyCommandLine.cjs");
|
||||
|
||||
test("parsePuttyCommandLine accepts JumpServer PuTTY template", () => {
|
||||
assert.deepEqual(
|
||||
parsePuttyCommandLine([
|
||||
String.raw`C:\Program Files\Netcatty\Netcatty.exe`,
|
||||
"-ssh",
|
||||
"alice@10.0.0.8",
|
||||
"-P",
|
||||
"2222",
|
||||
"-pw",
|
||||
"s3cret",
|
||||
]),
|
||||
{
|
||||
protocol: "ssh",
|
||||
url: "ssh://alice:s3cret@10.0.0.8:2222",
|
||||
hostname: "10.0.0.8",
|
||||
username: "alice",
|
||||
password: "s3cret",
|
||||
port: 2222,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine accepts -l username and mixed flag order", () => {
|
||||
const parsed = parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"bastion.example.com",
|
||||
"-P",
|
||||
"22",
|
||||
"-l",
|
||||
"ops",
|
||||
"-pw",
|
||||
"hunter2",
|
||||
"-ssh",
|
||||
]);
|
||||
assert.equal(parsed?.url, "ssh://ops:hunter2@bastion.example.com:22");
|
||||
assert.equal(parsed?.username, "ops");
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine prefers user@host over -newtab session names", () => {
|
||||
const parsed = parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-newtab",
|
||||
"Production",
|
||||
"-ssh",
|
||||
"root@192.168.10.2",
|
||||
"-P",
|
||||
"22",
|
||||
"-pw",
|
||||
"one-time",
|
||||
]);
|
||||
assert.equal(parsed?.hostname, "192.168.10.2");
|
||||
assert.equal(parsed?.username, "root");
|
||||
assert.equal(parsed?.url, "ssh://root:one-time@192.168.10.2:22");
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine percent-encodes passwords for ssh:// round-trip", () => {
|
||||
const parsed = parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"alice@example.com",
|
||||
"-pw",
|
||||
"p@ss:w/rd",
|
||||
]);
|
||||
assert.equal(parsed?.url, "ssh://alice:p%40ss%3Aw%2Frd@example.com");
|
||||
assert.equal(parsed?.password, "p@ss:w/rd");
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine accepts IPv6 hosts and optional positional port", () => {
|
||||
const parsed = parsePuttyCommandLine([
|
||||
"electron",
|
||||
".",
|
||||
"-ssh",
|
||||
"bob@[2001:db8::10]",
|
||||
"2200",
|
||||
]);
|
||||
assert.equal(parsed?.hostname, "2001:db8::10");
|
||||
assert.equal(parsed?.port, 2200);
|
||||
assert.equal(parsed?.url, "ssh://bob@[2001:db8::10]:2200");
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine accepts user@host:port without -P", () => {
|
||||
const parsed = parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"alice@bastion.example.com:2222",
|
||||
"-pw",
|
||||
"token",
|
||||
]);
|
||||
assert.equal(parsed?.url, "ssh://alice:token@bastion.example.com:2222");
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine accepts telnet launches", () => {
|
||||
const parsed = parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-telnet",
|
||||
"router.lab",
|
||||
"-P",
|
||||
"23",
|
||||
]);
|
||||
assert.equal(parsed?.protocol, "telnet");
|
||||
assert.equal(parsed?.url, "telnet://router.lab:23");
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine ignores Chromium flags and missing hosts", () => {
|
||||
assert.equal(parsePuttyCommandLine(["Netcatty.exe", "--enable-logging", "-ssh"]), null);
|
||||
assert.equal(parsePuttyCommandLine(["Netcatty.exe", "just-a-file.txt"]), null);
|
||||
assert.equal(parsePuttyCommandLine(["Netcatty.exe", "-P", "22"]), null);
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine rejects invalid ports", () => {
|
||||
assert.equal(parsePuttyCommandLine(["Netcatty.exe", "-ssh", "host.example", "-P", "99999"]), null);
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine does not treat -m operands as the destination host", () => {
|
||||
const parsed = parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"-l",
|
||||
"alice",
|
||||
"server.example",
|
||||
"-m",
|
||||
"commands.txt",
|
||||
"-pw",
|
||||
"secret",
|
||||
]);
|
||||
assert.equal(parsed?.hostname, "server.example");
|
||||
assert.equal(parsed?.username, "alice");
|
||||
assert.equal(parsed?.url, "ssh://alice:secret@server.example");
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine does not treat -D operands as the destination port", () => {
|
||||
const parsed = parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"alice@server.example",
|
||||
"-D",
|
||||
"1080",
|
||||
"-pw",
|
||||
"secret",
|
||||
]);
|
||||
assert.equal(parsed?.hostname, "server.example");
|
||||
assert.equal(parsed?.port, undefined);
|
||||
assert.equal(parsed?.url, "ssh://alice:secret@server.example");
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine consumes operands of known value-taking flags such as -cmd", () => {
|
||||
const parsed = parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"alice@server.example",
|
||||
"-cmd",
|
||||
"whoami",
|
||||
"-pw",
|
||||
"secret",
|
||||
]);
|
||||
assert.equal(parsed?.hostname, "server.example");
|
||||
assert.equal(parsed?.url, "ssh://alice:secret@server.example");
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine rejects unsupported PuTTY protocol selectors", () => {
|
||||
assert.equal(parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"server.example",
|
||||
"-raw",
|
||||
"-P",
|
||||
"8000",
|
||||
"-pw",
|
||||
"secret",
|
||||
]), null);
|
||||
assert.equal(parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-serial",
|
||||
"COM1",
|
||||
"-P",
|
||||
"22",
|
||||
]), null);
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine keeps the host after valueless options such as -batch", () => {
|
||||
const parsed = parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"-batch",
|
||||
"alice@server.example",
|
||||
"-pw",
|
||||
"secret",
|
||||
]);
|
||||
assert.equal(parsed?.url, "ssh://alice:secret@server.example");
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine rejects unknown dash flags", () => {
|
||||
assert.equal(parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"-not-a-putty-flag",
|
||||
"alice@server.example",
|
||||
]), null);
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine rejects missing or empty option operands", () => {
|
||||
assert.equal(parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"alice@server.example",
|
||||
"-pw",
|
||||
]), null);
|
||||
assert.equal(parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"alice@server.example",
|
||||
"-pw",
|
||||
"",
|
||||
]), null);
|
||||
assert.equal(parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"alice@server.example",
|
||||
"-P",
|
||||
]), null);
|
||||
assert.equal(parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"server.example",
|
||||
"-l",
|
||||
"",
|
||||
"-pw",
|
||||
"secret",
|
||||
]), null);
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine rejects unsupported authentication and host-key overrides", () => {
|
||||
assert.equal(parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"alice@server.example",
|
||||
"-pwfile",
|
||||
"secret.txt",
|
||||
]), null);
|
||||
assert.equal(parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"alice@server.example",
|
||||
"-i",
|
||||
"id_rsa.ppk",
|
||||
"-pw",
|
||||
"secret",
|
||||
]), null);
|
||||
assert.equal(parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"alice@server.example",
|
||||
"-hostkey",
|
||||
"aa:bb:cc:dd",
|
||||
"-pw",
|
||||
"secret",
|
||||
]), null);
|
||||
assert.equal(parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"alice@server.example",
|
||||
"-loghost",
|
||||
"logical.example",
|
||||
"-pw",
|
||||
"secret",
|
||||
]), null);
|
||||
});
|
||||
|
||||
test("parsePuttyCommandLine accepts destinations with executable-like suffixes", () => {
|
||||
const parsed = parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"alice@prod.app",
|
||||
"-pw",
|
||||
"secret",
|
||||
]);
|
||||
assert.equal(parsed?.hostname, "prod.app");
|
||||
assert.equal(parsed?.url, "ssh://alice:secret@prod.app");
|
||||
|
||||
const positional = parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"gateway.exe",
|
||||
"-ssh",
|
||||
"-l",
|
||||
"alice",
|
||||
"-pw",
|
||||
"secret",
|
||||
]);
|
||||
assert.equal(positional?.hostname, "gateway.exe");
|
||||
assert.equal(positional?.url, "ssh://alice:secret@gateway.exe");
|
||||
|
||||
const scriptLikeHost = parsePuttyCommandLine([
|
||||
"Netcatty.exe",
|
||||
"prod.js",
|
||||
"-ssh",
|
||||
"-l",
|
||||
"alice",
|
||||
"-pw",
|
||||
"secret",
|
||||
]);
|
||||
assert.equal(scriptLikeHost?.hostname, "prod.js");
|
||||
assert.equal(scriptLikeHost?.url, "ssh://alice:secret@prod.js");
|
||||
|
||||
const fromElectron = parsePuttyCommandLine([
|
||||
"electron",
|
||||
"main.cjs",
|
||||
"-ssh",
|
||||
"alice@host.example",
|
||||
"-pw",
|
||||
"secret",
|
||||
]);
|
||||
assert.equal(fromElectron?.hostname, "host.example");
|
||||
assert.equal(fromElectron?.url, "ssh://alice:secret@host.example");
|
||||
});
|
||||
|
||||
test("redactPuttyCommandLinePasswords masks -pw values in argv", () => {
|
||||
const argv = ["Netcatty.exe", "-ssh", "alice@host", "-pw", "s3cret!!"];
|
||||
redactPuttyCommandLinePasswords(argv);
|
||||
assert.equal(argv[4], "********");
|
||||
});
|
||||
Reference in new issue
Block a user