mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 10:52:38 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13d349bd4d | ||
|
|
1a745a2a22 | ||
|
|
2096e93420 | ||
|
|
56f0b55b0e | ||
|
|
2c84f9cb0f | ||
|
|
ea62a0c31e | ||
|
|
d27dbbcd1d | ||
|
|
eb2c4b1611 | ||
|
|
392a094297 | ||
|
|
f300d583b2 | ||
|
|
70d58a0cad | ||
|
|
b92177ec42 | ||
|
|
54481b6306 | ||
|
|
7e81bece9e | ||
|
|
04ee9afa7f | ||
|
|
efe5d4530d | ||
|
|
32e75b6ace | ||
|
|
4a831f41c2 | ||
|
|
141148ae0d | ||
|
|
8d15a29bc6 | ||
|
|
fcb6723c3d | ||
|
|
519c24cc4c |
No files matched your search
@@ -7,8 +7,9 @@ import {
|
||||
handleKeyboardInteractiveSubmitImpl,
|
||||
handleTrayPanelConnectRequestImpl,
|
||||
} from './app/AppHandlers.ts';
|
||||
import { resolveHostAuth } from '../domain/sshAuth.ts';
|
||||
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
|
||||
import type { Host } from '../types';
|
||||
import type { Host, Identity } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
id: 'host-1',
|
||||
@@ -194,6 +195,219 @@ test('keyboard-interactive submit can save login password for the session host',
|
||||
assert.equal(bridgeResponses.length, 1);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit detaches keychain identity when remembering a password change', async () => {
|
||||
const sharedIdentity: Identity = {
|
||||
id: 'id-1',
|
||||
label: 'shared',
|
||||
username: 'root',
|
||||
authMethod: 'password',
|
||||
password: 'stale-keychain-password',
|
||||
created: 0,
|
||||
};
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
identityId: 'id-1',
|
||||
password: 'old-host-password',
|
||||
savePassword: false,
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-rotate',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
let identityUpdates = 0;
|
||||
|
||||
// resolveHostAuth prefers identity.password, so the stale keychain secret
|
||||
// shadows whatever is already stored on the host.
|
||||
assert.equal(
|
||||
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
|
||||
'stale-keychain-password',
|
||||
);
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
identities: [sharedIdentity],
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: true }),
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
updateIdentities: () => {
|
||||
identityUpdates += 1;
|
||||
},
|
||||
}),
|
||||
'ki-rotate',
|
||||
['rotated-password'],
|
||||
'rotated-password',
|
||||
);
|
||||
|
||||
assert.equal(hosts[0].identityId, '');
|
||||
assert.equal(hosts[0].password, 'rotated-password');
|
||||
assert.equal(hosts[0].savePassword, true);
|
||||
assert.equal(hosts[0].username, baseHost.username);
|
||||
assert.equal(identityUpdates, 0);
|
||||
assert.equal(sharedIdentity.password, 'stale-keychain-password');
|
||||
assert.equal(
|
||||
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
|
||||
'rotated-password',
|
||||
);
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit snapshots a different keychain username when remembering a password', async () => {
|
||||
const sharedIdentity: Identity = {
|
||||
id: 'id-1',
|
||||
label: 'shared',
|
||||
username: 'deploy',
|
||||
authMethod: 'password',
|
||||
password: 'stale-keychain-password',
|
||||
created: 0,
|
||||
};
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
username: 'root',
|
||||
identityId: 'id-1',
|
||||
password: 'old-host-password',
|
||||
savePassword: false,
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-rotate-user',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
let identityUpdates = 0;
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
identities: [sharedIdentity],
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: true }),
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
updateIdentities: () => {
|
||||
identityUpdates += 1;
|
||||
},
|
||||
}),
|
||||
'ki-rotate-user',
|
||||
['new-password'],
|
||||
'new-password',
|
||||
);
|
||||
|
||||
assert.equal(hosts[0].identityId, '');
|
||||
assert.equal(hosts[0].password, 'new-password');
|
||||
assert.equal(hosts[0].savePassword, true);
|
||||
assert.equal(hosts[0].username, 'deploy');
|
||||
assert.equal(identityUpdates, 0);
|
||||
assert.equal(sharedIdentity.username, 'deploy');
|
||||
assert.equal(sharedIdentity.password, 'stale-keychain-password');
|
||||
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
|
||||
assert.equal(resolved.password, 'new-password');
|
||||
assert.equal(resolved.username, 'deploy');
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit fills a blank host username from the keychain identity', async () => {
|
||||
const sharedIdentity: Identity = {
|
||||
id: 'id-1',
|
||||
label: 'shared',
|
||||
username: 'alice',
|
||||
authMethod: 'password',
|
||||
password: 'stale-keychain-password',
|
||||
created: 0,
|
||||
};
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
username: '',
|
||||
identityId: 'id-1',
|
||||
password: 'old-host-password',
|
||||
savePassword: false,
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-blank-user',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
let identityUpdates = 0;
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
identities: [sharedIdentity],
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: true }),
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
updateIdentities: () => {
|
||||
identityUpdates += 1;
|
||||
},
|
||||
}),
|
||||
'ki-blank-user',
|
||||
['new-password'],
|
||||
'new-password',
|
||||
);
|
||||
|
||||
assert.equal(hosts[0].identityId, '');
|
||||
assert.equal(hosts[0].username, 'alice');
|
||||
assert.equal(hosts[0].password, 'new-password');
|
||||
assert.equal(hosts[0].savePassword, true);
|
||||
assert.equal(identityUpdates, 0);
|
||||
assert.equal(sharedIdentity.username, 'alice');
|
||||
assert.equal(sharedIdentity.password, 'stale-keychain-password');
|
||||
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
|
||||
assert.equal(resolved.password, 'new-password');
|
||||
assert.equal(resolved.username, 'alice');
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', async () => {
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
|
||||
@@ -393,6 +393,7 @@ export async function handleKeyboardInteractiveSubmitImpl(
|
||||
const {
|
||||
hosts,
|
||||
hostsRef,
|
||||
identities,
|
||||
keyboardInteractiveQueue,
|
||||
sessions,
|
||||
setKeyboardInteractiveQueue,
|
||||
@@ -424,12 +425,27 @@ export async function handleKeyboardInteractiveSubmitImpl(
|
||||
// Save password to host if requested - never for second-factor / EDR prompts
|
||||
// (allowSavePassword === false) so a secondary secret cannot overwrite the
|
||||
// host login password (#2150 / Codex review on #2151).
|
||||
// Detach a Keychain identity when one is attached. resolveHostAuth prefers
|
||||
// identity.password and identity.username, so snapshot the identity username
|
||||
// onto the host before clearing identityId (#3982). Same as
|
||||
// applyVaultHostUpdate's password detach. Do not mutate the identity.
|
||||
if (savePassword && host && request?.allowSavePassword !== false) {
|
||||
updateHosts(latestHosts.map((h: Host) => h.id === host.id ? {
|
||||
...h,
|
||||
password: savePassword,
|
||||
savePassword: true,
|
||||
} : h));
|
||||
updateHosts(latestHosts.map((h: Host) => {
|
||||
if (h.id !== host.id) return h;
|
||||
const identity = h.identityId
|
||||
? identities?.find((item: { id?: string; username?: string }) => item.id === h.identityId)
|
||||
: undefined;
|
||||
const detachedUsername = identity?.username?.trim();
|
||||
return {
|
||||
...h,
|
||||
password: savePassword,
|
||||
savePassword: true,
|
||||
...(h.identityId ? {
|
||||
identityId: '',
|
||||
...(detachedUsername ? { username: detachedUsername } : {}),
|
||||
} : {}),
|
||||
};
|
||||
}));
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
|
||||
@@ -716,7 +716,7 @@ export function AppSideEffects() {
|
||||
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
|
||||
|
||||
// Handle keyboard-interactive submit
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, identities, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [identities, keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
|
||||
|
||||
// Handle keyboard-interactive cancel
|
||||
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { return handleKeyboardInteractiveCancelImpl(() => ({ netcattyBridge, requestId, setKeyboardInteractiveQueue, t, toast }), requestId); }, [t]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import type { Host, Identity } from "../types";
|
||||
import { handleKeyboardInteractiveSubmitImpl } from "../application/app/AppHandlers";
|
||||
import { createTerminalSessionStarters } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createPromptLineBreakState } from "./terminal/runtime/promptLineBreak";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
test("KI remember input preserves the reconnect account and leaves shared hosts unchanged", async (t) => {
|
||||
const dom = installDomEnvironment();
|
||||
const globals = {
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
NodeFilter: dom.window.NodeFilter,
|
||||
HTMLInputElement: dom.window.HTMLInputElement,
|
||||
localStorage: dom.window.localStorage,
|
||||
};
|
||||
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
const { KeyboardInteractiveModal } = await import("./KeyboardInteractiveModal");
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider");
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
try {
|
||||
for (const scenario of [
|
||||
{ name: "differing usernames", hostUsername: "old-account", identityUsername: "fixtureki", remember: true },
|
||||
{ name: "blank host username", hostUsername: "", identityUsername: "fixtureki", remember: true },
|
||||
{ name: "blank identity username", hostUsername: "fixtureki", identityUsername: " ", remember: true },
|
||||
{ name: "no identity", hostUsername: "fixtureki", identityUsername: undefined, remember: true },
|
||||
{ name: "remember unchecked", hostUsername: "old-account", identityUsername: "fixtureki", remember: false },
|
||||
{ name: "secondary password", hostUsername: "old-account", identityUsername: "fixtureki", remember: true, allowSavePassword: false },
|
||||
]) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const identity: Identity = {
|
||||
id: "shared", label: "Fixture", username: scenario.identityUsername ?? "",
|
||||
authMethod: "password", password: "fixture-old", created: 0,
|
||||
};
|
||||
const identities = scenario.identityUsername === undefined ? [] : [identity];
|
||||
const originalIdentity = { ...identity };
|
||||
const host: Host = {
|
||||
id: "changed", label: "Fixture", hostname: "127.0.0.1", username: scenario.hostUsername,
|
||||
tags: [], os: "linux", protocol: "ssh", authMethod: "password",
|
||||
password: "fixture-host-old", savePassword: false,
|
||||
...(identities.length ? { identityId: identity.id } : {}),
|
||||
};
|
||||
const otherHost = { ...host, id: "unchanged", label: "Other fixture" };
|
||||
let hosts = [host, otherHost];
|
||||
let updates = 0;
|
||||
const request = {
|
||||
requestId: `ki-${scenario.name}`, sessionId: "fixture-session", hostname: host.hostname,
|
||||
name: "Password expiry", instructions: "", prompts: [{ prompt: "New password:", echo: false }],
|
||||
allowSavePassword: scenario.allowSavePassword ?? true,
|
||||
};
|
||||
let queue = [request];
|
||||
const responses: unknown[][] = [];
|
||||
await renderer.render(
|
||||
<I18nProvider locale="en">
|
||||
<KeyboardInteractiveModal request={request} onCancel={() => false}
|
||||
onSubmit={(requestId, values, savePassword) => handleKeyboardInteractiveSubmitImpl(() => ({
|
||||
hosts, identities, keyboardInteractiveQueue: queue,
|
||||
sessions: [{ id: "fixture-session", hostId: host.id, hostname: host.hostname }],
|
||||
netcattyBridge: { get: () => ({ respondKeyboardInteractive: (...args: unknown[]) => {
|
||||
responses.push(args);
|
||||
return { success: true };
|
||||
} }) },
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => { queue = updater(queue); },
|
||||
updateHosts: (next: Host[]) => { updates += 1; hosts = next; },
|
||||
t: (key: string) => key, toast: { error: (message: string) => assert.fail(message) },
|
||||
}), requestId, values, savePassword)} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const input = dom.document.querySelector<HTMLInputElement>("#ki-prompt-0");
|
||||
assert.ok(input);
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-new");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
const checkbox = dom.document.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
if (scenario.allowSavePassword === false) assert.equal(checkbox, null);
|
||||
else {
|
||||
assert.ok(checkbox);
|
||||
assert.equal(checkbox.checked, false);
|
||||
if (scenario.remember) await dispatchDomEvent(checkbox, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
const submit = [...dom.document.querySelectorAll("button")].find((button) => button.textContent === "Submit");
|
||||
assert.ok(submit);
|
||||
await dispatchDomEvent(submit, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.deepEqual(responses, [[request.requestId, ["fixture-new"], false]]);
|
||||
assert.deepEqual(queue, []);
|
||||
const saved = scenario.remember && scenario.allowSavePassword !== false;
|
||||
assert.equal(updates, saved ? 1 : 0);
|
||||
assert.equal(hosts[0].identityId, saved && identities.length ? "" : host.identityId);
|
||||
assert.equal(hosts[0].password, saved ? "fixture-new" : host.password);
|
||||
assert.equal(hosts[0].savePassword, saved);
|
||||
assert.equal(hosts[1], otherHost);
|
||||
assert.deepEqual(identity, originalIdentity);
|
||||
|
||||
// Exercise the reconnect launcher that builds the actual SSH bridge request.
|
||||
for (const [index, candidate] of hosts.entries()) {
|
||||
let started: { username: string; password?: string } | undefined;
|
||||
const ctx = {
|
||||
host: candidate, identities, keys: [], knownHosts: [], resolvedChainHosts: [],
|
||||
sessionId: `reconnect-${index}`, terminalSettings: {},
|
||||
sessionRef: { current: null }, hasConnectedRef: { current: true },
|
||||
hasRunStartupCommandRef: { current: true }, disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null }, fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null }, pendingAuthRef: { current: null },
|
||||
promptLineBreakStateRef: { current: createPromptLineBreakState() }, sudoAutofillRef: { current: null },
|
||||
updateStatus: noop, setStatus: noop, setError: noop, setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop, setAuthPassword: noop, setProgressLogs: noop,
|
||||
setProgressValue: noop, setChainProgress: noop,
|
||||
terminalBackend: {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: { username: string; password?: string }) => { started = options; return `reconnect-${index}`; },
|
||||
onSessionData: () => noop, onSessionExit: () => noop, onChainProgress: () => noop,
|
||||
writeToSession: noop, resizeSession: noop,
|
||||
},
|
||||
};
|
||||
await createTerminalSessionStarters(ctx as never).startSSH({
|
||||
cols: 80, rows: 24, write: noop, writeln: noop, scrollToBottom: noop,
|
||||
} as never);
|
||||
assert.ok(started);
|
||||
assert.equal(started.username, scenario.identityUsername?.trim() || scenario.hostUsername);
|
||||
assert.equal(started.password, saved && index === 0 ? "fixture-new" : identities.length ? "fixture-old" : "fixture-host-old");
|
||||
}
|
||||
// A fresh request resets the checkbox and submitting state for the next case.
|
||||
await renderer.render(<I18nProvider locale="en"><KeyboardInteractiveModal request={null} onSubmit={() => false} onCancel={() => false} /></I18nProvider>);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
formatTerminalTitleConnectionAddress,
|
||||
focusTerminalFromDisconnectedNotice,
|
||||
getLineTimestampToggleHostUpdate,
|
||||
resolveLineTimestampGutterLayout,
|
||||
resolveNetworkDeviceTipRightInset,
|
||||
resolveTerminalRightInset,
|
||||
resolveTerminalTopOffsets,
|
||||
subscribeTerminalAlternateScreenChanges,
|
||||
shouldBlockTerminalReconnectForTarget,
|
||||
shouldEnableStatusBarDisconnect,
|
||||
shouldEnableStatusBarReconnect,
|
||||
@@ -182,6 +184,127 @@ test("line timestamp toolbar toggle is hidden when timestamps are unavailable",
|
||||
assert.equal(shouldShowLineTimestampToolbarToggle(true, undefined), false);
|
||||
});
|
||||
|
||||
test("line timestamp gutter reserves no space while the alternate screen is active", () => {
|
||||
assert.deepEqual(resolveLineTimestampGutterLayout({
|
||||
showLineTimestampGutter: true,
|
||||
alternateScreenActive: true,
|
||||
gutterWidth: 72,
|
||||
}), { enabled: false, width: 0 });
|
||||
assert.deepEqual(resolveLineTimestampGutterLayout({
|
||||
showLineTimestampGutter: true,
|
||||
alternateScreenActive: false,
|
||||
gutterWidth: 72,
|
||||
}), { enabled: true, width: 72 });
|
||||
assert.deepEqual(resolveLineTimestampGutterLayout({
|
||||
showLineTimestampGutter: false,
|
||||
alternateScreenActive: false,
|
||||
gutterWidth: 72,
|
||||
}), { enabled: false, width: 0 });
|
||||
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /const activeLineTimestampGutterWidth = lineTimestampGutterLayout\.width/);
|
||||
assert.match(source, /enabled=\{lineTimestampGutterLayout\.enabled\}/);
|
||||
assert.doesNotMatch(source, /enabled=\{showLineTimestampGutter\}/);
|
||||
assert.match(source, /aria-pressed=\{showLineTimestampGutter\}/);
|
||||
assert.doesNotMatch(source, /aria-pressed=\{[^}]*alternateScreenActive/);
|
||||
});
|
||||
|
||||
type AlternateScreenWatchTerm = {
|
||||
buffer: { active: { type: string } };
|
||||
onRender: (callback: () => void) => { dispose: () => void };
|
||||
};
|
||||
|
||||
function createAlternateScreenWatchTerm(type: "normal" | "alternate") {
|
||||
const renders: Array<() => void> = [];
|
||||
let disposed = 0;
|
||||
const term: AlternateScreenWatchTerm = {
|
||||
buffer: { active: { type } },
|
||||
onRender(callback) {
|
||||
renders.push(callback);
|
||||
return { dispose: () => { disposed += 1; } };
|
||||
},
|
||||
};
|
||||
return {
|
||||
term,
|
||||
renders,
|
||||
disposeCount: () => disposed,
|
||||
};
|
||||
}
|
||||
|
||||
test("alternate screen enter and leave drive the timestamp gutter", (t) => {
|
||||
const watched = createAlternateScreenWatchTerm("normal");
|
||||
const seen: boolean[] = [];
|
||||
const stop = subscribeTerminalAlternateScreenChanges(
|
||||
() => watched.term,
|
||||
(active) => { seen.push(active); },
|
||||
);
|
||||
t.after(stop);
|
||||
|
||||
assert.deepEqual(seen, [false]);
|
||||
watched.term.buffer.active.type = "alternate";
|
||||
watched.renders[0]();
|
||||
watched.renders[0]();
|
||||
watched.term.buffer.active.type = "normal";
|
||||
watched.renders[0]();
|
||||
assert.deepEqual(seen, [false, true, false]);
|
||||
|
||||
stop();
|
||||
assert.equal(watched.disposeCount(), 1);
|
||||
watched.term.buffer.active.type = "alternate";
|
||||
watched.renders[0]();
|
||||
assert.deepEqual(seen, [false, true, false]);
|
||||
});
|
||||
|
||||
test("alternate screen watch attaches after the terminal runtime exists", (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const watched = createAlternateScreenWatchTerm("alternate");
|
||||
let current: AlternateScreenWatchTerm | null = null;
|
||||
const seen: boolean[] = [];
|
||||
const stop = subscribeTerminalAlternateScreenChanges(
|
||||
() => current,
|
||||
(active) => { seen.push(active); },
|
||||
);
|
||||
t.after(() => {
|
||||
stop();
|
||||
t.mock.timers.reset();
|
||||
});
|
||||
|
||||
assert.deepEqual(seen, [false]);
|
||||
current = watched.term;
|
||||
t.mock.timers.tick(50);
|
||||
assert.deepEqual(seen, [false, true]);
|
||||
assert.equal(watched.renders.length, 1);
|
||||
t.mock.timers.tick(500);
|
||||
assert.equal(watched.renders.length, 1);
|
||||
assert.deepEqual(seen, [false, true]);
|
||||
});
|
||||
|
||||
test("alternate screen watch follows a replacement terminal runtime", (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const first = createAlternateScreenWatchTerm("alternate");
|
||||
const second = createAlternateScreenWatchTerm("normal");
|
||||
let current: AlternateScreenWatchTerm = first.term;
|
||||
const seen: boolean[] = [];
|
||||
const stop = subscribeTerminalAlternateScreenChanges(
|
||||
() => current,
|
||||
(active) => { seen.push(active); },
|
||||
);
|
||||
t.after(() => {
|
||||
stop();
|
||||
t.mock.timers.reset();
|
||||
});
|
||||
|
||||
assert.deepEqual(seen, [true]);
|
||||
current = second.term;
|
||||
t.mock.timers.tick(250);
|
||||
assert.deepEqual(seen, [true, false]);
|
||||
assert.equal(first.disposeCount(), 1);
|
||||
assert.equal(second.renders.length, 1);
|
||||
second.term.buffer.active.type = "alternate";
|
||||
second.renders[0]();
|
||||
assert.deepEqual(seen, [true, false, true]);
|
||||
});
|
||||
|
||||
test("selection AI overlay honors the visibility preference", () => {
|
||||
const overlayPosition = { left: 120, top: 80 };
|
||||
const addSelection = () => {};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Terminal as XTerm } from '@xterm/xterm';
|
||||
import { ChevronsLeft, GripVertical, Minimize2, Network, PanelLeft, X as XIcon } from 'lucide-react';
|
||||
import { isTerminalSensitiveInputActive } from './runtime/terminalSensitiveInputRegistry';
|
||||
import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuContent';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
resolveTerminalTimestampGutterColor,
|
||||
resolveTerminalTimestampGutterWidth,
|
||||
} from './TerminalTimestampGutter';
|
||||
import { isTerminalAlternateScreenActive } from './terminalHibernateRuntime';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -122,6 +124,108 @@ export function shouldShowLineTimestampToolbarToggle(
|
||||
return lineTimestampsAvailable !== false && Boolean(onUpdateHost);
|
||||
}
|
||||
|
||||
export function resolveLineTimestampGutterLayout({
|
||||
showLineTimestampGutter,
|
||||
alternateScreenActive,
|
||||
gutterWidth,
|
||||
}: {
|
||||
showLineTimestampGutter: boolean;
|
||||
alternateScreenActive: boolean;
|
||||
gutterWidth: number;
|
||||
}): { enabled: boolean; width: number } {
|
||||
const enabled = showLineTimestampGutter && !alternateScreenActive;
|
||||
return { enabled, width: enabled ? gutterWidth : 0 };
|
||||
}
|
||||
|
||||
const ALTERNATE_SCREEN_ATTACH_RETRY_MS = 50;
|
||||
const ALTERNATE_SCREEN_RUNTIME_WATCH_MS = 250;
|
||||
|
||||
type TerminalAlternateScreenDisposable = { dispose: () => void };
|
||||
|
||||
type TerminalAlternateScreenWatchTarget = {
|
||||
buffer?: { active?: { type?: string } };
|
||||
onRender?: (callback: () => void) => TerminalAlternateScreenDisposable;
|
||||
};
|
||||
|
||||
const readAlternateScreenActive = (
|
||||
term: TerminalAlternateScreenWatchTarget | null | undefined,
|
||||
): boolean => {
|
||||
try {
|
||||
if (!term?.buffer?.active) return false;
|
||||
return isTerminalAlternateScreenActive(term as XTerm);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Alternate-screen programs intentionally skip timestamp recording. onRender
|
||||
* collapses and restores the gutter immediately. The timer attaches once xterm
|
||||
* exists and follows a replacement runtime after hibernate or reconnect.
|
||||
*/
|
||||
export function subscribeTerminalAlternateScreenChanges(
|
||||
getTerm: () => TerminalAlternateScreenWatchTarget | null | undefined,
|
||||
onChange: (alternateScreenActive: boolean) => void,
|
||||
): () => void {
|
||||
let disposed = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let renderDisposable: TerminalAlternateScreenDisposable | null = null;
|
||||
let attachedTerm: TerminalAlternateScreenWatchTarget | null = null;
|
||||
let lastActive: boolean | null = null;
|
||||
|
||||
const publish = (active: boolean) => {
|
||||
if (disposed || lastActive === active) return;
|
||||
lastActive = active;
|
||||
onChange(active);
|
||||
};
|
||||
|
||||
const detachRender = () => {
|
||||
renderDisposable?.dispose();
|
||||
renderDisposable = null;
|
||||
};
|
||||
|
||||
const watch = () => {
|
||||
if (disposed) return;
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
const term = getTerm() ?? null;
|
||||
if (term !== attachedTerm) {
|
||||
detachRender();
|
||||
attachedTerm = term;
|
||||
if (term?.onRender) {
|
||||
renderDisposable = term.onRender(() => {
|
||||
if (disposed) return;
|
||||
const current = getTerm() ?? null;
|
||||
if (current !== term) {
|
||||
watch();
|
||||
return;
|
||||
}
|
||||
publish(readAlternateScreenActive(current));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
publish(readAlternateScreenActive(term));
|
||||
if (disposed) return;
|
||||
const delay = term?.onRender
|
||||
? ALTERNATE_SCREEN_RUNTIME_WATCH_MS
|
||||
: ALTERNATE_SCREEN_ATTACH_RETRY_MS;
|
||||
timer = setTimeout(watch, delay);
|
||||
};
|
||||
|
||||
watch();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
detachRender();
|
||||
attachedTerm = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the tab/pane; only tear down the live transport. */
|
||||
export function shouldEnableStatusBarDisconnect(
|
||||
status: 'connecting' | 'connected' | 'disconnected' | undefined,
|
||||
@@ -486,6 +590,11 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
}, [hostTimestampsEnabled, timestampOverride]);
|
||||
const showLineTimestampGutter = lineTimestampsAvailable !== false
|
||||
&& (timestampOverride ?? hostTimestampsEnabled);
|
||||
const [alternateScreenActive, setAlternateScreenActive] = useState(false);
|
||||
useEffect(() => subscribeTerminalAlternateScreenChanges(
|
||||
() => termRef.current,
|
||||
setAlternateScreenActive,
|
||||
), [sessionId, status, termRef]);
|
||||
const lineTimestampColor = resolveTerminalTimestampGutterColor(effectiveTheme.colors);
|
||||
const [lineTimestampGutterWidth, setLineTimestampGutterWidth] = useState(() => (
|
||||
resolveTerminalTimestampGutterWidth({ fontSize: effectiveFontSize })
|
||||
@@ -497,7 +606,12 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
const handleLineTimestampGutterWidthChange = useCallback((width: number) => {
|
||||
setLineTimestampGutterWidth((current) => (current === width ? current : width));
|
||||
}, []);
|
||||
const activeLineTimestampGutterWidth = showLineTimestampGutter ? lineTimestampGutterWidth : 0;
|
||||
const lineTimestampGutterLayout = resolveLineTimestampGutterLayout({
|
||||
showLineTimestampGutter,
|
||||
alternateScreenActive,
|
||||
gutterWidth: lineTimestampGutterWidth,
|
||||
});
|
||||
const activeLineTimestampGutterWidth = lineTimestampGutterLayout.width;
|
||||
const lineTimestampToggleLabel = showLineTimestampGutter
|
||||
? t("terminal.toolbar.timestampsDisable")
|
||||
: t("terminal.toolbar.timestampsEnable");
|
||||
@@ -1086,7 +1200,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
<TerminalTimestampGutter
|
||||
termRef={termRef}
|
||||
containerRef={containerRef}
|
||||
enabled={showLineTimestampGutter}
|
||||
enabled={lineTimestampGutterLayout.enabled}
|
||||
top={terminalContentTop}
|
||||
left={terminalBodyInset}
|
||||
bottom={terminalBottomInset}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
// CloudLinker — issue #3592: the vendor identifies a single-channel gateway
|
||||
// and proposes this distinctive banner. Older "Go" banners cannot identify it
|
||||
// safely; this compatibility entry requires the vendor banner change.
|
||||
// russh — issue #3973: russh returns CHANNEL_FAILURE for wantReply=false env
|
||||
// requests, desynchronizing ssh2's callback FIFO so pty-req/shell appear to
|
||||
// fail. Skip env via the single-channel path. No chunked-write evidence.
|
||||
// JumpServer KoKo advertises Version "JumpServer" and multiplexes. Omit it.
|
||||
|
||||
const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
|
||||
@@ -23,6 +26,7 @@ const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
|
||||
"TERM-SSHD",
|
||||
"CLOUDBILITY",
|
||||
"CLOUDLINKER",
|
||||
"russh",
|
||||
]);
|
||||
|
||||
// #3146 also drops multi-character writes. That evidence is only for these
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
// CloudLinker — issue #3592: the vendor identifies a single-channel gateway
|
||||
// and proposes this distinctive banner. Older "Go" banners cannot identify it
|
||||
// safely; this compatibility entry requires the vendor banner change.
|
||||
// russh — issue #3973: russh returns CHANNEL_FAILURE for wantReply=false env
|
||||
// requests, desynchronizing ssh2's callback FIFO so pty-req/shell appear to
|
||||
// fail. Skip env via the single-channel path. No chunked-write evidence.
|
||||
// JumpServer KoKo advertises Version "JumpServer" and multiplexes. Omit it.
|
||||
|
||||
const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
|
||||
@@ -23,6 +26,7 @@ const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
|
||||
"TERM-SSHD",
|
||||
"CLOUDBILITY",
|
||||
"CLOUDLINKER",
|
||||
"russh",
|
||||
]);
|
||||
|
||||
// #3146 also drops multi-character writes. That evidence is only for these
|
||||
|
||||
@@ -20,6 +20,10 @@ test("single-channel banners match only the supported software tokens", () => {
|
||||
"CloudLinker",
|
||||
"SSH-2.0-CloudLinker",
|
||||
"cloudlinker-1.0",
|
||||
"russh",
|
||||
"SSH-2.0-russh",
|
||||
"SSH-2.0-russh_0.45.0",
|
||||
"RuSSH",
|
||||
]) {
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel(version), true, version);
|
||||
}
|
||||
@@ -49,6 +53,12 @@ test("single-channel banner match ignores OpenSSH, JumpServer, and lookalike tok
|
||||
"SSH-2.0-Go",
|
||||
"notcloudlinker",
|
||||
"CloudLinkerProxy",
|
||||
"russhd",
|
||||
"myrussh",
|
||||
"prerussh",
|
||||
"russhserver",
|
||||
"SSH-2.0-russhd",
|
||||
"notRussh",
|
||||
]) {
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel(version), false, String(version));
|
||||
}
|
||||
@@ -59,6 +69,8 @@ test("chunked-write limit stays on BHostSSH and TERM-SSHD", () => {
|
||||
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-TERM-SSHD"), true);
|
||||
assert.equal(remoteDisallowsChunkedChannelWrite("CLOUDBILITY-4.14"), false);
|
||||
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-CloudLinker"), false);
|
||||
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-russh"), false);
|
||||
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-russh_0.45.0"), false);
|
||||
assert.equal(remoteDisallowsChunkedChannelWrite("OpenSSH_9.6"), false);
|
||||
assert.equal(remoteDisallowsChunkedChannelWrite("superterm-sshd"), false);
|
||||
});
|
||||
@@ -82,7 +94,7 @@ test("CommonJS and renderer modules agree on SSH banner behavior", async () => {
|
||||
const browserModule = await import("./singleChannelSshBanner.shared.mjs");
|
||||
const nodeModule = require("./singleChannelSshBanner.shared.cjs");
|
||||
for (const version of [
|
||||
"BHostSSH_7.0", "TERM-SSHD", "CLOUDBILITY-4.14", "CloudLinker", "SSH-2.0-Go", "OpenSSH_9.6", "JumpServer", "",
|
||||
"BHostSSH_7.0", "TERM-SSHD", "CLOUDBILITY-4.14", "CloudLinker", "SSH-2.0-russh_0.45.0", "SSH-2.0-Go", "OpenSSH_9.6", "JumpServer", "",
|
||||
]) {
|
||||
assert.equal(browserModule.remoteSoftwareRequiresSingleChannel(version), nodeModule.remoteSoftwareRequiresSingleChannel(version));
|
||||
assert.equal(browserModule.remoteDisallowsChunkedChannelWrite(version), nodeModule.remoteDisallowsChunkedChannelWrite(version));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -138,6 +138,7 @@ function createZmodemSentry(opts) {
|
||||
let pendingTerminalSuppressionAt = 0;
|
||||
let pendingHexHeaderFragment = null;
|
||||
let pendingHexHeaderFragmentTimer = null;
|
||||
let emittedHexHeaderPrefixBytes = 0;
|
||||
// Set when a stripped hex header's trailing XON (0x11) may arrive in the
|
||||
// next terminal-output chunk; at most one byte is then dropped.
|
||||
let suppressNextTerminalXon = false;
|
||||
@@ -172,6 +173,11 @@ function createZmodemSentry(opts) {
|
||||
const HEX_HEADER_FRAGMENT_TTL_MS = Number.isFinite(opts.hexHeaderFragmentTtlMs)
|
||||
? Math.max(1, opts.hexHeaderFragmentTtlMs)
|
||||
: 1000;
|
||||
// A bare "*" or "**" is also ordinary shell echo (#3959). Give this
|
||||
// ambiguous printable prefix at most one frame to complete, rather than
|
||||
// holding every typed/pasted star for the full protocol timeout. Once
|
||||
// ZDLE arrives, retain the longer timeout for fragmented protocol bytes.
|
||||
const PRINTABLE_HEX_PREFIX_TTL_MS = Math.min(16, HEX_HEADER_FRAGMENT_TTL_MS);
|
||||
/**
|
||||
* Upper bound on how long a drag-drop echo suppression may stay armed.
|
||||
* The suppression hides the terminal echo of the upload command, whose echo
|
||||
@@ -308,6 +314,10 @@ function createZmodemSentry(opts) {
|
||||
buf.subarray(0, prefixAt),
|
||||
buf.subarray(prefixAt + headerLength),
|
||||
]);
|
||||
// Printable prefix bytes may already have been shown after the short
|
||||
// echo timeout. If they belonged to this header, do not skip bytes
|
||||
// from the ordinary output that follows the stripped header.
|
||||
emittedHexHeaderPrefixBytes = Math.min(emittedHexHeaderPrefixBytes, prefixAt);
|
||||
searchFrom = prefixAt;
|
||||
}
|
||||
|
||||
@@ -317,8 +327,17 @@ function createZmodemSentry(opts) {
|
||||
function flushPendingHexHeaderFragment() {
|
||||
pendingHexHeaderFragmentTimer = null;
|
||||
const buf = pendingHexHeaderFragment;
|
||||
pendingHexHeaderFragment = null;
|
||||
if (buf?.length) onData(buf);
|
||||
if (!buf?.length) return;
|
||||
const output = buf.subarray(emittedHexHeaderPrefixBytes);
|
||||
if (buf.every((byte) => byte === 0x2a)) {
|
||||
// Keep at most two printable bytes as parsing context: a later ZDLE
|
||||
// must still be recognized and stripped, without replaying the stars.
|
||||
emittedHexHeaderPrefixBytes = buf.length;
|
||||
} else {
|
||||
pendingHexHeaderFragment = null;
|
||||
emittedHexHeaderPrefixBytes = 0;
|
||||
}
|
||||
if (output.length) onData(output);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -412,19 +431,27 @@ function createZmodemSentry(opts) {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
|
||||
const fragmentAt = findTrailingHexHeaderFragment(buf);
|
||||
if (fragmentAt === -1) return buf;
|
||||
const alreadyEmitted = emittedHexHeaderPrefixBytes;
|
||||
emittedHexHeaderPrefixBytes = fragmentAt === -1
|
||||
? 0
|
||||
: Math.max(0, alreadyEmitted - fragmentAt);
|
||||
if (fragmentAt === -1) return buf.subarray(alreadyEmitted);
|
||||
|
||||
if (fragmentAt < buf.length) {
|
||||
pendingHexHeaderFragment = Buffer.from(buf.subarray(fragmentAt));
|
||||
if (!pendingHexHeaderFragmentTimer) {
|
||||
if (!pendingHexHeaderFragmentTimer
|
||||
&& pendingHexHeaderFragment.length > emittedHexHeaderPrefixBytes) {
|
||||
const ttl = pendingHexHeaderFragment.every((byte) => byte === 0x2a)
|
||||
? PRINTABLE_HEX_PREFIX_TTL_MS
|
||||
: HEX_HEADER_FRAGMENT_TTL_MS;
|
||||
pendingHexHeaderFragmentTimer = setTimeout(
|
||||
flushPendingHexHeaderFragment,
|
||||
HEX_HEADER_FRAGMENT_TTL_MS,
|
||||
ttl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return fragmentAt ? buf.subarray(0, fragmentAt) : Buffer.alloc(0);
|
||||
return buf.subarray(Math.min(alreadyEmitted, fragmentAt), fragmentAt);
|
||||
}
|
||||
|
||||
function looksLikeResidualZmodemData(data) {
|
||||
|
||||
@@ -1814,6 +1814,93 @@ function assertNoZmodemHeaderBytes(terminal, context) {
|
||||
);
|
||||
}
|
||||
|
||||
test("ordinary star echoes wait at most one frame for a possible ZMODEM prefix", (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
for (const text of ["*", "**", "ls *", "**/*.txt*"]) {
|
||||
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
|
||||
sentry.consume(Buffer.from(text));
|
||||
t.mock.timers.tick(16);
|
||||
assert.equal(joinedText(terminal), text, `echo delayed: ${text}`);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(joinedText(terminal), text, "the fragment must not be emitted twice");
|
||||
}
|
||||
});
|
||||
|
||||
test("a following parenthesis and repeated stars preserve echo order", (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
|
||||
for (const text of ["*", "(", "*", "*", "*", "!"]) {
|
||||
sentry.consume(Buffer.from(text));
|
||||
t.mock.timers.tick(16);
|
||||
}
|
||||
assert.equal(joinedText(terminal), "*(***!");
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(joinedText(terminal), "*(***!");
|
||||
});
|
||||
|
||||
test("expired printable prefixes preserve ordinary bytes across chunk boundaries", (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const input = Buffer.from("a***(b)**/*.txt *\x18X **\x18Bxy more *\uFF0A");
|
||||
for (const gap of [0, 16, 200]) {
|
||||
for (let chunkSize = 1; chunkSize <= input.length; chunkSize += 1) {
|
||||
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
|
||||
for (let i = 0; i < input.length; i += chunkSize) {
|
||||
sentry.consume(input.subarray(i, i + chunkSize));
|
||||
t.mock.timers.tick(gap);
|
||||
}
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(Buffer.concat(terminal), input, `chunkSize=${chunkSize}, gap=${gap}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a prefix containing ZDLE keeps the full fragmented-header timeout", (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
|
||||
const header = buildHexHeaderBytes("ZRQINIT");
|
||||
sentry.consume(header.subarray(0, 3));
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(joinedText(terminal), "");
|
||||
sentry.consume(header.subarray(3));
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(joinedText(terminal), "");
|
||||
assertNoZmodemHeaderBytes(terminal, "delayed ZDLE prefix");
|
||||
});
|
||||
|
||||
test("a slow split header after printable-prefix expiry still detects and hides control bytes", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
for (const prefixLength of [1, 2]) {
|
||||
let detected = false;
|
||||
const { sentry, terminal } = createProbeSentry({
|
||||
hexHeaderFragmentTtlMs: undefined,
|
||||
selectDownloadDirectory: async () => { detected = true; return { canceled: true }; },
|
||||
});
|
||||
const header = buildHexHeaderBytes("ZRQINIT");
|
||||
sentry.consume(header.subarray(0, prefixLength));
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(joinedText(terminal), "*".repeat(prefixLength));
|
||||
sentry.consume(header.subarray(prefixLength, 3));
|
||||
t.mock.timers.tick(200);
|
||||
sentry.consume(header.subarray(3));
|
||||
await Promise.resolve();
|
||||
assert.equal(detected, true, "the printable echo timeout must not reset protocol detection");
|
||||
assert.equal(joinedText(terminal), "*".repeat(prefixLength));
|
||||
assertNoZmodemHeaderBytes(terminal, "slow split after printable prefix");
|
||||
}
|
||||
});
|
||||
|
||||
test("stripping a slow header does not skip ordinary output after it", (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
|
||||
const header = buildHexHeaderBytes("ZRQINIT");
|
||||
sentry.consume(header.subarray(0, 2));
|
||||
t.mock.timers.tick(200);
|
||||
sentry.consume(Buffer.concat([header.subarray(2), Buffer.from("* next prompt")]));
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(joinedText(terminal), "*** next prompt");
|
||||
assertNoZmodemHeaderBytes(terminal, "ordinary output after slow header");
|
||||
});
|
||||
|
||||
test("fragmented sz ZRQINIT preamble never reaches the terminal", () => {
|
||||
const { sentry, terminal } = createProbeSentry();
|
||||
const stream = Buffer.concat([
|
||||
|
||||
+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");
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!Dockerfile
|
||||
!ca-certificates.crt
|
||||
@@ -0,0 +1,4 @@
|
||||
/ca-certificates.crt
|
||||
/docker-config/
|
||||
/private-fixture.json
|
||||
/__pycache__/
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM debian:bookworm-slim
|
||||
COPY --chmod=644 ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
RUN chmod 755 /etc/ssl /etc/ssl/certs && printf '%s\n' 'Acquire::https::CaInfo "/etc/ssl/certs/ca-certificates.crt";' > /etc/apt/apt.conf.d/99fixture-ca && sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources && apt-get -o Acquire::Retries=0 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 update && apt-get install -y --no-install-recommends openssh-server passwd && rm -rf /var/lib/apt/lists/*
|
||||
RUN useradd --create-home --shell /bin/bash fixtureki && mkdir -p /run/sshd && ssh-keygen -A
|
||||
RUN printf '%s\n' 'Port 22' 'ListenAddress 0.0.0.0' 'UsePAM yes' 'PasswordAuthentication yes' 'KbdInteractiveAuthentication yes' 'PubkeyAuthentication no' 'PermitRootLogin no' 'PrintMotd no' 'LogLevel VERBOSE' > /etc/ssh/sshd_config
|
||||
CMD ["/usr/sbin/sshd", "-D", "-e"]
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the disposable fixture with this environment's existing CA trust."""
|
||||
import os, socket, subprocess
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
root=Path(__file__).resolve().parent
|
||||
certs=[]
|
||||
for value in ['/etc/ssl/certs/ca-certificates.crt',os.environ.get('NODE_EXTRA_CA_CERTS','')]:
|
||||
path=Path(value)
|
||||
if value and path.is_file(): certs.append(path.read_bytes())
|
||||
(root/'ca-certificates.crt').write_bytes(b'\n'.join(certs))
|
||||
(root/'ca-certificates.crt').chmod(0o644)
|
||||
args=['docker','build']
|
||||
seen=set()
|
||||
for key in ['HTTP_PROXY','HTTPS_PROXY','http_proxy','https_proxy']:
|
||||
if os.environ.get(key):
|
||||
args += ['--build-arg',key]
|
||||
host=urlsplit(os.environ[key]).hostname
|
||||
if host and host not in seen:
|
||||
seen.add(host)
|
||||
args += ['--add-host',f'{host}:{socket.gethostbyname(host)}']
|
||||
args += ['-t','netcatty-3982-pam-fixture',str(root)]
|
||||
subprocess.run(args,env={**os.environ,'DOCKER_CONFIG':str(root/'docker-config')},check=True)
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Manage only this kit's disposable, loopback-bound SSH containers."""
|
||||
import json, os, secrets, subprocess, sys
|
||||
from pathlib import Path
|
||||
ROOT=Path(__file__).resolve().parent
|
||||
CONFIG=ROOT/'private-fixture.json'
|
||||
def run(*args, input=None):
|
||||
return subprocess.check_output(['docker',*args],input=input,text=True).strip()
|
||||
def reset(config):
|
||||
for name, host in config['hosts'].items():
|
||||
run('exec','-i',host['container'],'chpasswd',input=f"{config['username']}:{config['currentPassword']}\n")
|
||||
run('exec',host['container'],'chage','-d','0' if name=='target' else '1',config['username'])
|
||||
if sys.argv[1]=='start':
|
||||
if CONFIG.exists(): raise SystemExit('Existing fixture config: use reset or stop first.')
|
||||
config={'username':'fixtureki','currentPassword':secrets.token_urlsafe(18)+'aA1!','newPassword':secrets.token_urlsafe(18)+'bB2!','hosts':{}}
|
||||
try:
|
||||
for name in ['target','control']:
|
||||
container='netcatty-3982-'+name+'-'+secrets.token_hex(4)
|
||||
run('run','--rm','-d','--name',container,'-p','127.0.0.1::22','netcatty-3982-pam-fixture')
|
||||
port=int(run('port',container,'22/tcp').rsplit(':',1)[1])
|
||||
config['hosts'][name]={'container':container,'hostname':'127.0.0.1','port':port}
|
||||
reset(config)
|
||||
except BaseException:
|
||||
for host in config['hosts'].values(): subprocess.run(['docker','rm','-f',host['container']],stdout=subprocess.DEVNULL)
|
||||
raise
|
||||
fd=os.open(CONFIG,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
|
||||
with os.fdopen(fd,'w') as f: json.dump(config,f,indent=2)
|
||||
print('Disposable fixture ready. Private connection details:',CONFIG)
|
||||
elif sys.argv[1]=='reset':
|
||||
reset(json.loads(CONFIG.read_text()))
|
||||
print('Reset target expiry and control account; retained the synthetic fixture secrets.')
|
||||
elif sys.argv[1]=='stop':
|
||||
config=json.loads(CONFIG.read_text())
|
||||
for host in config['hosts'].values(): run('rm','-f',host['container'])
|
||||
CONFIG.unlink()
|
||||
print('Removed this fixture containers and private connection details.')
|
||||
else: raise SystemExit('Use start, reset, or stop')
|
||||
@@ -0,0 +1,68 @@
|
||||
# Verify remembered KI passwords and reconnects
|
||||
|
||||
Run this recipe in the parent's native GUI session on PR #3983 head `2096e93420f547be6491b7fb5df90f20f75a9545`.
|
||||
Use a fresh Netcatty profile and the two disposable local servers from this kit.
|
||||
The OpenSSH/PAM fixture requires Docker.
|
||||
Keep private-fixture.json out of screenshots, recordings, uploads, and reports.
|
||||
|
||||
1. Run `python3 build.py` in this directory.
|
||||
2. Run `python3 manage.py start`.
|
||||
3. Read private-fixture.json locally for the synthetic account and connection details. Do not copy those values into the report.
|
||||
4. Build the app at the exact PR head with `npm ci` and `npm run build`.
|
||||
5. From the app checkout, launch Electron directly with a new disposable profile:
|
||||
|
||||
```bash
|
||||
node <<'NODE'
|
||||
const { spawn } = require('node:child_process');
|
||||
const { mkdtempSync } = require('node:fs');
|
||||
const { applyElectronLaunchEnv } = require('./electron/launchEnv.cjs');
|
||||
const { getTempFilePath } = require('./electron/bridges/tempDirBridge.cjs');
|
||||
const profile = mkdtempSync(getTempFilePath('ki-expiry-profile-'));
|
||||
console.log('Disposable app profile:', profile);
|
||||
const child = spawn(require('electron'), ['.', `--user-data-dir=${profile}`], {
|
||||
stdio: 'inherit',
|
||||
env: applyElectronLaunchEnv(process.env),
|
||||
});
|
||||
child.on('error', (error) => { console.error(error.message); process.exit(1); });
|
||||
child.on('exit', (code) => process.exit(code ?? 1));
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => { if (!child.killed) child.kill(signal); });
|
||||
}
|
||||
NODE
|
||||
```
|
||||
|
||||
`electron/launch.cjs` does not forward command-line arguments. Do not pass the profile flag to that script. Keep the sandbox enabled. Coordinate the GUI session through the parent.
|
||||
6. In **Keychain**, create one password identity from the kit's current account.
|
||||
7. In **Hosts**, add the kit's target and control servers. Link both hosts to that identity.
|
||||
8. Enable **Prioritize keyboard-interactive authentication** on the target host.
|
||||
9. Set the target host's stored username to a different synthetic account while retaining the identity. The identity must supply the actual login account.
|
||||
10. Connect to the control host. Verify that the shell opens. Close its session.
|
||||
11. Connect to the target host. Accept only the disposable server's host key.
|
||||
12. Enter the current password when the expiry dialog requests it.
|
||||
13. Enter the new password from private-fixture.json. Select **Remember** and submit.
|
||||
14. Confirm the new password when requested. Verify that the shell opens.
|
||||
15. Close the target session. Reconnect by opening the saved target host. Verify that login succeeds with the saved new password.
|
||||
16. Open the target host's editor. Verify that the identity reference is cleared, the actual identity username is retained, and the password remains masked.
|
||||
17. Open **Keychain**. Verify that the shared identity remains unchanged without revealing its password.
|
||||
18. Reconnect to the control host. Verify that it still opens with the shared identity.
|
||||
19. Record the exact app head and the expected results. Capture screenshots at the expiry dialog, Remember selection, first shell, reconnected shell, detached target identity, and connected control host.
|
||||
20. Repeat after `python3 manage.py reset` with **Remember** unchecked. Verify that the target keeps its identity reference. Do not treat a subsequent prompt or failed reconnect with the old password as a fix regression.
|
||||
21. Repeat with a blank stored host username. Verify that the identity supplies and preserves the actual account.
|
||||
22. Repeat with no identity reference. Verify that Remember saves the new host password and reconnect succeeds.
|
||||
|
||||
## Verify the separate wrong-current path
|
||||
|
||||
1. Run `python3 manage.py reset`.
|
||||
2. Restore the target host's shared identity and its old fixture credential in the app.
|
||||
3. Connect to the target host.
|
||||
4. Enter an intentionally incorrect current password in the KI dialog.
|
||||
5. Capture the transition to the terminal password-change prompt in the same connection.
|
||||
6. Record whether the server rejects KI, accepts password authentication, and then opens the shell. Do not infer protocol events from the dialog alone.
|
||||
7. Run `node probe.cjs /absolute/path/to/Netcatty wrong` after another reset to capture the redacted protocol event sequence.
|
||||
8. Keep this outcome separate from remembered KI credential persistence. The terminal password prompt does not offer KI Remember.
|
||||
|
||||
## Remove the fixture
|
||||
|
||||
1. Close the native app sessions.
|
||||
2. Run `python3 manage.py stop` to remove both containers and the private connection details.
|
||||
3. Remove the disposable app profile.
|
||||
@@ -0,0 +1,62 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
const appRequire = createRequire(path.resolve(process.argv[2], 'package.json'));
|
||||
const { Client } = appRequire('ssh2');
|
||||
const { buildAuthHandler, createKeyboardInteractiveHandler } = appRequire('./electron/bridges/sshAuthHelper.cjs');
|
||||
const keyboard = appRequire('./electron/bridges/keyboardInteractiveHandler.cjs');
|
||||
const config = JSON.parse(fs.readFileSync(path.join(__dirname, 'private-fixture.json')));
|
||||
const mode = process.argv[3] || 'correct';
|
||||
const host = config.hosts.target;
|
||||
const events = [];
|
||||
const originalLog = console.log;
|
||||
console.log = () => {}; // Main-process debug output includes server instructions; report only bounded event labels.
|
||||
(async () => {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const client = new Client();
|
||||
let settled = false;
|
||||
let sawNew = false;
|
||||
let sawCurrent = false;
|
||||
const timer = setTimeout(() => done(new Error('fixture probe timed out')), 20000);
|
||||
function done(error) {
|
||||
if (settled) return;
|
||||
settled=true;
|
||||
clearTimeout(timer);
|
||||
keyboard.cancelRequestsForSession('fixture-probe');
|
||||
client.end();
|
||||
error ? reject(error) : resolve({events,sawNew,sawCurrent});
|
||||
}
|
||||
const sender = { id: 3982, isDestroyed: () => false, send(channel, data) {
|
||||
if(channel !== 'netcatty:keyboard-interactive') return;
|
||||
const prompts = data.prompts.map(p=>p.prompt.toLowerCase());
|
||||
const answers = prompts.map(prompt=>{
|
||||
if(prompt.includes('current')) { sawCurrent=true; events.push('ki-current'); return mode==='wrong'?'intentionally-wrong-fixture-response':config.currentPassword; }
|
||||
if(prompt.includes('new') || prompt.includes('retype')) { sawNew=true; events.push(prompt.includes('retype')?'ki-confirm':'ki-new'); return config.newPassword; }
|
||||
events.push('ki-password'); return config.currentPassword;
|
||||
});
|
||||
queueMicrotask(()=>keyboard.handleResponse({sender:{id:3982}},{requestId:data.requestId,responses:answers,cancelled:false}));
|
||||
}};
|
||||
const auth = buildAuthHandler({authMethod:'password',username:config.username,password:config.currentPassword,requiresMfa:true,onAuthAttempt:m=>events.push('auth-'+m)});
|
||||
client.on('keyboard-interactive', createKeyboardInteractiveHandler({sender,sessionId:'fixture-probe',hostname:'127.0.0.1',password:config.currentPassword,scope:'terminal'}));
|
||||
client.on('error',done);
|
||||
client.on('ready',()=>{
|
||||
events.push('ssh-ready');
|
||||
client.shell({term:'xterm',cols:80,rows:24},(error,stream)=>{
|
||||
if(error) return done(error);
|
||||
events.push('shell-open');
|
||||
let text='';
|
||||
stream.on('data',chunk=>{
|
||||
text+=chunk.toString();
|
||||
if(mode==='wrong' && /Current password:/i.test(text)) {events.push('shell-current-password');done();}
|
||||
if(mode==='correct' && /fixtureki@/.test(text)) {events.push('shell-prompt');done();}
|
||||
});
|
||||
});
|
||||
});
|
||||
client.connect({host:'127.0.0.1',port:host.port,username:config.username,password:config.currentPassword,authHandler:auth.authHandler,tryKeyboard:true,readyTimeout:15000,hostVerifier:()=>true});
|
||||
});
|
||||
assert.equal(result.sawCurrent,true);
|
||||
if(mode==='correct') assert.equal(result.sawNew,true);
|
||||
else { assert.equal(result.sawNew,false); assert.ok(events.includes('shell-current-password')); }
|
||||
originalLog(JSON.stringify({mode,...result},null,2));
|
||||
})().catch(error=>{originalLog(error.message);process.exitCode=1;});
|
||||
Reference in new issue
Block a user