mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92177ec42 | ||
|
|
54481b6306 | ||
|
|
7e81bece9e | ||
|
|
04ee9afa7f | ||
|
|
efe5d4530d | ||
|
|
32e75b6ace | ||
|
|
4a831f41c2 | ||
|
|
141148ae0d | ||
|
|
8d15a29bc6 | ||
|
|
fcb6723c3d | ||
|
|
519c24cc4c |
No files matched your search
@@ -1,3 +1,14 @@
|
||||
!macro customInit
|
||||
; #3946: external MCP hosts launch Netcatty.exe with ELECTRON_RUN_AS_NODE=1
|
||||
; via netcatty-external-mcp.cmd. Those processes can outlive the host UI as
|
||||
; orphans and keep $INSTDIR locked, so the in-app updater hangs (AppHangB1).
|
||||
; Force-kill leftover Netcatty.exe before files are replaced. Do not pass /T:
|
||||
; electron-updater spawns this installer as a child of the UI Netcatty.exe,
|
||||
; and /T would terminate the installer itself. Ignore a missing process.
|
||||
nsExec::ExecToLog "taskkill /F /IM Netcatty.exe"
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro customInstall
|
||||
; Open a selected folder in a new Netcatty local terminal.
|
||||
; Args go after `--` so Electron/Chromium does not swallow them. Use
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createRequire } from "node:module";
|
||||
import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serialize";
|
||||
import type { Terminal as XTermType } from "@xterm/xterm";
|
||||
|
||||
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../domain/models/terminal.ts";
|
||||
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
|
||||
import {
|
||||
noteTerminalOutputPressureData,
|
||||
@@ -81,6 +82,76 @@ test("new output is colored on the written cells without decorations", async ()
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("keyword highlight skips cells whose PTY foreground is already colored", async () => {
|
||||
// Starship directory segments set a truecolor foreground (#3940). Those
|
||||
// cells must keep the PTY color; only default-foreground matches are recolored.
|
||||
const unixPath = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((entry) => entry.id === "unix-path");
|
||||
assert.ok(unixPath);
|
||||
const keywordRgb = Number.parseInt(unixPath.color.slice(1), 16);
|
||||
const cream = 0xfbf1c7;
|
||||
const yellow = 0xfabd2f;
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules([unixPath], true);
|
||||
await write(
|
||||
term,
|
||||
"\x1b[38;2;251;241;199m\x1b[48;2;250;189;47m/opt\x1b[0m"
|
||||
+ " \x1b[38;5;220m/mnt\x1b[0m"
|
||||
+ " \x1b[31m/bin\x1b[0m"
|
||||
+ " /var",
|
||||
);
|
||||
|
||||
const tokenCells = (text: string) => {
|
||||
const line = term.buffer.active.getLine(0);
|
||||
const index = (line?.translateToString(true) ?? "").indexOf(text);
|
||||
assert.ok(index >= 0, text);
|
||||
return Array.from({ length: text.length }, (_, offset) => {
|
||||
const cell = line?.getCell(index + offset);
|
||||
assert.ok(cell, `${text}[${offset}]`);
|
||||
return cell;
|
||||
});
|
||||
};
|
||||
|
||||
for (const cell of tokenCells("/opt")) {
|
||||
assert.equal(cell.isFgRGB(), true);
|
||||
assert.equal(cell.getFgColorMode(), 0x3000000);
|
||||
assert.equal(cell.getFgColor(), cream);
|
||||
assert.equal(cell.isBgRGB(), true);
|
||||
assert.equal(cell.getBgColor(), yellow);
|
||||
}
|
||||
for (const cell of tokenCells("/mnt")) {
|
||||
assert.equal(cell.isFgPalette(), true);
|
||||
assert.equal(cell.getFgColorMode(), 0x2000000);
|
||||
assert.equal(cell.getFgColor(), 220);
|
||||
}
|
||||
for (const cell of tokenCells("/bin")) {
|
||||
assert.equal(cell.isFgPalette(), true);
|
||||
assert.equal(cell.getFgColorMode(), 0x1000000);
|
||||
assert.equal(cell.getFgColor(), 1);
|
||||
}
|
||||
for (const cell of tokenCells("/var")) {
|
||||
assert.equal(cell.isFgRGB(), true);
|
||||
assert.equal(cell.getFgColor(), keywordRgb);
|
||||
}
|
||||
|
||||
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], true);
|
||||
await highlighter.whenSettled();
|
||||
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
|
||||
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
|
||||
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
|
||||
assert.equal(tokenCells("/var")[0]?.getFgColor(), BLUE);
|
||||
|
||||
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], false);
|
||||
await highlighter.whenSettled();
|
||||
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
|
||||
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
|
||||
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
|
||||
assert.equal(tokenCells("/var")[0]?.getFgColor(), -1);
|
||||
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("ordinary Enter output does not revisit existing history", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
|
||||
@@ -67,6 +67,7 @@ const CELL_INDICES = 3;
|
||||
const CELL_CONTENT = 0;
|
||||
const CELL_FG = 1;
|
||||
const STYLE_MASK = 0xfc000000;
|
||||
const CM_MASK = 0x3000000;
|
||||
const CM_RGB = 0x3000000;
|
||||
const MAX_PLUGIN_HIGHLIGHT_SCAN_CHARS = 4_096;
|
||||
const MAX_PLUGIN_HIGHLIGHT_MATCHES_PER_WRITE = 256;
|
||||
@@ -1061,7 +1062,13 @@ export class KeywordHighlighter implements IDisposable {
|
||||
const dataIndex = x * CELL_INDICES;
|
||||
const content = internal._data[dataIndex + CELL_CONTENT];
|
||||
const currentFg = internal._data[dataIndex + CELL_FG];
|
||||
if (!originals.mask[x] || originals.content[x] !== content) {
|
||||
// A cell already carrying a PTY palette/truecolor foreground (starship
|
||||
// directory segments, ANSI logs) must keep that color. Keyword RGB is
|
||||
// only for the default foreground.
|
||||
const maskedForContent = originals.mask[x] === 1 && originals.content[x] === content;
|
||||
const ptyFg = maskedForContent ? originals.fg[x] : currentFg;
|
||||
if ((ptyFg & CM_MASK) !== 0) continue;
|
||||
if (!maskedForContent) {
|
||||
originals.fg[x] = currentFg;
|
||||
originals.content[x] = content;
|
||||
originals.mask[x] = 1;
|
||||
|
||||
@@ -68,7 +68,8 @@ const PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR = 8;
|
||||
|
||||
function hasExpectedPromptSuffix(text, expectedPrompt) {
|
||||
if (!expectedPrompt) return false;
|
||||
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
||||
const ansiFreePrompt = stripAnsi(String(expectedPrompt || ""));
|
||||
const normalizedPrompt = ansiFreePrompt.replace(/\r/g, "");
|
||||
if (!normalizedPrompt) return false;
|
||||
const fullText = String(text || "");
|
||||
const windowChars = Math.max(
|
||||
@@ -76,7 +77,17 @@ function hasExpectedPromptSuffix(text, expectedPrompt) {
|
||||
String(expectedPrompt).length * PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR,
|
||||
);
|
||||
const tail = fullText.length > windowChars ? fullText.slice(-windowChars) : fullText;
|
||||
return stripAnsi(tail).replace(/\r/g, "").endsWith(normalizedPrompt);
|
||||
const strippedTail = stripAnsi(tail);
|
||||
// Drop CR so a redraw inside one line (`root@host:~\r# `) still matches
|
||||
// the cached prompt. That collapses a two-line PS1's `\r\r\n` to a single
|
||||
// LF, which no longer ends with the cache (`user@host: /root\n\n\n$ `).
|
||||
if (strippedTail.replace(/\r/g, "").endsWith(normalizedPrompt)) return true;
|
||||
// The idle-prompt cache turns those CRs into LFs and keeps the blank
|
||||
// lines. Compare that form too, only when the cache itself spans lines,
|
||||
// so a bare `$`/`#` is unchanged.
|
||||
if (!ansiFreePrompt.includes("\n")) return false;
|
||||
const crAsLfPrompt = ansiFreePrompt.replace(/\r/g, "\n");
|
||||
return strippedTail.replace(/\r/g, "\n").endsWith(crAsLfPrompt);
|
||||
}
|
||||
|
||||
function escapePosixSingleQuoted(text) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { hasExpectedPromptSuffix } = require("./ptyExecHelpers.cjs");
|
||||
const { stripAnsi } = require("./shellUtils.cjs");
|
||||
const { extractTrailingIdlePrompt, stripAnsi } = require("./shellUtils.cjs");
|
||||
|
||||
// The behaviour before the tail window was introduced: normalise the whole
|
||||
// text, then compare its end.
|
||||
@@ -41,6 +41,38 @@ test("prompt suffix is found after a very large flood of CRLF output", () => {
|
||||
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
|
||||
});
|
||||
|
||||
test("live PTY CR bytes match a cached two-line idle prompt", () => {
|
||||
// Bash writes the PS1 newline as CR and ONLCR adds another, so the bytes
|
||||
// are `\r\r\n`. The idle-prompt cache keeps those as blank lines. Suffix
|
||||
// matching has to accept that form; stripping CR down to one LF does not.
|
||||
const live = "user@host: /workspace\r\r\n$ ";
|
||||
const cached = extractTrailingIdlePrompt(`welcome\r\n${live}`);
|
||||
assert.equal(cached, "user@host: /workspace\n\n\n$ ");
|
||||
const matchLive = hasExpectedPromptSuffix(live, cached);
|
||||
assert.equal(matchLive, true);
|
||||
assert.equal(hasExpectedPromptSuffix(`hostname\r\nbox\r\n${live}`, cached), true);
|
||||
assert.equal(
|
||||
hasExpectedPromptSuffix("\x1b[?2004huser@host: /workspace\r\r\n$ ", cached),
|
||||
true,
|
||||
);
|
||||
|
||||
const rootLive = "user@host: /root\r\r\n# ";
|
||||
const rootCached = extractTrailingIdlePrompt(rootLive);
|
||||
assert.equal(rootCached, "user@host: /root\n\n\n# ");
|
||||
assert.equal(hasExpectedPromptSuffix(rootLive, rootCached), true);
|
||||
|
||||
// Readiness for a real LF two-line prompt still matches, and a bare
|
||||
// `$`/`#` is neither this prompt nor a POSIX prompt on its own.
|
||||
const lfCached = extractTrailingIdlePrompt("welcome\r\nuser@host: /workspace\n$ ");
|
||||
assert.equal(lfCached, "user@host: /workspace\n$ ");
|
||||
assert.equal(hasExpectedPromptSuffix("user@host: /workspace\n$ ", lfCached), true);
|
||||
assert.equal(hasExpectedPromptSuffix("output\r\n$ ", cached), false);
|
||||
assert.equal(hasExpectedPromptSuffix("$ ", cached), false);
|
||||
assert.equal(hasExpectedPromptSuffix("# ", rootCached), false);
|
||||
assert.equal(extractTrailingIdlePrompt("$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# "), "");
|
||||
});
|
||||
|
||||
test("prompt suffix check does not scan the whole buffer on every call", () => {
|
||||
// 2M characters of CRLF output used to cost seconds per call because every
|
||||
// call stripped ANSI and carriage returns over the entire text.
|
||||
|
||||
@@ -69,6 +69,11 @@ const CMD_PROMPT_PATTERN = /^[A-Za-z]:(?:\\[^<>"|]*)?>$/;
|
||||
// the final `]` before `$`/`#` is the delimiter.
|
||||
const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s\]:]+(?:\s+[^\n\r]+)?\])[#$]$/;
|
||||
|
||||
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
|
||||
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
|
||||
// without a prompt marker on this line. A bare `$`/`#` alone is not a prompt.
|
||||
const POSIX_PROMPT_HOST_LINE_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?$/;
|
||||
|
||||
// Alpine's default ash PS1 omits the username: `\h:\w\$ ` -> `localhost:~# `.
|
||||
// Require a hostname and a home/absolute path, excluding prompt delimiters
|
||||
// within the path so partially entered commands do not count as idle prompts.
|
||||
@@ -84,7 +89,14 @@ function isDefaultCmdPromptLine(line) {
|
||||
|
||||
function isDefaultPosixPromptLine(line) {
|
||||
const normalized = String(line || "").replace(/\s+$/, "");
|
||||
return POSIX_PROMPT_PATTERN.test(normalized) || ALPINE_PROMPT_PATTERN.test(normalized);
|
||||
// Optional virtualenv / conda / BT-panel style env name prefix: "(name) "
|
||||
// before the shell prompt. Recognition only; callers still return the full
|
||||
// prompt line so suffix matching sees the visible prefix (#3947).
|
||||
const withoutEnvPrefix = normalized.replace(/^\([^()\n\r]+\)\s+/, "");
|
||||
return (
|
||||
POSIX_PROMPT_PATTERN.test(withoutEnvPrefix) ||
|
||||
ALPINE_PROMPT_PATTERN.test(withoutEnvPrefix)
|
||||
);
|
||||
}
|
||||
|
||||
function extractTrailingIdlePrompt(output) {
|
||||
@@ -95,7 +107,8 @@ function extractTrailingIdlePrompt(output) {
|
||||
const normalized = stripAnsi(output).replace(/\r/g, "\n");
|
||||
if (!normalized || normalized.endsWith("\n")) return "";
|
||||
|
||||
const lastLine = normalized.split("\n").pop() || "";
|
||||
const lines = normalized.split("\n");
|
||||
const lastLine = lines.pop() || "";
|
||||
const rightTrimmed = lastLine.replace(/\s+$/, "");
|
||||
if (!rightTrimmed) return "";
|
||||
|
||||
@@ -111,6 +124,24 @@ function extractTrailingIdlePrompt(output) {
|
||||
return lastLine;
|
||||
}
|
||||
|
||||
// Two-line PS1 (`export PS1="\u@\h: \w\n$ "`). Bash emits the newline as
|
||||
// CR and the PTY's ONLCR adds another, so the bytes are `\r\r\n`. The
|
||||
// CR-to-LF normalize above turns that into blank lines between the host
|
||||
// line and `$`/`#`. Keep those blanks in the cache: getFreshIdlePrompt
|
||||
// uses the same normalize, and hasExpectedPromptSuffix compares this
|
||||
// CR-as-LF form against live PTY bytes. A lone `$` must not satisfy endsWith.
|
||||
if (rightTrimmed === "$" || rightTrimmed === "#") {
|
||||
let hostIndex = lines.length - 1;
|
||||
while (hostIndex >= 0 && lines[hostIndex].replace(/\s+$/, "") === "") {
|
||||
hostIndex -= 1;
|
||||
}
|
||||
const previousLine = hostIndex >= 0 ? lines[hostIndex] : "";
|
||||
const previousRightTrimmed = previousLine.replace(/\s+$/, "");
|
||||
if (POSIX_PROMPT_HOST_LINE_PATTERN.test(previousRightTrimmed)) {
|
||||
return `${lines.slice(hostIndex).join("\n")}\n${lastLine}`;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -171,7 +202,8 @@ function trackSessionIdlePrompt(session, chunk) {
|
||||
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
|
||||
// still ends with it. The cached prompt is updated only when
|
||||
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
|
||||
// `user@host[:path][#$]`, RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
|
||||
// `user@host[:path][#$]`, two-line `user@host[:path]` + `$`/`#`,
|
||||
// RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
|
||||
// a remote shell switch into another shell, an oh-my-posh / starship / custom
|
||||
// PS1, or any unrecognized prompt would
|
||||
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
|
||||
|
||||
@@ -219,6 +219,97 @@ test("extracts a trailing RHEL-family bracketed idle prompt", () => {
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nroot@host ~#"), "");
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt recognizes a two-line user@host then bare $/# PS1", () => {
|
||||
// export PS1="\u@\h: \w\n$ " paints the host line, then a lone "$ ".
|
||||
// The cached prompt must keep both lines so a lone "$" cannot satisfy endsWith.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\nuser@host: /home/foo\n$ "),
|
||||
"user@host: /home/foo\n$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\nroot@host: /root\n# "),
|
||||
"root@host: /root\n# ",
|
||||
);
|
||||
// Live bash PTY: prompt CR plus ONLCR is `\r\r\n`, which this normalizer
|
||||
// stores as blank lines. The cache must keep them so endsWith still matches.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("\x1b[?2004huser@host: /home/foo\r\r\n$ "),
|
||||
"user@host: /home/foo\n\n\n$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("\x1b[?2004hroot@host: /root\r\r\n# "),
|
||||
"root@host: /root\n\n\n# ",
|
||||
);
|
||||
assert.equal(extractTrailingIdlePrompt("$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("not a host line\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("copied output\n# "), "");
|
||||
assert.equal(isDefaultPosixPromptLine("$"), false);
|
||||
assert.equal(isDefaultPosixPromptLine("#"), false);
|
||||
assert.equal(isDefaultPosixPromptLine("$ "), false);
|
||||
assert.equal(isDefaultPosixPromptLine("# "), false);
|
||||
});
|
||||
|
||||
test("trackSessionIdlePrompt and getFreshIdlePrompt accept a two-line user@host PS1", () => {
|
||||
const session = {};
|
||||
const prompt = "user@host: /home/foo\n$ ";
|
||||
trackSessionIdlePrompt(session, `welcome\r\n${prompt}`);
|
||||
assert.equal(getFreshIdlePrompt(session), prompt);
|
||||
|
||||
const ptySession = {};
|
||||
trackSessionIdlePrompt(ptySession, "\x1b[?2004huser@host: /home/foo\r\r\n$ ");
|
||||
assert.equal(getFreshIdlePrompt(ptySession), "user@host: /home/foo\n\n\n$ ");
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt still recognizes a classic single-line POSIX prompt", () => {
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "), "user@host:~$ ");
|
||||
});
|
||||
|
||||
test("isDefaultPosixPromptLine accepts virtualenv, conda, and BT-panel env prefixes", () => {
|
||||
// Python venv / conda / BT-panel prepend "(name) " before the real prompt.
|
||||
// Recognition strips that prefix; a missing space after ")" is already
|
||||
// accepted because the username class absorbs the parens (#3947).
|
||||
for (const prompt of [
|
||||
"(3.12.12) root@example:~# ",
|
||||
"(3.12.12) root@example:~#",
|
||||
"(venv) alice@host:~$ ",
|
||||
"(base) [opc@host ~]$ ",
|
||||
"(my-env) localhost:~# ",
|
||||
"(3.12.12)root@example:~# ",
|
||||
]) {
|
||||
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt keeps the full env-prefixed POSIX prompt", () => {
|
||||
// Suffix matching compares the visible PTY line, so the captured prompt
|
||||
// must still include the "(name) " prefix rather than the stripped form.
|
||||
for (const prompt of [
|
||||
"(3.12.12) root@example:~# ",
|
||||
"(3.12.12) root@example:~#",
|
||||
"(venv) alice@host:~$ ",
|
||||
"(base) [opc@host ~]$ ",
|
||||
"(my-env) localhost:~# ",
|
||||
]) {
|
||||
assert.equal(extractTrailingIdlePrompt(`Last login\r\n${prompt}`), prompt, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("env-prefixed POSIX recognition rejects incomplete prefixes and non-prompts", () => {
|
||||
for (const line of [
|
||||
"(3.12.12 root@example:~# ",
|
||||
"(3.12.12) root@example:~# printf busy",
|
||||
"(venv) not a prompt",
|
||||
"root@host ~#",
|
||||
"(base) [opc@host ~]$ printf busy",
|
||||
"(my-env) localhost:~# printf busy",
|
||||
]) {
|
||||
assert.equal(isDefaultPosixPromptLine(line), false, line);
|
||||
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
|
||||
}
|
||||
});
|
||||
|
||||
test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
|
||||
assert.equal(isPlausibleCliVersionOutput("2.1.123 (Claude Code)"), true);
|
||||
assert.equal(isPlausibleCliVersionOutput("codex-cli 0.125.0"), true);
|
||||
|
||||
@@ -836,6 +836,105 @@ test("sudo SFTP-page transfer reuses the elevated channel instead of dialing aga
|
||||
assert.equal(sftpClients.has("browse-sudo"), true);
|
||||
});
|
||||
|
||||
for (const [ident, sequential] of [
|
||||
["OpenSSH_9.6", false],
|
||||
["CLOUDBILITY-4.14", true],
|
||||
["TERM-SSHD", true],
|
||||
]) {
|
||||
test(`sudo transfer aliases preserve ${ident} upload concurrency`, { timeout: 15000 }, async (t) => {
|
||||
const server = await startOtpOnlyServer({ ident });
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const sftpClients = new Map();
|
||||
sftpBridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
||||
transferBridge.init({ sftpClients });
|
||||
t.after(async () => {
|
||||
for (const sftpId of [...sftpClients.keys()].reverse()) {
|
||||
await sftpBridge.closeSftp(null, { sftpId }).catch(() => {});
|
||||
}
|
||||
pool.discardAllTransports("test-end");
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
await server.close();
|
||||
});
|
||||
|
||||
const options = { ...BASE_OPTIONS(server.port), reuseTransport: false };
|
||||
const sender = createSender();
|
||||
const opening = sftpBridge.openSftp({ sender }, { ...options, sessionId: "sudo-owner" });
|
||||
opening.catch(() => {});
|
||||
await waitFor(() => answerOtpPrompts(sender));
|
||||
const opened = await opening;
|
||||
const owner = sftpClients.get(opened.sftpId);
|
||||
// Model an already-elevated channel. The real SSH/SFTP peer remains the
|
||||
// ordinary server or bastion above; no second sudo/authentication is needed.
|
||||
const sudoOptions = { ...BASE_OPTIONS(server.port), sudo: true };
|
||||
const endpoint = pool.buildConnectionReuseEndpoint(sudoOptions, { sftpSudo: true });
|
||||
owner.__netcattySudoMode = true;
|
||||
owner.__netcattyEndpointKey = pool.buildEndpointKey(endpoint);
|
||||
owner.__netcattyChannelAdoptionKey = pool.buildChannelAdoptionKey(endpoint);
|
||||
const firstAlias = await sftpBridge.openSftp({ sender: createSender() }, sudoOptions);
|
||||
const secondAlias = await sftpBridge.openSftp({ sender: createSender() }, sudoOptions);
|
||||
for (const alias of [firstAlias, secondAlias]) {
|
||||
const client = sftpClients.get(alias.sftpId);
|
||||
assert.equal(client.__netcattySharedChannelAlias, true);
|
||||
assert.equal(client.__netcattySudoMode, true);
|
||||
assert.equal(client.sftp, owner.sftp, "both uploads must retain the elevated channel");
|
||||
}
|
||||
|
||||
const root = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("sudo-concurrency")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, "source.bin");
|
||||
const payload = Buffer.alloc(3 * 32768 + 17, 0x5a);
|
||||
fs.writeFileSync(source, payload);
|
||||
const firstPath = "/home/otpuser/first.bin";
|
||||
const secondPath = "/home/otpuser/second.bin";
|
||||
server.files.set(firstPath, Buffer.from("original file"));
|
||||
const channel = owner.sftp;
|
||||
const originalLstat = channel.lstat.bind(channel);
|
||||
let firstTargetChecks = 0;
|
||||
let promotionStarted;
|
||||
const promotionStartedPromise = new Promise((resolve) => { promotionStarted = resolve; });
|
||||
let releasePromotion;
|
||||
const promotionGate = new Promise((resolve) => { releasePromotion = resolve; });
|
||||
channel.lstat = (remotePath, ...args) => {
|
||||
if (String(remotePath) === firstPath && ++firstTargetChecks === 2) {
|
||||
promotionStarted();
|
||||
void promotionGate.then(() => originalLstat(remotePath, ...args));
|
||||
return;
|
||||
}
|
||||
originalLstat(remotePath, ...args);
|
||||
};
|
||||
const upload = (alias, targetPath) => transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: `sudo-concurrency-${ident}-${path.basename(targetPath)}`,
|
||||
sourcePath: source, targetPath,
|
||||
sourceType: "local", targetType: "sftp", targetSftpId: alias.sftpId,
|
||||
totalBytes: payload.length, resumable: true,
|
||||
});
|
||||
let firstFinished = false;
|
||||
let secondFinished = false;
|
||||
const first = upload(firstAlias, firstPath).then((result) => { firstFinished = true; return result; });
|
||||
let second;
|
||||
let results;
|
||||
try {
|
||||
await promotionStartedPromise;
|
||||
second = upload(secondAlias, secondPath).then((result) => { secondFinished = true; return result; });
|
||||
await waitFor(() => secondFinished
|
||||
|| transferBridge._getSessionFastDownloadWaiterCountForTests(channel) > 0);
|
||||
assert.equal(firstFinished, false, "the first upload must still be held before publication");
|
||||
assert.equal(secondFinished, !sequential,
|
||||
sequential ? "a bastion must wait for the first promotion" : "ordinary sudo uploads must remain concurrent");
|
||||
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(channel), sequential ? 1 : 0);
|
||||
} finally {
|
||||
releasePromotion();
|
||||
results = await Promise.all(second ? [first, second] : [first]);
|
||||
channel.lstat = originalLstat;
|
||||
}
|
||||
for (const result of results) assert.equal(result.error, undefined, result.error);
|
||||
assert.deepEqual(server.files.get(firstPath), payload);
|
||||
assert.deepEqual(server.files.get(secondPath), payload);
|
||||
assert.equal(server.getConnectionCount(), 1, "sudo uploads must not open an unelevated connection");
|
||||
assert.equal(server.getKeyboardInteractiveRounds(), 1, "uploads must not ask for another OTP");
|
||||
});
|
||||
}
|
||||
|
||||
test("reconnecting the soft-closed owner id does not replace it with a self-alias (#3310)", async (t) => {
|
||||
const { sftpTransferSessionLeaseStore, channelAliasLeaseId } = require("./sftpTransferSessionLease.cjs");
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
|
||||
@@ -2474,16 +2474,68 @@ async function acquireSessionFastDownloadSlot(client, transfer) {
|
||||
}
|
||||
}
|
||||
|
||||
// TERM-SSHD compatibility requires one file handle per channel. Per-file request
|
||||
// concurrency is not enough: pooled SFTP aliases share that physical channel,
|
||||
// and a second body or verification OPEN would fail even for tiny files.
|
||||
// Reuse the cancelable one-slot queue, keyed by the stream rather than its
|
||||
// wrapper client, across both directions and the complete verification phase.
|
||||
function isSingleChannelSftpClient(client) {
|
||||
return Boolean(
|
||||
client?.__netcattySingleChannelSsh
|
||||
|| client?.client?.__netcattySingleChannelSsh,
|
||||
);
|
||||
}
|
||||
|
||||
function requiresExclusiveSftpTransferChannel(client, channel) {
|
||||
// Aliases stay on one authenticated/elevated channel, even on ordinary SSH
|
||||
// servers. Only the owning peer's restriction requires serial transfers.
|
||||
const peer = client?.__netcattySharedChannelAlias
|
||||
? sftpClients?.get(client.__netcattySharedChannelOwnerId) || client
|
||||
: client;
|
||||
return isSingleChannelSftpClient(peer)
|
||||
|| getSftpTransferConcurrency(channel, DOWNLOAD_TRANSFER_CONCURRENCY) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Some single-channel gateways cannot safely interleave SFTP metadata and
|
||||
* write requests from different files. Serialize the complete upload
|
||||
* transaction per raw SFTP stream while leaving independent connections free.
|
||||
*/
|
||||
async function acquireExclusiveSftpTransferTransaction(client, transfer) {
|
||||
if (isScpModeClient(client)) return null;
|
||||
// Preserve the normal lazy SFTP-open path. Only known single-channel peers
|
||||
// acquire the transaction slot before the upload itself opens a handle.
|
||||
const knownChannel = client?.sftp || { _client: client?.client };
|
||||
if (!requiresExclusiveSftpTransferChannel(client, knownChannel)) return null;
|
||||
await requireSftpChannel(client);
|
||||
const channel = client.sftp;
|
||||
if (transfer?._exclusiveSftpTransactionChannel === channel) return null;
|
||||
|
||||
await acquireSessionFastDownloadSlot(channel, transfer);
|
||||
transfer._exclusiveSftpTransactionChannel = channel;
|
||||
let released = false;
|
||||
return {
|
||||
client,
|
||||
channel,
|
||||
release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (transfer._exclusiveSftpTransactionChannel === channel) {
|
||||
transfer._exclusiveSftpTransactionChannel = null;
|
||||
}
|
||||
releaseSessionFastDownloadSlot(channel);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Single-channel compatibility permits only one transfer operation per raw
|
||||
// SFTP stream. A per-file request limit is insufficient because pooled aliases
|
||||
// share the physical channel, and even a tiny file's second OPEN or verification
|
||||
// read can fail. Reuse the cancelable one-slot queue keyed by stream rather
|
||||
// than wrapper client across both directions and complete verification.
|
||||
async function withSequentialSftpTransferSlot(client, transfer, operation) {
|
||||
if (isScpModeClient(client)) return operation();
|
||||
await requireSftpChannel(client);
|
||||
const channel = client.sftp;
|
||||
if (getSftpTransferConcurrency(channel, DOWNLOAD_TRANSFER_CONCURRENCY) !== 1) {
|
||||
if (transfer?._exclusiveSftpTransactionChannel === channel) {
|
||||
return operation();
|
||||
}
|
||||
if (!requiresExclusiveSftpTransferChannel(client, channel)) {
|
||||
return operation();
|
||||
}
|
||||
await acquireSessionFastDownloadSlot(channel, transfer);
|
||||
@@ -6625,6 +6677,7 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
});
|
||||
};
|
||||
|
||||
let exclusiveSftpUploadTransaction = null;
|
||||
try {
|
||||
// Explicit 0 is a valid empty-snapshot plan (e.g. download of an empty log
|
||||
// that may grow later). Do not treat it as "size unknown" and re-stat into
|
||||
@@ -6752,6 +6805,16 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
else await verify();
|
||||
}
|
||||
|
||||
// A single-channel upload holds the raw SFTP stream from directory setup
|
||||
// through stage verification, destination safety checks, promotion, and
|
||||
// timestamp restoration so directory children cannot interleave requests.
|
||||
if (sourceType === "local" && targetType === "sftp" && targetClient) {
|
||||
exclusiveSftpUploadTransaction = await acquireExclusiveSftpTransferTransaction(
|
||||
targetClient,
|
||||
transfer,
|
||||
);
|
||||
}
|
||||
|
||||
sendProgress(transfer.checkpointBytes, fileSize, { force: true });
|
||||
|
||||
if (sourceType === 'local' && targetType === 'sftp') {
|
||||
@@ -7380,6 +7443,13 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
|
||||
return { transferId, totalBytes: fileSize, ...(transfer.warning ? { warning: transfer.warning } : {}) };
|
||||
} catch (err) {
|
||||
if (
|
||||
exclusiveSftpUploadTransaction
|
||||
&& err?.uncleanSftpChannel
|
||||
&& exclusiveSftpUploadTransaction.client.sftp === exclusiveSftpUploadTransaction.channel
|
||||
) {
|
||||
abandonWedgedVerificationSftpChannel(exclusiveSftpUploadTransaction.client);
|
||||
}
|
||||
const failureMessage = err?.message || String(err);
|
||||
if (
|
||||
sourceType === "local"
|
||||
@@ -7465,6 +7535,8 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
sendError(err);
|
||||
}
|
||||
return { transferId, error: err.message };
|
||||
} finally {
|
||||
exclusiveSftpUploadTransaction?.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ async function createSequentialTransferFixture(t) {
|
||||
return {
|
||||
payload, files, sftp, handles, clients,
|
||||
rejectedOpens: () => rejectedOpens,
|
||||
openCount: () => opens,
|
||||
async start(direction, sftpId = "first", options = {}) {
|
||||
const transferId = options.transferId || crypto.randomUUID();
|
||||
const localPath = tempDirBridge.getTempFilePath(`term-concurrent-${transferId}.bin`);
|
||||
@@ -263,6 +264,61 @@ async function createSequentialTransferFixture(t) {
|
||||
};
|
||||
}
|
||||
|
||||
test("single-channel upload waits for the prior promotion before starting the next file", { timeout: 5_000 }, async (t) => {
|
||||
const fixture = await createSequentialTransferFixture(t);
|
||||
const originalLstat = fixture.sftp.lstat;
|
||||
let lstatCalls = 0;
|
||||
let promotionStarted;
|
||||
const promotionStartedPromise = new Promise((resolve) => { promotionStarted = resolve; });
|
||||
let releasePromotion;
|
||||
const promotionGate = new Promise((resolve) => { releasePromotion = resolve; });
|
||||
|
||||
fixture.sftp.lstat = (remotePath, callback) => {
|
||||
lstatCalls += 1;
|
||||
// The first two calls inspect the recovery backup and initial destination.
|
||||
// The third is the final safety check after the .part write; hold it to
|
||||
// prove that the next file cannot acquire the channel yet.
|
||||
if (lstatCalls === 3) {
|
||||
promotionStarted();
|
||||
void promotionGate.then(() => originalLstat(remotePath, callback));
|
||||
return;
|
||||
}
|
||||
originalLstat(remotePath, callback);
|
||||
};
|
||||
|
||||
const first = fixture.start("upload", "first");
|
||||
await promotionStartedPromise;
|
||||
const openCountAtPromotion = fixture.openCount();
|
||||
const second = fixture.start("upload", "alias");
|
||||
let firstResult;
|
||||
let secondResult;
|
||||
try {
|
||||
// start() first writes its local source. Wait until the second upload
|
||||
// actually queues, or incorrectly starts remote work, before asserting.
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (
|
||||
transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp) === 0
|
||||
&& lstatCalls === 3
|
||||
&& fixture.openCount() === openCountAtPromotion
|
||||
&& Date.now() < deadline
|
||||
) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(lstatCalls, 3, "the next file must not inspect its target during prior promotion");
|
||||
assert.equal(fixture.openCount(), openCountAtPromotion, "the next file must not open a remote handle during prior promotion");
|
||||
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 1,
|
||||
"the second upload must be waiting for the shared channel");
|
||||
} finally {
|
||||
releasePromotion();
|
||||
[firstResult, secondResult] = await Promise.all([first, second]);
|
||||
}
|
||||
assert.equal(firstResult.result.error, undefined);
|
||||
assert.equal(secondResult.result.error, undefined);
|
||||
assert.deepEqual(fixture.files.get(firstResult.targetPath), fixture.payload);
|
||||
assert.deepEqual(fixture.files.get(secondResult.targetPath), fixture.payload);
|
||||
assert.equal(fixture.rejectedOpens(), 0);
|
||||
});
|
||||
|
||||
for (const secondId of ["first", "alias"]) {
|
||||
test(`TERM-SSHD small downloads serialize through body and verification (${secondId})`, async (t) => {
|
||||
const fixture = await createSequentialTransferFixture(t);
|
||||
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
{
|
||||
version = "1.1.89";
|
||||
version = "1.1.90";
|
||||
|
||||
sources = {
|
||||
x86_64-linux = {
|
||||
appImageArch = "x86_64";
|
||||
hash = "sha256-Ne5TalZ3G9cHPl0xnQpPq/0GNRe1ErUg1DzE+/nnseM=";
|
||||
hash = "sha256-oa/T3Zey0FHsH+SOt4C1H4CT6aZRJta+x4x/11EAOJE=";
|
||||
};
|
||||
aarch64-linux = {
|
||||
appImageArch = "arm64";
|
||||
hash = "sha256-L1aiJ/CroFwLSLH5aDnakogxjP8k5gLt8NJC+IGfXIw=";
|
||||
hash = "sha256-r5hr3+gpIGrv9XJ7PQkHCdhOd2COkhELMHlnr3xIG7A=";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -345,6 +345,26 @@ test("windows packaging includes a zip archive target", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("windows installer kills leftover Netcatty.exe before file replacement", () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const installerScript = fs.readFileSync(
|
||||
path.join(__dirname, "..", config.nsis.include),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(installerScript, /!macro customInit\b/);
|
||||
assert.match(installerScript, /#3946/);
|
||||
assert.match(installerScript, /ELECTRON_RUN_AS_NODE/);
|
||||
assert.match(
|
||||
installerScript,
|
||||
/nsExec::ExecToLog "taskkill \/F \/IM Netcatty\.exe"/,
|
||||
);
|
||||
assert.match(installerScript, /Pop \$0/);
|
||||
// /T would kill this installer while it is still a child of the UI Netcatty.exe.
|
||||
assert.doesNotMatch(installerScript, /taskkill [^\r\n]*\/T/);
|
||||
});
|
||||
|
||||
test("windows installer registers and removes Explorer folder context menu entries", () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
Reference in new issue
Block a user