mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
018a6ab8b2 | ||
|
|
f0897c3c17 | ||
|
|
8d826594d3 | ||
|
|
a4999fdf1d | ||
|
|
e907336ef5 |
No files matched your search
@@ -330,6 +330,9 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
|
||||
run: npm run ${{ matrix.pack_script }}
|
||||
|
||||
- name: Test packaged SSH cipher negotiation
|
||||
run: node scripts/test-packaged-ssh-ciphers.cjs
|
||||
|
||||
- name: Test tray panel layout
|
||||
run: npm run test:tray-panel-layout
|
||||
|
||||
@@ -631,6 +634,9 @@ jobs:
|
||||
done
|
||||
npm run pack:linux-x64
|
||||
|
||||
- name: Test packaged SSH cipher negotiation
|
||||
run: node scripts/test-packaged-ssh-ciphers.cjs
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify x64
|
||||
|
||||
@@ -765,6 +771,9 @@ jobs:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-arm64
|
||||
|
||||
- name: Test packaged SSH cipher negotiation
|
||||
run: node scripts/test-packaged-ssh-ciphers.cjs
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify arm64
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test SSH cipher negotiation in Electron's crypto runtime
|
||||
env:
|
||||
NETCATTY_SSH_TEST_RUNTIME: electron
|
||||
run: node --test electron/bridges/sshChaChaTransport.test.cjs
|
||||
|
||||
- name: Test browser first-link lifecycle
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
|
||||
|
||||
@@ -138,5 +143,10 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Test SSH cipher negotiation in Windows Electron's crypto runtime
|
||||
env:
|
||||
NETCATTY_SSH_TEST_RUNTIME: electron
|
||||
run: node --test electron/bridges/sshChaChaTransport.test.cjs
|
||||
|
||||
- name: Exercise two SSH password prompts in the desktop app
|
||||
run: node scripts/terminal-broadcast-ssh.live.cjs
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost/' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'CustomEvent', 'Event'] as const) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value: dom.window[name], configurable: true });
|
||||
}
|
||||
for (const [name, value] of Object.entries({ IS_REACT_ACT_ENVIRONMENT: true, fetch: () => { throw new Error('Network is forbidden in model discovery tests'); } })) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true });
|
||||
}
|
||||
after(() => {
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
|
||||
const { TooltipProvider } = await import('../../../ui/tooltip');
|
||||
const { ModelSelector } = await import('./ModelSelector');
|
||||
|
||||
type Response = { ok: boolean; status?: number; data: string; error?: string };
|
||||
function deferred() {
|
||||
let resolve!: (value: Response) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<Response>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
type Props = React.ComponentProps<typeof ModelSelector>;
|
||||
const baseProps: Props = {
|
||||
value: '', onChange: () => {}, baseURL: 'http://127.0.0.1:9217/v1',
|
||||
providerId: 'custom', style: 'openai', apiKey: 'SYNTHETIC_FIXTURE_KEY', customHeaders: {},
|
||||
};
|
||||
async function mount() {
|
||||
const requests: ({ url: string; method: string; body: string | undefined } & ReturnType<typeof deferred>)[] = [];
|
||||
const allowlistCalls: string[] = [];
|
||||
let overflow = false;
|
||||
Object.defineProperty(window, 'netcatty', { configurable: true, value: {
|
||||
aiAllowlistAddHost: async (baseURL: string) => { allowlistCalls.push(baseURL); return { ok: true }; },
|
||||
aiFetch: (url: string, method: string, _headers: unknown, body: string | undefined) => {
|
||||
// Hold instead of rejecting if a regression exceeds the budget, so even a
|
||||
// broken auto-fetch effect cannot create an uncontrolled test loop.
|
||||
if (requests.length >= 4) { overflow = true; return new Promise<Response>(() => {}); }
|
||||
const pending = deferred();
|
||||
requests.push({ url, method, body, ...pending });
|
||||
return pending.promise;
|
||||
},
|
||||
} });
|
||||
let renderer!: ReactTestRenderer;
|
||||
const view = (updates: Partial<Props> = {}) => <I18nProvider locale="en"><TooltipProvider>
|
||||
<ModelSelector {...baseProps} {...updates} />
|
||||
</TooltipProvider></I18nProvider>;
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const input = () => renderer.root.findByType('input');
|
||||
const open = async () => { await act(async () => input().props.onFocus()); };
|
||||
const close = async () => { await act(async () => input().props.onBlur()); };
|
||||
const refresh = async () => {
|
||||
const button = renderer.root.findAllByType('button').find((node) => typeof node.props.onClick === 'function')!;
|
||||
await act(async () => button.props.onClick({ defaultPrevented: false }));
|
||||
};
|
||||
const markup = () => JSON.stringify(renderer.toJSON());
|
||||
return {
|
||||
requests, allowlistCalls, input, open, close, refresh, markup,
|
||||
update: async (updates: Partial<Props>) => { await act(async () => renderer.update(view(updates))); },
|
||||
assertBounded: () => assert.equal(overflow, false, 'mock request budget was not exceeded'),
|
||||
unmount: async () => { await act(async () => renderer.unmount()); },
|
||||
};
|
||||
}
|
||||
const notFound: Response = { ok: false, status: 404, data: '{"error":"fixture has no model-list route"}' };
|
||||
const models = (id: string): Response => ({ ok: true, status: 200, data: JSON.stringify({ data: [{ id }] }) });
|
||||
|
||||
for (const failure of ['404', 'transport', 'invalid-json'] as const) {
|
||||
test(`model discovery ${failure} failure stays visible without auto-retry; Refresh retries once`, async () => {
|
||||
const h = await mount();
|
||||
const reject = (index: number) => {
|
||||
if (failure === 'transport') h.requests[index].reject(new Error('Synthetic model-list transport failure'));
|
||||
else h.requests[index].resolve(failure === '404' ? notFound : { ok: true, status: 200, data: '{not json' });
|
||||
};
|
||||
try {
|
||||
assert.equal(h.requests.length, 0);
|
||||
await h.open();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.equal(h.requests[0].url, 'http://127.0.0.1:9217/v1/models');
|
||||
assert.equal(h.requests[0].method, 'GET');
|
||||
assert.equal(h.requests[0].body, undefined);
|
||||
await act(async () => reject(0));
|
||||
assert.equal(h.requests.length, 1, 'failure must settle instead of triggering the loading effect again');
|
||||
assert.match(h.markup(), /text-destructive/);
|
||||
assert.doesNotMatch(h.markup(), /Loading models/);
|
||||
await h.close();
|
||||
await h.open();
|
||||
await h.update({ customHeaders: {} });
|
||||
assert.equal(h.requests.length, 1, 'reopen or an equal-config render must not retry a failure');
|
||||
assert.match(h.markup(), /text-destructive/);
|
||||
await h.refresh();
|
||||
assert.equal(h.requests.length, 2);
|
||||
await act(async () => reject(1));
|
||||
assert.equal(h.requests.length, 2);
|
||||
assert.match(h.markup(), /text-destructive/);
|
||||
assert.equal(h.allowlistCalls.length, 2);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('changing provider configuration clears a settled error and permits one fresh discovery', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await h.open();
|
||||
await act(async () => h.requests[0].resolve(notFound));
|
||||
assert.equal(h.requests.length, 1);
|
||||
await h.update({ baseURL: 'http://127.0.0.1:9217/second-provider/v1', providerId: 'openai' });
|
||||
assert.equal(h.requests.length, 2);
|
||||
assert.equal(h.requests[1].url, 'http://127.0.0.1:9217/second-provider/v1/models');
|
||||
await act(async () => h.requests[1].resolve(models('second-model')));
|
||||
assert.equal(h.requests.length, 2);
|
||||
assert.match(h.markup(), /second-model/);
|
||||
assert.doesNotMatch(h.markup(), /Failed to fetch models/);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
});
|
||||
|
||||
test('a provider switch ignores an older pending discovery failure', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await h.open();
|
||||
await h.update({ baseURL: 'http://127.0.0.1:9217/second-provider/v1' });
|
||||
assert.equal(h.requests.length, 2);
|
||||
await act(async () => h.requests[1].resolve(models('current-model')));
|
||||
await act(async () => h.requests[0].resolve(notFound));
|
||||
assert.equal(h.requests.length, 2);
|
||||
assert.match(h.markup(), /current-model/);
|
||||
assert.doesNotMatch(h.markup(), /Failed to fetch models/);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
});
|
||||
|
||||
test('successful discovery remains cached across reopen and manual model entry remains controlled', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await h.open();
|
||||
await act(async () => h.requests[0].resolve(models('listed-model')));
|
||||
await h.close();
|
||||
await h.open();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.match(h.markup(), /listed-model/);
|
||||
const changed: string[] = [];
|
||||
await h.update({ onChange: (value) => { changed.push(value); } });
|
||||
await act(async () => h.input().props.onChange({ target: { value: 'manual-model' } }));
|
||||
await h.update({ value: changed[0] });
|
||||
assert.deepEqual(changed, ['manual-model']);
|
||||
assert.equal(h.input().props.value, 'manual-model');
|
||||
assert.equal(h.requests.length, 1);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
});
|
||||
|
||||
test('closing and unmounting with a pending discovery do not trigger another request', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await h.open();
|
||||
await h.close();
|
||||
await act(async () => h.requests[0].resolve(notFound));
|
||||
assert.equal(h.requests.length, 1);
|
||||
await h.refresh();
|
||||
assert.equal(h.requests.length, 2);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
await act(async () => h.requests[1].resolve(notFound));
|
||||
assert.equal(h.requests.length, 2);
|
||||
});
|
||||
@@ -175,12 +175,12 @@ export const ModelSelector: React.FC<{
|
||||
}
|
||||
}, [baseURL, effectiveModelsEndpoint, apiKey, customHeaders, resolvedStyle, skipTLSVerify, discoveryKey]);
|
||||
|
||||
// Auto-fetch when dropdown first opens
|
||||
// Failed discovery stays settled until explicit refresh or a new discovery key.
|
||||
useEffect(() => {
|
||||
if (isOpen && canFetch && !hasFetched && !isLoading) {
|
||||
if (isOpen && canFetch && !hasFetched && !isLoading && error === null) {
|
||||
void fetchModels();
|
||||
}
|
||||
}, [isOpen, canFetch, hasFetched, isLoading, fetchModels]);
|
||||
}, [isOpen, canFetch, hasFetched, isLoading, error, fetchModels]);
|
||||
|
||||
// Filter preset and discovered models by current input value (inline autocomplete).
|
||||
const suggestions = useMemo(() => {
|
||||
|
||||
@@ -357,6 +357,34 @@ test("startPluginConnection displays status diagnostics when a Provider exits wi
|
||||
]);
|
||||
});
|
||||
|
||||
test("startSSH forwards a saved ChaCha20-only host setting to the transport", async () => {
|
||||
const host = JSON.parse(JSON.stringify({
|
||||
id: "host-chacha",
|
||||
label: "Synthetic ChaCha20 server",
|
||||
hostname: "127.0.0.1",
|
||||
username: "synthetic",
|
||||
algorithms: { cipher: ["chacha20-poly1305@openssh.com"] },
|
||||
}));
|
||||
let captured: Record<string, unknown> | undefined;
|
||||
const ctx = createStarterContext({
|
||||
host,
|
||||
terminalBackend: {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: Record<string, unknown>) => {
|
||||
captured = options;
|
||||
return "ssh-session";
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
},
|
||||
});
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
assert.deepEqual(captured?.algorithmOverrides, host.algorithms);
|
||||
});
|
||||
|
||||
test("startSSH forwards imported system agent authentication settings", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
const terminalBackend = {
|
||||
|
||||
@@ -9,7 +9,8 @@ const FIXED_DH_GROUP_BY_KEX = Object.freeze({
|
||||
});
|
||||
|
||||
const CIPHER_SSL_NAME_BY_ALGORITHM = Object.freeze({
|
||||
"chacha20-poly1305@openssh.com": "chacha20",
|
||||
// Patched ssh2 supplies original ChaCha20 when Electron/BoringSSL lacks it.
|
||||
// Do not filter this SSH cipher against the OpenSSL-only inventory.
|
||||
"aes128-gcm@openssh.com": "aes-128-gcm",
|
||||
"aes256-gcm@openssh.com": "aes-256-gcm",
|
||||
"aes128-gcm": "aes-128-gcm",
|
||||
|
||||
@@ -144,17 +144,17 @@ for (const [label, buildAlgorithms] of [
|
||||
});
|
||||
});
|
||||
|
||||
test(`${label} drops chacha20-poly1305 when the runtime lacks chacha20`, () => {
|
||||
test(`${label} retains chacha20-poly1305 through ssh2's fallback when the runtime lacks chacha20`, () => {
|
||||
const ciphersWithoutChacha20 = crypto.getCiphers().filter((cipher) => cipher !== "chacha20");
|
||||
|
||||
withAlgorithmRuntime({ ciphers: ciphersWithoutChacha20 }, () => {
|
||||
const algorithms = buildAlgorithms(false);
|
||||
|
||||
assert.equal(algorithms.cipher.includes("chacha20-poly1305@openssh.com"), false);
|
||||
assert.equal(algorithms.cipher.includes("chacha20-poly1305@openssh.com"), true);
|
||||
});
|
||||
});
|
||||
|
||||
test(`${label} cipher override is filtered against runtime support`, () => {
|
||||
test(`${label} preserves explicit ChaCha20 and mixed cipher overrides without native crypto`, () => {
|
||||
const ciphersWithoutChacha20 = crypto.getCiphers().filter((cipher) => cipher !== "chacha20");
|
||||
|
||||
withAlgorithmRuntime({ ciphers: ciphersWithoutChacha20 }, () => {
|
||||
@@ -164,7 +164,10 @@ for (const [label, buildAlgorithms] of [
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(algorithms.cipher, ["aes256-ctr"]);
|
||||
assert.deepEqual(algorithms.cipher, ["chacha20-poly1305@openssh.com", "aes256-ctr"]);
|
||||
assert.deepEqual(buildAlgorithms(false, {
|
||||
algorithmOverrides: { cipher: ["chacha20-poly1305@openssh.com"] },
|
||||
}).cipher, ["chacha20-poly1305@openssh.com"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { Server } = require("ssh2");
|
||||
|
||||
const CHACHA = "chacha20-poly1305@openssh.com";
|
||||
const AES = "aes128-gcm@openssh.com";
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const { privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const hostKey = privateKey.export({ type: "pkcs1", format: "pem" });
|
||||
|
||||
async function connect(t, {
|
||||
serverCiphers = [CHACHA],
|
||||
cipher,
|
||||
entrypoint = "netcatty:start",
|
||||
withoutNativeChacha = true,
|
||||
} = {}) {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-chacha-"));
|
||||
t.after(() => fs.rmSync(homeDir, { recursive: true, force: true }));
|
||||
const offered = {};
|
||||
let negotiated;
|
||||
const connections = new Set();
|
||||
// The server uses Node/OpenSSL's ChaCha20; only the child uses the fallback.
|
||||
const server = new Server({
|
||||
hostKeys: [hostKey],
|
||||
algorithms: { cipher: serverCiphers },
|
||||
debug(line) {
|
||||
const match = /Handshake: \(remote\) (C->S|S->C) cipher: (.*)/.exec(line);
|
||||
if (match) offered[match[1]] = match[2].split(",");
|
||||
},
|
||||
}, (client) => {
|
||||
connections.add(client);
|
||||
client.on("error", () => {});
|
||||
client.on("close", () => connections.delete(client));
|
||||
client.on("handshake", (info) => { negotiated = info; });
|
||||
client.on("authentication", (ctx) => ctx.accept());
|
||||
client.on("ready", () => {
|
||||
client.on("session", (accept) => {
|
||||
const session = accept();
|
||||
session.on("pty", (acceptPty) => acceptPty?.());
|
||||
session.on("env", (acceptEnv) => acceptEnv?.());
|
||||
session.on("shell", (acceptShell) => {
|
||||
const stream = acceptShell();
|
||||
stream.on("data", (data) => stream.write(data));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
t.after(() => {
|
||||
for (const client of connections) client.end();
|
||||
server.close();
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const runtime = process.env.NETCATTY_SSH_TEST_RUNTIME === "electron"
|
||||
? require("electron")
|
||||
: path.resolve(process.env.NETCATTY_SSH_TEST_RUNTIME || process.execPath);
|
||||
const child = spawn(runtime, ["scripts/fixtures/ssh-chacha-client.cjs"], {
|
||||
cwd: root,
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
t.after(() => child.kill());
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (data) => { stdout += data; });
|
||||
child.stderr.on("data", (data) => { stderr += data; });
|
||||
child.stdin.end(JSON.stringify({
|
||||
port: server.address().port,
|
||||
appRoot: process.env.NETCATTY_SSH_TEST_APP_ROOT,
|
||||
homeDir,
|
||||
// Cross several SSH packets, exercising both nonce/counter directions.
|
||||
input: "synthetic SSH echo\r\n".repeat(4000),
|
||||
algorithmOverrides: cipher ? { cipher } : undefined,
|
||||
entrypoint,
|
||||
withoutNativeChacha,
|
||||
}));
|
||||
const code = await new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("close", resolve);
|
||||
});
|
||||
assert.equal(code, 0, stderr || stdout);
|
||||
const resultLine = stdout.split("\n").find((line) => line.startsWith("NETCATTY_TRANSPORT_RESULT="));
|
||||
assert.ok(resultLine, stderr || stdout);
|
||||
const result = JSON.parse(resultLine.slice("NETCATTY_TRANSPORT_RESULT=".length));
|
||||
if (process.env.NETCATTY_SSH_TEST_APP_ROOT && result.connected) {
|
||||
assert.equal(result.packagedDependency, "node_modules/@noble/ciphers/chacha.js");
|
||||
}
|
||||
return { ...result, offered, negotiated };
|
||||
}
|
||||
|
||||
for (const entrypoint of ["netcatty:start", "netcatty:test-connection"]) {
|
||||
test(`${entrypoint} advertises and negotiates an explicit ChaCha20-only override without native crypto`, { timeout: 15000 }, async (t) => {
|
||||
const result = await connect(t, { cipher: [CHACHA], entrypoint });
|
||||
assert.equal(result.connected, true, result.error);
|
||||
assert.equal(result.nativeChacha, false);
|
||||
assert.deepEqual(result.offered["C->S"], [CHACHA]);
|
||||
assert.deepEqual(result.offered["S->C"], [CHACHA]);
|
||||
assert.equal(result.negotiated.cs.cipher, CHACHA);
|
||||
assert.equal(result.negotiated.sc.cipher, CHACHA);
|
||||
if (entrypoint === "netcatty:start") assert.equal(result.echoed, true);
|
||||
});
|
||||
}
|
||||
|
||||
test("default ciphers can connect to a ChaCha20-only server", async (t) => {
|
||||
const result = await connect(t);
|
||||
assert.equal(result.connected, true, result.error);
|
||||
assert.equal(result.echoed, true);
|
||||
assert.ok(result.offered["C->S"].includes(CHACHA));
|
||||
assert.equal(result.negotiated.cs.cipher, CHACHA);
|
||||
});
|
||||
|
||||
test("default and mixed offers retain AES preference and explicit cipher order", async (t) => {
|
||||
for (const cipher of [undefined, [AES, CHACHA], [CHACHA, AES]]) {
|
||||
const result = await connect(t, { serverCiphers: [AES, CHACHA], cipher });
|
||||
assert.equal(result.connected, true, result.error);
|
||||
assert.equal(result.echoed, true);
|
||||
assert.equal(result.negotiated.cs.cipher, cipher?.[0] || AES);
|
||||
if (cipher) assert.deepEqual(result.offered["C->S"], cipher);
|
||||
}
|
||||
});
|
||||
|
||||
test("an explicit AES-only choice never falls back to ChaCha20", async (t) => {
|
||||
const result = await connect(t, { cipher: [AES] });
|
||||
assert.equal(result.connected, false);
|
||||
assert.match(result.error, /no matching C->S cipher/);
|
||||
assert.deepEqual(result.offered["C->S"], [AES]);
|
||||
});
|
||||
|
||||
test("the runtime's unmodified crypto path still supports ChaCha20", async (t) => {
|
||||
const result = await connect(t, { cipher: [CHACHA], withoutNativeChacha: false });
|
||||
assert.equal(result.connected, true, result.error);
|
||||
assert.equal(result.echoed, true);
|
||||
});
|
||||
@@ -234,7 +234,7 @@ async function createSequentialTransferFixture(t) {
|
||||
payload, files, sftp, handles, clients,
|
||||
rejectedOpens: () => rejectedOpens,
|
||||
openCount: () => opens,
|
||||
async start(direction, sftpId = "first", options = {}) {
|
||||
async start(direction, sftpId = "first", options = {}, beforeTransfer = null) {
|
||||
const transferId = options.transferId || crypto.randomUUID();
|
||||
const localPath = tempDirBridge.getTempFilePath(`term-concurrent-${transferId}.bin`);
|
||||
paths.push(localPath);
|
||||
@@ -250,6 +250,7 @@ async function createSequentialTransferFixture(t) {
|
||||
const remoteStage = `/.${path.basename(targetPath)}.netcatty-${transferId}.part`;
|
||||
files.set(remoteStage, payload.subarray(0, options.uploadCheckpointBytes || 0));
|
||||
}
|
||||
if (beforeTransfer) await beforeTransfer;
|
||||
const result = await transferBridge.startTransfer({ sender: { send() {} } }, {
|
||||
transferId,
|
||||
sourcePath: direction === "upload" ? localPath : "/source.bin", targetPath,
|
||||
@@ -367,22 +368,58 @@ test("TERM-SSHD queued cancellation releases its turn without closing the owner
|
||||
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 0);
|
||||
});
|
||||
|
||||
test("TERM-SSHD failed transfer releases the physical channel for the next alias", async (t) => {
|
||||
const fixture = await createSequentialTransferFixture(t);
|
||||
const originalOpen = fixture.sftp.open;
|
||||
let rejectFirst = true;
|
||||
fixture.sftp.open = (...args) => {
|
||||
if (rejectFirst) {
|
||||
rejectFirst = false;
|
||||
args.at(-1)(Object.assign(new Error("Permission denied"), { code: 3 }));
|
||||
} else originalOpen(...args);
|
||||
};
|
||||
const [first, next] = await Promise.all([fixture.start("download"), fixture.start("download", "alias")]);
|
||||
assert.match(first.result.error || "", /Permission denied/);
|
||||
assert.equal(next.result.error, undefined);
|
||||
assert.deepEqual(await fs.readFile(next.targetPath), fixture.payload);
|
||||
assert.equal(fixture.rejectedOpens(), 0);
|
||||
});
|
||||
for (const firstArrival of ["first", "alias"]) {
|
||||
test(`TERM-SSHD failed transfer releases the physical channel for the next alias (${firstArrival} arrives first)`, { timeout: 5_000 }, async (t) => {
|
||||
const fixture = await createSequentialTransferFixture(t);
|
||||
const originalOpen = fixture.sftp.open;
|
||||
let signalFirstOpen;
|
||||
let rejectFirstOpen;
|
||||
let firstOpenPending = true;
|
||||
const firstOpened = new Promise((resolve) => { signalFirstOpen = resolve; });
|
||||
fixture.sftp.open = (...args) => {
|
||||
if (firstOpenPending) {
|
||||
firstOpenPending = false;
|
||||
rejectFirstOpen = () => args.at(-1)(Object.assign(new Error("Permission denied"), { code: 3 }));
|
||||
signalFirstOpen();
|
||||
} else originalOpen(...args);
|
||||
};
|
||||
let releaseDelayed;
|
||||
const delayed = new Promise((resolve) => { releaseDelayed = resolve; });
|
||||
// Gate fixture admission to exercise both physical OPEN arrival orders
|
||||
// while keeping calls-array order fixed. Local I/O can reorder real calls.
|
||||
const calls = [
|
||||
fixture.start("download", "first", {}, firstArrival === "alias" ? delayed : null),
|
||||
fixture.start("download", "alias", {}, firstArrival === "first" ? delayed : null),
|
||||
];
|
||||
const settled = Promise.allSettled(calls);
|
||||
const failedIndex = firstArrival === "first" ? 0 : 1;
|
||||
let transfers;
|
||||
try {
|
||||
await Promise.race([firstOpened, calls[failedIndex].then(() => {
|
||||
throw new Error("Transfer finished before the controlled OPEN");
|
||||
})]);
|
||||
releaseDelayed();
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp) === 0 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 1,
|
||||
"the other alias must queue before the first OPEN fails");
|
||||
} finally {
|
||||
releaseDelayed();
|
||||
rejectFirstOpen?.();
|
||||
transfers = await settled;
|
||||
}
|
||||
for (const transfer of transfers) assert.equal(transfer.status, "fulfilled");
|
||||
const failed = transfers[failedIndex].value;
|
||||
const succeeded = transfers[1 - failedIndex].value;
|
||||
assert.match(failed.result.error || "", /Permission denied/);
|
||||
assert.equal(succeeded.result.error, undefined);
|
||||
assert.deepEqual(await fs.readFile(succeeded.targetPath), fixture.payload);
|
||||
assert.equal(fixture.rejectedOpens(), 0);
|
||||
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 0);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
for (const direction of ["download", "upload"]) {
|
||||
|
||||
Generated
+13
@@ -20,6 +20,7 @@
|
||||
"@netcatty/plugin-cli": "0.1.0-internal",
|
||||
"@netcatty/plugin-contract": "0.1.0-internal",
|
||||
"@netcatty/plugin-sdk": "0.1.0-internal",
|
||||
"@noble/ciphers": "2.4.0",
|
||||
"@openai/codex-sdk": "0.144.3",
|
||||
"@smithy/node-http-handler": "^4.4.9",
|
||||
"@withfig/autocomplete": "^2.692.3",
|
||||
@@ -4683,6 +4684,18 @@
|
||||
"resolved": "packages/plugin-sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
|
||||
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"@netcatty/plugin-cli": "0.1.0-internal",
|
||||
"@netcatty/plugin-contract": "0.1.0-internal",
|
||||
"@netcatty/plugin-sdk": "0.1.0-internal",
|
||||
"@noble/ciphers": "2.4.0",
|
||||
"@openai/codex-sdk": "0.144.3",
|
||||
"@smithy/node-http-handler": "^4.4.9",
|
||||
"@withfig/autocomplete": "^2.692.3",
|
||||
|
||||
+120
-5
@@ -860,10 +860,10 @@ index 9f33c02..9751164 100644
|
||||
if (names !== undefined) {
|
||||
sftp._debug && sftp._debug(
|
||||
diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib/protocol/constants.js
|
||||
index ad77592..a249e00 100644
|
||||
index ad77592..03ab33b 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/constants.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/constants.js
|
||||
@@ -71,9 +71,30 @@ const DEFAULT_SERVER_HOST_KEY = [
|
||||
@@ -71,14 +71,37 @@ const DEFAULT_SERVER_HOST_KEY = [
|
||||
];
|
||||
if (eddsaSupported)
|
||||
DEFAULT_SERVER_HOST_KEY.unshift('ssh-ed25519');
|
||||
@@ -894,7 +894,15 @@ index ad77592..a249e00 100644
|
||||
|
||||
|
||||
const canUseCipher = (() => {
|
||||
@@ -160,6 +181,7 @@ const COMPAT = {
|
||||
const ciphers = crypto.getCiphers();
|
||||
- return (name) => ciphers.includes(CIPHER_INFO[name].sslName);
|
||||
+ // crypto.js supplies the original ChaCha20 primitive on BoringSSL too.
|
||||
+ return (name) => (name === 'chacha20-poly1305@openssh.com'
|
||||
+ || ciphers.includes(CIPHER_INFO[name].sslName));
|
||||
})();
|
||||
let DEFAULT_CIPHER = [
|
||||
// http://tools.ietf.org/html/rfc5647
|
||||
@@ -160,6 +183,7 @@ const COMPAT = {
|
||||
DYN_RPORT_BUG: 1 << 2,
|
||||
BUG_DHGEX_LARGE: 1 << 3,
|
||||
IMPLY_RSA_SHA2_SIGALGS: 1 << 4,
|
||||
@@ -902,7 +910,7 @@ index ad77592..a249e00 100644
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
@@ -330,6 +352,7 @@ module.exports = {
|
||||
@@ -330,6 +354,7 @@ module.exports = {
|
||||
COMPAT_CHECKS: [
|
||||
[ 'Cisco-1.25', COMPAT.BAD_DHGEX ],
|
||||
[ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ],
|
||||
@@ -910,6 +918,113 @@ index ad77592..a249e00 100644
|
||||
[ /^[0-9.]+$/, COMPAT.OLD_EXIT ], // old SSH.com implementations
|
||||
[ /^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG ],
|
||||
[ /^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS ],
|
||||
diff --git a/node_modules/ssh2/lib/protocol/crypto.js b/node_modules/ssh2/lib/protocol/crypto.js
|
||||
index 851cc73..088de2a 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/crypto.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/crypto.js
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
} = require('crypto');
|
||||
|
||||
const { readUInt32BE, writeUInt32BE } = require('./utils.js');
|
||||
+const { chacha20, nativeChacha20 } = require('./crypto/chacha20.js');
|
||||
|
||||
const FastBuffer = Buffer[Symbol.species];
|
||||
const MAX_SEQNO = 2 ** 32 - 1;
|
||||
@@ -198,20 +199,17 @@ class ChaChaPolyCipherNative {
|
||||
POLY1305_OUT_COMPUTE[0] = 0; // Set counter to 0 (little endian)
|
||||
writeUInt32BE(POLY1305_OUT_COMPUTE, this.outSeqno, 12);
|
||||
const polyKey =
|
||||
- createCipheriv('chacha20', this._encKeyMain, POLY1305_OUT_COMPUTE)
|
||||
- .update(POLY1305_ZEROS);
|
||||
+ chacha20(this._encKeyMain, POLY1305_OUT_COMPUTE, POLY1305_ZEROS);
|
||||
|
||||
// Encrypt packet length
|
||||
const pktLenEnc =
|
||||
- createCipheriv('chacha20', this._encKeyPktLen, POLY1305_OUT_COMPUTE)
|
||||
- .update(packet.slice(0, 4));
|
||||
+ chacha20(this._encKeyPktLen, POLY1305_OUT_COMPUTE, packet.slice(0, 4));
|
||||
this._onWrite(pktLenEnc);
|
||||
|
||||
// Encrypt rest of packet
|
||||
POLY1305_OUT_COMPUTE[0] = 1; // Set counter to 1 (little endian)
|
||||
const payloadEnc =
|
||||
- createCipheriv('chacha20', this._encKeyMain, POLY1305_OUT_COMPUTE)
|
||||
- .update(packet.slice(4));
|
||||
+ chacha20(this._encKeyMain, POLY1305_OUT_COMPUTE, packet.slice(4));
|
||||
this._onWrite(payloadEnc);
|
||||
|
||||
// Calculate Poly1305 MAC
|
||||
@@ -650,8 +648,7 @@ class ChaChaPolyDecipherNative {
|
||||
writeUInt32BE(POLY1305_OUT_COMPUTE, this.inSeqno, 12);
|
||||
|
||||
const decLenBytes =
|
||||
- createDecipheriv('chacha20', this._decKeyPktLen, POLY1305_OUT_COMPUTE)
|
||||
- .update(this._lenBuf);
|
||||
+ chacha20(this._decKeyPktLen, POLY1305_OUT_COMPUTE, this._lenBuf);
|
||||
this._len = readUInt32BE(decLenBytes, 0);
|
||||
|
||||
if (this._len > MAX_PACKET_SIZE
|
||||
@@ -706,8 +703,7 @@ class ChaChaPolyDecipherNative {
|
||||
POLY1305_OUT_COMPUTE[0] = 0; // Set counter to 0 (little endian)
|
||||
writeUInt32BE(POLY1305_OUT_COMPUTE, this.inSeqno, 12);
|
||||
const polyKey =
|
||||
- createCipheriv('chacha20', this._decKeyMain, POLY1305_OUT_COMPUTE)
|
||||
- .update(POLY1305_ZEROS);
|
||||
+ chacha20(this._decKeyMain, POLY1305_OUT_COMPUTE, POLY1305_ZEROS);
|
||||
|
||||
// Calculate and compare Poly1305 MACs
|
||||
poly1305_auth(POLY1305_RESULT_MALLOC,
|
||||
@@ -729,8 +725,7 @@ class ChaChaPolyDecipherNative {
|
||||
// Decrypt packet
|
||||
POLY1305_OUT_COMPUTE[0] = 1; // Set counter to 1 (little endian)
|
||||
const packet =
|
||||
- createDecipheriv('chacha20', this._decKeyMain, POLY1305_OUT_COMPUTE)
|
||||
- .update(this._packet);
|
||||
+ chacha20(this._decKeyMain, POLY1305_OUT_COMPUTE, this._packet);
|
||||
|
||||
const payload = new FastBuffer(packet.buffer,
|
||||
packet.byteOffset + 1,
|
||||
@@ -1496,7 +1491,7 @@ function createCipher(config) {
|
||||
? new AESGCMCipherBinding(config)
|
||||
: new AESGCMCipherNative(config));
|
||||
case 'chacha20':
|
||||
- return (ChaChaPolyCipher && !forceNative
|
||||
+ return (ChaChaPolyCipher && nativeChacha20 && !forceNative
|
||||
? new ChaChaPolyCipherBinding(config)
|
||||
: new ChaChaPolyCipherNative(config));
|
||||
default: {
|
||||
@@ -1556,7 +1551,7 @@ function createDecipher(config) {
|
||||
? new AESGCMDecipherBinding(config)
|
||||
: new AESGCMDecipherNative(config));
|
||||
case 'chacha20':
|
||||
- return (ChaChaPolyDecipher && !forceNative
|
||||
+ return (ChaChaPolyDecipher && nativeChacha20 && !forceNative
|
||||
? new ChaChaPolyDecipherBinding(config)
|
||||
: new ChaChaPolyDecipherNative(config));
|
||||
default: {
|
||||
diff --git a/node_modules/ssh2/lib/protocol/crypto/chacha20.js b/node_modules/ssh2/lib/protocol/crypto/chacha20.js
|
||||
new file mode 100644
|
||||
index 0000000..86dd731
|
||||
--- /dev/null
|
||||
+++ b/node_modules/ssh2/lib/protocol/crypto/chacha20.js
|
||||
@@ -0,0 +1,17 @@
|
||||
+// OpenSSH uses original ChaCha20 (64-bit nonce), not the IETF/TLS AEAD.
|
||||
+// Electron's BoringSSL lacks the standalone cipher, including in sshcrypto.
|
||||
+'use strict';
|
||||
+const { createCipheriv, getCiphers } = require('crypto');
|
||||
+const nativeChacha20 = getCiphers().includes('chacha20');
|
||||
+const { chacha20orig } = nativeChacha20 ? {} : require('@noble/ciphers/chacha.js');
|
||||
+
|
||||
+function chacha20(key, iv, data) {
|
||||
+ if (nativeChacha20)
|
||||
+ return createCipheriv('chacha20', key, iv).update(data);
|
||||
+ // ssh2 only uses block counters 0 (length/Poly1305 key) and 1 (payload).
|
||||
+ // The last eight IV bytes are its big-endian SSH packet sequence nonce.
|
||||
+ return Buffer.from(chacha20orig(key, iv.subarray(8), data, undefined,
|
||||
+ iv.readUInt32LE(0)));
|
||||
+}
|
||||
+
|
||||
+module.exports = { chacha20, nativeChacha20 };
|
||||
diff --git a/node_modules/ssh2/lib/protocol/kex.js b/node_modules/ssh2/lib/protocol/kex.js
|
||||
index 811e631..422a198 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/kex.js
|
||||
@@ -1127,7 +1242,7 @@ index 811e631..422a198 100644
|
||||
onKEXPayload,
|
||||
DEFAULT_KEXINIT_CLIENT: new KexInit({
|
||||
diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib/protocol/keyParser.js
|
||||
index a276c1a..c8ff9d1 100644
|
||||
index a276c1a..57a05d6 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/keyParser.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/keyParser.js
|
||||
@@ -35,6 +35,9 @@ const SYM_PRIV_PEM = Symbol('Private key PEM');
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Runs in a separate Node/Electron process so ssh2 detects its actual crypto
|
||||
// capabilities at module load. The parent owns the disposable loopback server.
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
async function main(config) {
|
||||
os.homedir = () => config.homeDir;
|
||||
delete process.env.SSH_AUTH_SOCK;
|
||||
delete process.env.SSH_AGENT_PID;
|
||||
if (config.withoutNativeChacha) {
|
||||
const getCiphers = crypto.getCiphers;
|
||||
crypto.getCiphers = () => getCiphers().filter((name) => name !== "chacha20");
|
||||
for (const name of ["createCipheriv", "createDecipheriv"]) {
|
||||
const original = crypto[name];
|
||||
crypto[name] = (algorithm, ...args) => {
|
||||
if (algorithm === "chacha20") throw new Error("Standalone ChaCha20 unavailable");
|
||||
return original(algorithm, ...args);
|
||||
};
|
||||
}
|
||||
}
|
||||
let packagedDependency;
|
||||
if (config.appRoot) {
|
||||
config.appRoot = require("node:fs").realpathSync(config.appRoot);
|
||||
// RUN_AS_NODE supports ASAR files, but does not expose Electron's main
|
||||
// process APIs. The packaged bridge imports sessionLogsBridge's dialog/shell
|
||||
// even with logging disabled. Supply only that unrelated UI host seam;
|
||||
// ssh2, crypto, algorithm selection, and sockets remain the shipped code.
|
||||
const Module = require("node:module");
|
||||
const load = Module._load;
|
||||
Module._load = function loadWithHeadlessElectron(request, ...args) {
|
||||
if (request === "electron") return { dialog: {}, shell: {} };
|
||||
return load.call(this, request, ...args);
|
||||
};
|
||||
const packagedRequire = Module.createRequire(path.join(config.appRoot, "node_modules/ssh2/lib/protocol/crypto/chacha20.js"));
|
||||
const noblePath = packagedRequire.resolve("@noble/ciphers/chacha.js");
|
||||
if (!noblePath.startsWith(`${config.appRoot}${path.sep}`)) {
|
||||
throw new Error("ChaCha20 dependency resolved outside the packaged app");
|
||||
}
|
||||
packagedDependency = path.relative(config.appRoot, noblePath).replaceAll("\\", "/");
|
||||
}
|
||||
const bridge = require(path.join(config.appRoot || path.resolve(__dirname, "../.."), "electron/bridges/sshBridge.cjs"));
|
||||
const sessions = new Map();
|
||||
let output = "";
|
||||
let resolveOutput;
|
||||
const echoed = new Promise((resolve) => { resolveOutput = resolve; });
|
||||
bridge.init({
|
||||
sessions,
|
||||
electronModule: {},
|
||||
terminalOutputChannel: {
|
||||
openSession() {},
|
||||
send(_sessionId, data) {
|
||||
output += data;
|
||||
if (output.length >= config.input.length) resolveOutput();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
const handlers = new Map();
|
||||
bridge.registerHandlers({ handle: (name, handler) => handlers.set(name, handler), on() {} });
|
||||
const sender = new (require("node:events").EventEmitter)();
|
||||
Object.assign(sender, { id: 1, isDestroyed: () => false, send() {} });
|
||||
try {
|
||||
const result = await handlers.get(config.entrypoint)({ sender }, {
|
||||
sessionId: "synthetic-chacha-test",
|
||||
hostname: "127.0.0.1",
|
||||
port: config.port,
|
||||
username: "synthetic",
|
||||
authMethod: "password",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
skipShellDiscovery: true,
|
||||
algorithmOverrides: config.algorithmOverrides,
|
||||
});
|
||||
if (config.entrypoint === "netcatty:start") {
|
||||
sessions.get(result.sessionId).stream.write(config.input);
|
||||
await echoed;
|
||||
}
|
||||
return {
|
||||
connected: true,
|
||||
echoed: output === config.input,
|
||||
electron: process.versions.electron,
|
||||
nativeChacha: crypto.getCiphers().includes("chacha20"),
|
||||
packagedDependency,
|
||||
};
|
||||
} finally {
|
||||
for (const session of sessions.values()) session.conn?.destroy();
|
||||
bridge.stopAllMonitoring();
|
||||
}
|
||||
}
|
||||
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (data) => { input += data; });
|
||||
process.stdin.on("end", async () => {
|
||||
const config = JSON.parse(input);
|
||||
const timeout = setTimeout(() => process.exit(2), 10000);
|
||||
let result;
|
||||
try { result = await main(config); }
|
||||
catch (error) { result = { connected: false, error: error.message }; }
|
||||
clearTimeout(timeout);
|
||||
// Only public algorithm/runtime information leaves the synthetic client.
|
||||
console.log(`NETCATTY_TRANSPORT_RESULT=${JSON.stringify(result)}`);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// Exercise shipped JS/dependencies with the same Electron runtime, headlessly.
|
||||
// No BrowserWindow, real accounts, or external SSH hosts are involved.
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const releaseDir = path.resolve(process.argv[2] || "release");
|
||||
const archives = fs.readdirSync(releaseDir, { recursive: true })
|
||||
.filter((entry) => path.basename(entry) === "app.asar")
|
||||
.map((entry) => path.join(releaseDir, entry));
|
||||
if (archives.length === 0) throw new Error("No packaged app.asar found");
|
||||
for (const appRoot of archives) {
|
||||
// Outside the checkout, a missing packaged dependency cannot resolve from
|
||||
// the checkout's node_modules. Copy the paired unpacked payload as shipped.
|
||||
const detachedDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-packaged-ssh-"));
|
||||
try {
|
||||
const detachedRoot = path.join(detachedDir, "app.asar");
|
||||
fs.copyFileSync(appRoot, detachedRoot);
|
||||
fs.cpSync(`${appRoot}.unpacked`, `${detachedRoot}.unpacked`, { recursive: true });
|
||||
const env = {
|
||||
...process.env,
|
||||
NETCATTY_SSH_TEST_APP_ROOT: detachedRoot,
|
||||
NETCATTY_SSH_TEST_RUNTIME: require("electron"),
|
||||
};
|
||||
delete env.NODE_PATH;
|
||||
console.log(`Testing detached packaged SSH transport: ${appRoot}`);
|
||||
const result = spawnSync(process.execPath, ["--test", "electron/bridges/sshChaChaTransport.test.cjs"], {
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`Packaged SSH transport tests failed (${result.status})`);
|
||||
} finally {
|
||||
fs.rmSync(detachedDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user