Compare commits

..
Author SHA1 Message Date
陈奇 018a6ab8b2 fix(ai): stop automatic model discovery retries after failure 2026-10-27 05:27:39 +00:00
陈大猫 f0897c3c17 test(transfers): control TERM-SSHD alias arrival order (#3995) 2026-10-27 02:08:36 +08:00
陈大猫 8d826594d3 fix(ssh): support ChaCha20 in Electron crypto runtimes (#3992)
* fix(ssh): support ChaCha20 in Electron crypto runtimes

* test(ssh): verify packaged cipher transport across platforms

* test(ssh): isolate unused Electron UI APIs in packaged transport harness

* test(ssh): isolate packaged dependency resolution from the checkout
2026-10-27 01:59:11 +08:00
a4999fdf1d fix: clear keychain ref when remembering KI password change (#3983)
* fix: clear keychain ref when remembering KI password change

Keyboard-interactive saves wrote the new host password but left
identityId attached. resolveHostAuth prefers the shared identity
password, so the next login kept the stale secret.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: snapshot keychain username when remembering KI password

resolveHostAuth prefers identity.username. Detaching the identity
without copying that username left the host's stale account next to
the new password.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: fill blank username only when detaching KI keychain identity

detachEffectiveHostIdentity copies the effective username only when the
host username is blank. Always replacing a set username went past that
helper.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: snapshot keychain username when remembering KI password

Clearing identityId left a different host username next to the new
password. Copy the identity username onto the host whenever it is
non-empty, matching the vault password detach.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test(ssh): exercise KI remember input and reconnect

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-27 01:11:50 +08:00
陈大猫 e907336ef5 fix(terminal): preserve active SSH input mode in attached popups (#3991) 2026-10-27 01:07:10 +08:00
18 changed files with 1080 additions and 37 deletions

No files matched your search

+9
View File
@@ -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
+10
View File
@@ -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
+215 -1
View File
@@ -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,
+21 -5
View File
@@ -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));
+1 -1
View File
@@ -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 = {
+2 -1
View File
@@ -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",
+7 -4
View File
@@ -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"]) {
+13
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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');
+104
View File
@@ -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)}`);
});
+37
View File
@@ -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 });
}
}