Compare commits

..
Author SHA1 Message Date
陈大猫 7bad2f4939 fix(logs): tolerate unavailable finalization IPC
Preserve window close and layout saves when session-log finalization IPC cannot be set up, and clear its timeout on setup failures.

Follow-up for #4235 / PR #4236.
2026-11-14 14:20:47 +08:00
陈大猫 ee06726153 fix(logs): finalize logs when unconnected sessions close
Capture never-connected terminal logs at explicit session removal, including empty buffers, without finalizing logs during runtime remounts. Cover tab close, failed-dialog close, batch close, and workspace close with persistence regressions.

Fixes #4235
2026-11-14 13:43:35 +08:00
陈大猫 69de51a61d fix(logs): finalize session logs when the app quits without closing tabs
Fixes #4235
2026-11-14 13:34:14 +08:00
陈大猫 3096f86889 fix(sftp): use remote cp for fresh same-host copies (Fixes #4207) (#4226)
* fix(sftp): use remote cp for fresh same-host copies (Fixes #4207)

Ordinary SFTP tasks were marked resumable, which skipped the existing file and directory cp optimizations. Select the non-resumable route only when creating fresh matching-host copies with eligible encodings, keeping transfer-center rows and recursive children consistent. Existing checkpointed transfers retain their saved resumable path; failed cp attempts retain the download/upload fallback.

* fix(sftp): skip same-host directory cp when auto encoding resolves to non-UTF-8

* fix(sftp): preserve dest mode when same-host cp replaces a file

* fix(sftp): skip same-host cp for symlinks and directory merges

* test(sftp): mock missing dest in same-host cp cache fixture

* fix(sftp): expose lstat on session-backed copy clients
2026-11-13 17:24:27 +08:00
陈大猫 94f8bd9bc7 fix(ai): recognize Enter-PSSession remoting prompts as idle (#4229)
Fixes #4228

PowerShell remoting (Enter-PSSession / WinRM) prefixes the default
prompt with [ComputerName]: , which POWERSHELL_PROMPT_PATTERN missed,
so the readiness gate returned SHELL_NOT_READY and never sent the
command. Accept one or more [token]: prefixes before the existing
PS ...> shape, including [DBG]:.
2026-11-13 16:47:59 +08:00
陈大猫 5cd42cb518 fix(settings): include folder compression in settings search (#4231)
Fixes #4230

Settings search uses a static catalog. The SFTP "Enable folder
compression" row was already in the tab but missing from the catalog
and had no anchor, so queries such as 压缩 showed no matches. Index
that destination, plus the skip-unchanged and SSH keep-alive rows in
the same tab that had the same omission.
2026-11-13 16:30:21 +08:00
陈大猫andCodex 9ccb62ea07 fix(ssh): fingerprint OpenSSH host certs by the underlying key (Fixes #4222)
Fixes #4222

describeHostKey previously hashed the entire certificate blob, so load-balanced or re-issued certs for the same host key looked like a fingerprint change. Pin the underlying public key instead.

Share extraction across verifier, renderer migration, and system known_hosts. Migrate legacy full-cert fingerprints only when the stored certificate body is available, preserving certificate metadata and raw rekey checks.

Co-authored-by: Codex <codex@openai.com>
2026-11-13 09:33:14 +08:00
43 changed files with 1836 additions and 97 deletions

No files matched your search

+17
View File
@@ -1220,6 +1220,23 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
}
}
export function startRestoredSessionConnectionLogImpl(getCtx: AppContextGetter, session: TerminalSession) {
const { addConnectionLog, systemInfoRef } = getCtx();
const { username, hostname } = systemInfoRef.current;
addConnectionLog({
sessionId: session.id,
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
protocol: session.protocol || 'ssh',
startTime: Date.now(),
localUsername: username,
localHostname: hostname,
saved: false,
});
}
export async function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
{
+12 -2
View File
@@ -104,7 +104,7 @@ import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
import { createOpenTerminalQueue } from './openTerminalQueue';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, startRestoredSessionConnectionLogImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
type OpenSessionInNewWindowPayload = {
title?: string;
@@ -1895,8 +1895,18 @@ export function AppSideEffects() {
setActiveTabId('vault');
}, [setActiveTabId]);
const restoredSessionLogsStartedRef = useRef(new Set<string>());
// A restored tab retains its session ID, but its previous ConnectionLog is closed.
// Start the new incarnation's log before updateSessionStatus clears restoreState.
// Wrap updateSessionStatus to track lastConnectedAt on successful connection
const handleSessionStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
const session = sessionByIdRef.current.get(sessionId);
if (session?.restoreState === 'restored-disconnected'
&& (status === 'connecting' || status === 'connected')
&& !restoredSessionLogsStartedRef.current.has(sessionId)) {
restoredSessionLogsStartedRef.current.add(sessionId);
startRestoredSessionConnectionLogImpl(() => ({ addConnectionLog, systemInfoRef }), session);
}
updateSessionStatus(sessionId, status);
if (status === 'connected') {
const session = sessionByIdRef.current.get(sessionId);
@@ -1904,7 +1914,7 @@ export function AppSideEffects() {
updateHostLastConnected(session.hostId);
}
}
}, [updateSessionStatus, updateHostLastConnected]);
}, [addConnectionLog, updateSessionStatus, updateHostLastConnected]);
const handleUpdateHostFromTerminal = useCallback((host: TerminalHostUpdate) => {
// Functional update + identity-preserving updateHosts: only the patched
@@ -0,0 +1,206 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import React, { useEffect, useRef } from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { JSDOM } from 'jsdom';
import { installBrowserLockFixture } from '../testSupport/browserLockFixture';
import { useSessionState } from '../state/useSessionState';
import { captureClosingTerminalSessions, useTerminalSessionCloseCapture } from '../state/terminalSessionCloseCapture';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { createConnectionLogBuffer } from '../../components/terminal/connectionLogBuffer';
import { TerminalConnectionProgress } from '../../components/terminal/TerminalConnectionProgress';
import { I18nProvider } from '../i18n/I18nProvider';
import { handleTerminalDataCaptureImpl } from './AppHandlers';
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
test('explicit close persists never-connected logs; runtime remounts leave them open', async (t) => {
const dom = new JSDOM('', { url: 'http://localhost' });
const globals = ['window', 'localStorage', 'CustomEvent', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'];
const previous = new Map(globals.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import('../state/useVaultState');
try {
for (const scenario of [
{ name: 'tab X before connected, empty buffer', status: 'connecting', data: '' },
{ name: 'failed-dialog close, empty buffer', status: 'disconnected', data: '' },
{ name: 'failed-dialog close preserves buffered diagnostics', status: 'disconnected', data: 'SSH diagnostic\r\n' },
{ name: 'batch close before connected', status: 'connecting', data: '', closeMode: 'batch' },
{ name: 'workspace close before connected', status: 'connecting', data: '', closeMode: 'workspace' },
] as const) {
await t.test(scenario.name, async () => {
let vault!: ReturnType<typeof useVaultState>;
let state!: ReturnType<typeof useSessionState>;
let root: ReactTestRenderer | undefined;
let sessionId!: string;
let logId!: string;
const captures: Promise<void>[] = [];
const capturedData: string[] = [];
function TerminalProbe() {
const hasConnectedRef = useRef(false);
const terminalDataCapturedRef = useRef(false);
const buffer = useRef(createConnectionLogBuffer(10000));
useEffect(() => {
buffer.current.append(scenario.data);
return () => { buffer.current.reset(); };
}, []);
useTerminalSessionCloseCapture({
sessionId, enabled: true, hasConnectedRef, terminalDataCapturedRef,
finalizeTerminalLogData: () => buffer.current.toString(),
onTerminalDataCapture: (id, data, options) => {
assert.equal(options.finalized, true);
assert.ok(state.sessions.some((session) => session.id === id), 'capture starts before removal');
terminalDataCapturedRef.current = true;
capturedData.push(data);
const pending = handleTerminalDataCaptureImpl(() => ({
IS_DEV: false, connectionLogs: vault.connectionLogs, sessions: state.sessions,
selectConnectionLogForTerminalDataCapture, updateConnectionLog: vault.updateConnectionLog,
}), id, data);
captures.push(pending);
return pending;
},
});
return scenario.status === 'disconnected' ? React.createElement(
I18nProvider, { locale: 'en' }, React.createElement(TerminalConnectionProgress, {
status: 'disconnected', error: 'Connection refused', timeLeft: 0,
isCancelling: false, showLogs: false, progressLogs: [],
onCancelConnect: () => {}, onCloseSession: () => state.closeSession(sessionId),
}),
) : null;
}
function Probe({ showTerminal = true }) {
vault = useVaultState();
state = useSessionState({ persistSessionRestore: false });
return showTerminal && state.sessions.some((session) => session.id === sessionId)
? React.createElement(TerminalProbe) : null;
}
const mount = async () => {
await act(async () => { root = create(React.createElement(Probe)); });
for (let attempt = 0; !vault.isInitialized && attempt < 50; attempt++) {
await act(async () => { await tick(); });
}
assert.equal(vault.isInitialized, true);
};
try {
await mount();
await act(async () => { sessionId = state.createLocalTerminal(); });
await act(async () => {
state.updateSessionStatus(sessionId, scenario.status);
const session = state.sessions.find((candidate) => candidate.id === sessionId)!;
logId = vault.addConnectionLog({
sessionId, hostId: session.hostId, hostLabel: session.hostLabel,
hostname: session.hostname, username: session.username,
protocol: session.protocol, startTime: Date.now(),
localUsername: 'local-user', localHostname: 'local-host', saved: false,
});
await vault.updateConnectionLog(logId, {});
});
if ('closeMode' in scenario && scenario.closeMode === 'workspace') {
let otherId!: string;
await act(async () => { otherId = state.createLocalTerminal(); });
await act(async () => { state.createWorkspaceFromSessions(sessionId, otherId, 'right'); });
}
// Recycle the renderer while retaining the session, as with a pane
// remount. Neither disposal nor the replacement runtime may end its log.
await act(async () => { root!.update(React.createElement(Probe, { showTerminal: false })); });
await act(async () => { root!.update(React.createElement(Probe)); });
assert.equal(captures.length, 0);
assert.equal(vault.connectionLogs.find((log) => log.id === logId)?.endTime, undefined);
// Both UI actions converge here. This exercises the actual state
// removal, owning-terminal hook, capture handler and vault persistence.
await act(async () => {
if ('closeMode' in scenario && scenario.closeMode === 'workspace') {
state.closeWorkspace(state.workspaces[0].id);
} else if ('closeMode' in scenario && scenario.closeMode === 'batch') {
state.closeSessions([sessionId]);
} else if (scenario.status === 'disconnected') {
// Click the actual Close button rendered inside the failed dialog.
root!.root.findByType('button').props.onClick();
} else {
state.closeSession(sessionId);
}
await Promise.all(captures);
});
assert.equal(state.sessions.some((session) => session.id === sessionId), false);
assert.deepEqual(capturedData, [scenario.data]);
await act(async () => { state.closeSession(sessionId); });
assert.equal(captures.length, 1, 'removed runtimes must unregister their capture');
await act(async () => { root!.unmount(); });
root = undefined;
await mount();
const persisted = vault.connectionLogs.find((log) => log.id === logId)!;
assert.ok(persisted.endTime, 'a durable endTime must exist even without terminal output');
assert.equal(persisted.terminalData || '', scenario.data);
} finally {
await act(async () => { root?.unmount(); });
}
});
}
} finally {
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
test('close capture skips non-owning, already connected and already captured runtimes', async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true });
try {
for (const scenario of [
{ name: 'attached popup / inactive restored placeholder', enabled: false, connected: false, captured: false },
{ name: 'connected terminal uses existing drain path', enabled: true, connected: true, captured: false },
{ name: 'quit already captured the log', enabled: true, connected: false, captured: true },
]) {
await t.test(scenario.name, async () => {
let calls = 0;
let root!: ReactTestRenderer;
function Probe() {
useTerminalSessionCloseCapture({
sessionId: 'guarded', enabled: scenario.enabled,
hasConnectedRef: useRef(scenario.connected),
terminalDataCapturedRef: useRef(scenario.captured),
finalizeTerminalLogData: () => { calls++; return ''; },
onTerminalDataCapture: () => { calls++; },
});
return null;
}
await act(async () => { root = create(React.createElement(Probe)); });
try {
await act(async () => { captureClosingTerminalSessions(['guarded']); });
assert.equal(calls, 0);
} finally {
await act(async () => { root.unmount(); });
}
});
}
} finally {
if (previous) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previous);
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
}
});
test('both UI close paths reach session removal and Terminal registers the owning capture', () => {
const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8');
const app = read('./AppView.tsx');
assert.match(app, /onCloseSession=\{closeSession\}/);
assert.match(read('../../components/top-tabs/TopTabItems.tsx'), /onClick=\{\(e\) => onCloseSession\(session\.id, e\)\}/);
const terminal = read('../../components/Terminal.tsx');
assert.match(terminal, /useTerminalSessionCloseCapture\(\{\s*sessionId,\s*enabled: !attachExistingSession && restoreState !== "restored-disconnected",\s*hasConnectedRef,\s*terminalDataCapturedRef,\s*finalizeTerminalLogData,\s*onTerminalDataCapture: handleTerminalDataCaptureOnce,/);
const handler = terminal.slice(terminal.indexOf('const handleCloseDisconnectedSession ='), terminal.indexOf('const handleHostKeyClose ='));
assert.match(handler, /onCloseSession\?\.\(sessionId\)/);
assert.match(read('../../components/TerminalLayer.tsx'), /onCloseSession\(sessionId\)/);
assert.match(read('../../components/terminal/TerminalView.tsx'), /onCloseSession: handleCloseDisconnectedSession/);
assert.match(read('../../components/terminal/TerminalConnectionProgress.tsx'), /onClick=\{onCloseSession\}/);
});
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { EventEmitter } from 'node:events';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { JSDOM } from 'jsdom';
import { installBrowserLockFixture } from '../testSupport/browserLockFixture';
import { withVaultImportLock } from '../state/vaultManagedImportLock';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import type { TerminalSession } from '../../domain/models';
import { createConnectionLogBuffer } from '../../components/terminal/connectionLogBuffer';
import { handleTerminalDataCaptureImpl, startRestoredSessionConnectionLogImpl } from './AppHandlers';
import { createPreloadApi } from '../../electron/preload/api.cjs';
import { finalizeSessionLogs } from '../../electron/bridges/sessionLogShutdown.cjs';
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
test('quit persists replay before acknowledgment; restored reconnect keeps the old log and starts a new one', async () => {
const dom = new JSDOM('', { url: 'http://localhost' });
const globals = ['window', 'localStorage', 'CustomEvent', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'];
const previous = new Map(globals.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import('../state/useVaultState');
let vault!: ReturnType<typeof useVaultState>;
let root: ReactTestRenderer | undefined;
const Probe = () => { vault = useVaultState(); return null; };
const mount = async () => {
await act(async () => { root = create(React.createElement(Probe)); });
for (let attempt = 0; !vault.isInitialized && attempt < 50; attempt++) {
await act(async () => { await tick(); });
}
assert.equal(vault.isInitialized, true);
};
const session: TerminalSession = {
id: 'restorable-tab', hostId: 'host', hostLabel: 'Host', hostname: 'host.test',
username: 'user', protocol: 'ssh', status: 'connected',
};
const systemInfoRef = { current: { username: 'local-user', hostname: 'local-host' } };
const capture = (data: string) => handleTerminalDataCaptureImpl(() => ({
IS_DEV: false, connectionLogs: vault.connectionLogs, sessions: [session],
selectConnectionLogForTerminalDataCapture, updateConnectionLog: vault.updateConnectionLog,
}), session.id, data);
try {
await mount();
let oldId!: string;
await act(async () => {
oldId = vault.addConnectionLog({
sessionId: session.id, hostId: session.hostId, hostLabel: session.hostLabel,
hostname: session.hostname, username: session.username, protocol: 'ssh', startTime: 1,
localUsername: 'local-user', localHostname: 'local-host', saved: false,
});
// Wait for the initial catalog write before blocking the capture write.
await vault.updateConnectionLog(oldId, {});
});
const buffer = createConnectionLogBuffer(10000);
buffer.append('output before quitting\r\n');
const ipcMain = new EventEmitter();
const ipcRenderer = new EventEmitter() as EventEmitter & { send: (channel: string, payload: unknown) => void };
const webContents = { send: (channel: string, payload: unknown) => ipcRenderer.emit(channel, {}, payload) };
ipcRenderer.send = (channel, payload) => ipcMain.emit(channel, { sender: webContents }, payload);
const api = createPreloadApi({ ipcRenderer });
const off = api.onFinalizeSessionLogs(() => capture(buffer.toString()));
let release!: () => void;
const blockedWrite = withVaultImportLock('connection-logs', () => new Promise<void>((resolve) => { release = resolve; }));
await tick();
let acknowledged = false;
await act(async () => {
const quit = finalizeSessionLogs(webContents, { ipcMain }).then((success: boolean) => {
acknowledged = true;
assert.equal(success, true);
});
await tick();
assert.equal(acknowledged, false, 'main must wait for the persistence queue');
release();
await blockedWrite;
await quit;
});
off();
// Remounting reads durable storage. Node has no IndexedDB, so this also
// exercises the existing localStorage replay fallback.
await act(async () => { root?.unmount(); });
await mount();
const oldLog = vault.connectionLogs.find((log) => log.id === oldId)!;
assert.ok(oldLog.endTime);
assert.equal(oldLog.terminalData, 'output before quitting\r\n');
await act(async () => {
startRestoredSessionConnectionLogImpl(() => ({ addConnectionLog: vault.addConnectionLog, systemInfoRef }), {
...session, status: 'disconnected', restoreState: 'restored-disconnected',
});
});
const newLog = vault.connectionLogs.find((log) => log.id !== oldId)!;
assert.equal(newLog.sessionId, session.id);
assert.equal(newLog.endTime, undefined);
assert.equal(newLog.terminalData, undefined);
await act(async () => { await capture('output after reconnect\r\n'); });
await act(async () => { root?.unmount(); });
await mount();
assert.equal(vault.connectionLogs.find((log) => log.id === oldId)?.terminalData, oldLog.terminalData);
assert.equal(vault.connectionLogs.find((log) => log.id === oldId)?.endTime, oldLog.endTime);
assert.equal(vault.connectionLogs.find((log) => log.id === newLog.id)?.terminalData, 'output after reconnect\r\n');
assert.ok(vault.connectionLogs.find((log) => log.id === newLog.id)?.endTime);
} finally {
await act(async () => { root?.unmount(); });
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
test('terminal quit listener drains output and awaits capture without tearing down tabs', () => {
const source = readFileSync(new URL('../../components/Terminal.tsx', import.meta.url), 'utf8');
const start = source.indexOf('onFinalizeSessionLogs?.(async () => {');
assert.ok(start > 0);
const listener = source.slice(start, source.indexOf('const attachHomeWebContentsIdRef', start));
assert.match(listener, /await flushPendingTerminalWritesBeforeHibernate\(term\)/);
assert.match(listener, /await handleTerminalDataCaptureOnce\(sessionId, finalizeTerminalLogData\(\), \{ finalized: true \}\)/);
assert.doesNotMatch(listener, /closeSession|teardown/);
const effects = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
const handler = effects.slice(effects.indexOf('const handleSessionStatusChange ='), effects.indexOf('const handleUpdateHostFromTerminal ='));
assert.ok(handler.indexOf('startRestoredSessionConnectionLogImpl(') < handler.indexOf('updateSessionStatus(sessionId, status)'));
assert.match(handler, /restoredSessionLogsStartedRef\.current\.has\(sessionId\)/);
});
@@ -6,6 +6,7 @@ import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import type { SftpPane } from "./types";
import { buildCacheKey } from "./sharedRemoteHostCache";
for (const direction of ["download", "upload"] as const) {
test(`failed ${direction} connection replaces Waiting with the actual error (#3698)`, async () => {
@@ -999,3 +1000,86 @@ test("no-conflict local copy awaits discovery so a late stat cannot overwrite th
restore();
}
});
for (const scenario of ["folder-cp", "folder-fallback", "file-cp", "different-key", "missing-key", "non-utf8"] as const) {
test(`fresh remote copy selects a consistent strategy: ${scenario} (#4207)`, async () => {
const isDirectory = scenario.startsWith("folder");
const source = makePane("left");
const target = makePane("left");
target.connection = { ...target.connection!, id: "target-conn", currentPath: "/usr/tmp" };
if (scenario === "non-utf8") target.filenameEncoding = "gb18030";
const key = buildCacheKey("host-1", "debian", 22, "ssh", false, "user", "sftp");
const keys = new Map([["remote-conn", key], ["target-conn", key]]);
if (scenario === "different-key") keys.set("target-conn", buildCacheKey("host-2", "debian", 22, "ssh", false, "user", "sftp"));
if (scenario === "missing-key") keys.delete("target-conn");
const ownerId = `same-host-${scenario}-4207`;
const streams: Array<{ resumable: boolean; sameHost?: boolean; parentTaskId?: string }> = [];
const copies: unknown[][] = [];
let listings = 0;
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
const restore = installGlobals({
statSftp: async (id: string) => id === "sftp-source"
? { type: "file", size: 3626, lastModified: 1758000001000 }
: null,
mkdirSftp: async () => undefined,
sameHostCopyDirectory: async (...args: unknown[]) => {
copies.push(args);
assert.equal(sftpTransferCenterStore.getTask(args[4] as string)?.resumable, false);
return { success: scenario !== "folder-fallback" };
},
startStreamTransfer: async (options: { transferId: string; resumable: boolean; sameHost?: boolean; parentTaskId?: string }) => {
streams.push(options);
assert.equal(sftpTransferCenterStore.getTask(options.transferId)?.resumable, options.resumable);
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: options.transferId,
transferred: 3626, totalBytes: 3626, lifecycleEpoch: 0,
});
return {};
},
});
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: (side) => side === "left" ? source : target,
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-source"], ["target-conn", "sftp-target"]]) },
connectionCacheKeyMapRef: { current: keys },
listLocalFiles: async () => [],
listRemoteFiles: async () => { listings++; return source.files; },
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
const results = await ops!.startTransfer(
[{ name: isDirectory ? "bt_mine" : "aops_dns_view_demo.sh", isDirectory }], "left", "right",
);
assert.equal(results[0]?.status, "completed");
});
const eligible = ["folder-cp", "folder-fallback", "file-cp"].includes(scenario);
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId);
assert.ok(rows.length > 0);
assert.ok(rows.every(row => row.resumable === !eligible));
assert.equal(copies.length, isDirectory ? 1 : 0);
if (isDirectory) assert.deepEqual(copies[0]?.slice(0, 4), ["sftp-source", "/opt/bt_mine", "/usr/tmp/bt_mine", "auto"]);
assert.equal(listings, scenario === "folder-fallback" ? 1 : 0);
assert.equal(streams.length, scenario === "folder-cp" ? 0 : 1);
if (streams.length) {
assert.equal(streams[0].resumable, !eligible);
assert.equal(streams[0].sameHost, !["different-key", "missing-key"].includes(scenario) || undefined);
if (isDirectory) assert.ok(streams[0].parentTaskId);
}
} finally {
await act(async () => { renderer?.unmount(); });
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
restore();
}
});
}
+15 -2
View File
@@ -1348,6 +1348,17 @@ export const useSftpTransfers = ({
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
const batchId = crypto.randomUUID();
const usesLegacyScp = sourcePane.connection.fileProtocol === "scp" || targetPane.connection.fileProtocol === "scp";
const sourceCacheKey = connectionCacheKeyMapRef.current.get(sourcePane.connection.id);
const targetCacheKey = connectionCacheKeyMapRef.current.get(targetPane.connection.id);
const encodingSafeForExec = [sourcePane.filenameEncoding, targetPane.filenameEncoding]
.every((encoding) => !encoding || encoding === "utf-8" || encoding === "auto");
// Select the existing non-resumable cp route only for newly created copies.
// Resume/recovery keeps its saved strategy and checkpoints. If cp fails,
// recursive/download+upload fallback also remains non-resumable.
const useSameHostCopy = !usesLegacyScp
&& !sourcePane.connection.isLocal && !targetPane.connection.isLocal
&& !!sourceCacheKey && sourceCacheKey === targetCacheKey
&& encodingSafeForExec;
const newTasks: TransferTask[] = [];
const skippedResults: TransferResult[] = [];
@@ -1582,8 +1593,10 @@ export const useSftpTransfers = ({
sourceLastModified,
preflightStatSkipped: preflightSkippedNames.has(file.name),
origin: "manual",
resumable: !usesLegacyScp,
pauseUnavailableReason: usesLegacyScp ? "This server uses legacy SCP; cancel and retry from the beginning instead" : undefined,
resumable: !usesLegacyScp && !useSameHostCopy,
pauseUnavailableReason: usesLegacyScp
? "This server uses legacy SCP; cancel and retry from the beginning instead"
: useSameHostCopy ? "Same-host copies cannot be paused; cancel and retry from the beginning instead" : undefined,
phase: file.isDirectory ? "scanning" : "transferring",
});
}
@@ -0,0 +1,57 @@
import { useEffect, type RefObject } from 'react';
import { logger } from '../../lib/logger';
const closeCaptures = new Map<string, Set<() => void>>();
/** Notify owning terminals before explicit session removal, never on runtime cleanup. */
export function captureClosingTerminalSessions(sessionIds: readonly string[]): void {
for (const sessionId of new Set(sessionIds)) {
for (const capture of closeCaptures.get(sessionId) ?? []) capture();
}
}
export function useTerminalSessionCloseCapture({
sessionId,
enabled,
hasConnectedRef,
terminalDataCapturedRef,
finalizeTerminalLogData,
onTerminalDataCapture,
}: {
sessionId: string;
enabled: boolean;
hasConnectedRef: RefObject<boolean>;
terminalDataCapturedRef: RefObject<boolean>;
finalizeTerminalLogData: () => string;
onTerminalDataCapture: (
sessionId: string,
data: string,
options: { finalized: true },
) => void | Promise<void>;
}): void {
useEffect(() => {
if (!enabled) return;
const capture = () => {
// Connected terminals retain their existing drain/serialize close path.
if (hasConnectedRef.current || terminalDataCapturedRef.current) return;
try {
// A never-connected runtime is disposed synchronously on unmount.
// Finish its log now, even if empty, so persistence can set endTime.
void Promise.resolve(onTerminalDataCapture(
sessionId, finalizeTerminalLogData(), { finalized: true },
)).catch((err: unknown) => {
logger.warn('Failed to capture terminal log on session close', err);
});
} catch (err) {
logger.warn('Failed to capture terminal log on session close', err);
}
};
const captures = closeCaptures.get(sessionId) ?? new Set<() => void>();
closeCaptures.set(sessionId, captures);
captures.add(capture);
return () => {
captures.delete(capture);
if (!captures.size) closeCaptures.delete(sessionId);
};
}, [sessionId, enabled, hasConnectedRef, terminalDataCapturedRef, finalizeTerminalLogData, onTerminalDataCapture]);
}
+3
View File
@@ -75,6 +75,7 @@ import {
getSessionConnectionLabel,
} from '../../domain/sessionTabTitle';
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
import { captureClosingTerminalSessions } from './terminalSessionCloseCapture';
import { syncSessionHostLabels } from '../../domain/syncSessionHostLabels';
import { useVaultSnapshotField } from './vaultSnapshotStore';
@@ -603,6 +604,7 @@ export const useSessionState = ({
const closedIds = sessionsRef.current
.filter(session => session.workspaceId === workspaceId)
.map(session => session.id);
captureClosingTerminalSessions(closedIds);
cleanupClosedTerminalSessions(closedIds);
for (const sessionId of closedIds) {
sessionPresentationStore.clearSession(sessionId);
@@ -626,6 +628,7 @@ export const useSessionState = ({
}, [setActiveTabId]);
const closeSessions = useCallback((sessionIds: string[]) => {
captureClosingTerminalSessions(sessionIds);
cleanupClosedTerminalSessions(sessionIds);
for (const sessionId of sessionIds) {
sessionPresentationStore.clearSession(sessionId);
+23
View File
@@ -82,6 +82,7 @@ import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
import { performSessionLogAction, type SessionLogAction } from "../application/state/sessionLogActions";
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
import { useTerminalSessionCloseCapture } from "../application/state/terminalSessionCloseCapture";
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
import {
@@ -1676,6 +1677,28 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return captureHandler(capturedSessionId, capturedData);
}, [finalizeTerminalLogData]);
useTerminalSessionCloseCapture({
sessionId,
enabled: !attachExistingSession && restoreState !== "restored-disconnected",
hasConnectedRef,
terminalDataCapturedRef,
finalizeTerminalLogData,
onTerminalDataCapture: handleTerminalDataCaptureOnce,
});
useEffect(() => {
// Attached popups do not own the ConnectionLog; the home terminal captures it.
if (attachExistingSession) return;
return netcattyBridge.get()?.onFinalizeSessionLogs?.(async () => {
if (terminalDataCapturedRef.current || (restoreState === "restored-disconnected" && statusRef.current === "disconnected")) return;
const term = termRef.current;
if (term && !await flushPendingTerminalWritesBeforeHibernate(term)) {
throw new Error("Terminal output did not drain before quit capture");
}
await handleTerminalDataCaptureOnce(sessionId, finalizeTerminalLogData(), { finalized: true });
});
}, [attachExistingSession, restoreState, sessionId, finalizeTerminalLogData, handleTerminalDataCaptureOnce]);
const attachHomeWebContentsIdRef = useRef<number | null | undefined>(undefined);
useEffect(() => {
@@ -9,6 +9,13 @@ test("SFTP settings imports the shared Toggle used by its option rows", () => {
assert.match(source, /Toggle,\s*\n\s*Select,/);
});
test("folder compression row exposes its settings search anchor", () => {
assert.match(
source,
/<SettingRow\s+anchorId="sftp-compressed-upload"\s+label=\{t\('settings\.sftp\.compressedUpload\.enable'\)\}/,
);
});
function functionBody(functionName: string): string {
const start = source.indexOf(`const ${functionName} = useCallback`);
assert.notEqual(start, -1, `${functionName} should exist`);
@@ -180,6 +180,7 @@ export default function SettingsFileAssociationsTab() {
<SectionHeader title={t('settings.sftp.compressedUpload')} />
<SettingCard>
<SettingRow
anchorId="sftp-compressed-upload"
label={t('settings.sftp.compressedUpload.enable')}
description={t('settings.sftp.compressedUpload.enableDesc')}
>
@@ -231,12 +232,14 @@ export default function SettingsFileAssociationsTab() {
</div>
</SettingRow>
<SettingRow
anchorId="sftp-skip-unchanged"
label={t('settings.sftp.skipUnchanged.enable')}
description={t('settings.sftp.skipUnchanged.enableDesc')}
>
<Toggle checked={sftpSkipUnchanged} onChange={setSftpSkipUnchanged} />
</SettingRow>
<SettingRow
anchorId="sftp-transport-idle-ttl"
label={t('settings.ssh.transportIdleTtl')}
description={t('settings.ssh.transportIdleTtl.desc')}
>
@@ -75,6 +75,9 @@ test('list hook preserves local copy/move routing, remote HTML drag, selection a
await act(async () => root.render(React.createElement(Harness, { local: false })));
await act(async () => right.handlePaneDrop(event()));
assert.equal(actions.at(-1)?.kind, 'copy');
actions.length = 0;
await act(async () => left.handleEntryDrop(entries[2], event()));
assert.deepEqual(actions, [{ kind: 'move', args: [['/files/b.txt'], '/files/folder'] }], 'same-pane remote drag stays a move');
} finally {
await act(async () => root.unmount());
clearLocalFileDrag();
+3 -3
View File
@@ -938,9 +938,9 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
return;
}
// A boot that never reached "connected" has nothing worth capturing, and
// the teardown below awaits terminal drain plus serialization before it
// closes anything. Cancel the pending backend boot after giving an immediate
// Never-connected logs are captured at explicit session close, including
// empty buffers. Runtime cleanup alone must not end an open log.
// Cancel the pending backend boot after giving an immediate
// remount a chance to replace this generation, instead of letting it
// finish against a pane that is already gone. Attached popups never own
// the backend session (skip closeSession) but still sync-dispose xterm.
+54
View File
@@ -1,6 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { describeHostKey } = require("../electron/bridges/hostKeyVerifier.cjs");
const { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip } = require("../electron/bridges/fixtures/hostCertificates.test.fixture.cjs");
import type { KnownHost } from "./models";
import {
@@ -139,6 +144,55 @@ test("fingerprintFromPublicKey returns empty string on missing input", () => {
// --- Migration --------------------------------------------------------------
for (const options of [
{ type: "ed25519" },
{ type: "rsa", bits: 2048 },
...[256, 384, 521].map((bits) => ({ type: "ecdsa", bits })),
{ type: "ed25519", sk: true },
{ type: "ecdsa", bits: 256, sk: true },
]) {
test(`renderer certificate fingerprint and migration agree with verifier: ${JSON.stringify(options)}`, { skip: sshKeygenSkip }, (t) => {
const { first, renewed, different } = mintHostCertificates(t, options);
const expected = describeHostKey(first.blob).fingerprint;
assert.equal(fingerprintFromPublicKey(first.line), expected);
assert.equal(expected, first.publicKeyFingerprint);
assert.equal(fingerprintFromPublicKey(renewed.line), expected);
assert.notEqual(fingerprintFromPublicKey(different.line), expected);
assert.equal(fingerprintFromPublicKey(first.plain.line), expected);
const legacy = knownHost({
keyType: first.keyType,
publicKey: first.line,
fingerprint: crypto.createHash("sha256").update(first.blob).digest("base64").replace(/=+$/g, ""),
});
const migrated = normalizeKnownHost(legacy);
assert.equal(migrated.fingerprint, expected);
assert.equal(migrated.keyType, first.keyType);
assert.equal(migrated.publicKey, first.line);
assert.equal(normalizeKnownHost(migrated), migrated, "migration is idempotent");
assert.equal(normalizeKnownHost({ ...legacy, fingerprint: undefined }).fingerprint, expected);
assert.equal(normalizeKnownHost({ ...legacy, keyType: "" }).keyType, first.keyType);
const unrelated = { ...legacy, fingerprint: "unrelated-explicit-fingerprint" };
assert.equal(normalizeKnownHost(unrelated), unrelated);
const opaque = { ...legacy, publicKey: `SHA256:${legacy.fingerprint}` };
assert.equal(normalizeKnownHost(opaque), opaque);
const fingerprintOnly = { ...legacy, publicKey: undefined };
assert.equal(normalizeKnownHost(fingerprintOnly), fingerprintOnly);
});
}
test("renderer fingerprints v00 certificates and falls back for unparseable certificates", () => {
const cert = legacyCertificate("ssh-rsa", ["exponent", "modulus"]);
assert.equal(fingerprintFromPublicKey(cert.line), describeHostKey(cert.plain).fingerprint);
const malformed = Buffer.concat([
sshString("ssh-ed25519-cert-v01@openssh.com"), sshString("nonce"), sshString(Buffer.alloc(32)),
]);
const line = `ssh-ed25519-cert-v01@openssh.com ${malformed.toString("base64")}`;
const fullHash = crypto.createHash("sha256").update(malformed).digest("base64").replace(/=+$/g, "");
assert.equal(fingerprintFromPublicKey(line), fullHash);
assert.equal(fingerprintFromPublicKey(line), describeHostKey(malformed).fingerprint);
});
test("normalizeKnownHost backfills fingerprint when only publicKey is stored", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const base64Body = rawKey.toString("base64");
+15 -6
View File
@@ -1,4 +1,5 @@
import type { KnownHost } from "./models";
import { extractCertificatePublicKey } from "@/domain/sshPublicKey.shared.cjs";
const normalizeHost = (value: string) => value.trim().toLowerCase();
@@ -132,7 +133,7 @@ export const fingerprintFromPublicKey = (publicKey: string | undefined | null):
const parts = trimmed.split(/\s+/);
if (parts.length >= 2 && SSH_KEY_TYPE_PREFIX.test(parts[0])) {
const bytes = base64Decode(parts[1]);
if (bytes) return stripPadding(base64Encode(sha256Bytes(bytes)));
if (bytes) return stripPadding(base64Encode(sha256Bytes(extractCertificatePublicKey(bytes) || bytes)));
}
return stripPadding(trimmed);
@@ -145,9 +146,9 @@ const extractKeyTypeFromPublicKey = (publicKey: string | undefined | null): stri
};
/**
* Backfill missing `fingerprint` / `keyType` on a stored record so the host
* verifier can match it without falling back to the brittle re-derivation
* path. Returns the same reference when nothing changes so callers can skip
* Backfill missing `fingerprint` / `keyType` and migrate full-certificate
* fingerprints to the underlying key when the certificate body is available.
* Returns the same reference when nothing changes so callers can skip
* persistence writes and React re-renders.
*/
export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
@@ -156,9 +157,17 @@ export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
&& knownHost.keyType.length > 0
&& knownHost.keyType !== "unknown";
if (hasFingerprint && hasKeyType) return knownHost;
const parts = knownHost.publicKey?.trim().split(/\s+/) ?? [];
const certBytes = /-cert-v0[01]@openssh\.com$/.test(parts[0]) && parts[1]
? base64Decode(parts[1]) : null;
const legacyCertFingerprint = certBytes && extractCertificatePublicKey(certBytes)
? stripPadding(base64Encode(sha256Bytes(certBytes))) : "";
const migrateCertificate = legacyCertFingerprint
&& stripPadding((knownHost.fingerprint ?? "").replace(/^SHA256:/i, "")) === legacyCertFingerprint;
const derivedFingerprint = hasFingerprint
if (hasFingerprint && hasKeyType && !migrateCertificate) return knownHost;
const derivedFingerprint = hasFingerprint && !migrateCertificate
? knownHost.fingerprint!
: fingerprintFromPublicKey(knownHost.publicKey);
const derivedKeyType = hasKeyType
+42
View File
@@ -125,6 +125,48 @@ test("settings search finds App Lock from the zh-CN locale", () => {
assert.ok(hits.some((hit) => hit.entry.id === "system-app-lock"));
});
test("settings search finds folder compression from the real locales", () => {
const tZhReal = (key: string) => zhCN[key] ?? key;
const tEnReal = (key: string) => en[key] ?? key;
for (const [t, queries] of [
[tZhReal, ["压缩", "普通传输"]],
[tEnReal, ["compress", "tar"]],
] as const) {
for (const query of queries) {
const hits = filterSettingsSearchCatalog(query, t);
assert.ok(hits.some((hit) => hit.entry.id === "sftp-compressed-upload"), query);
}
}
});
test("settings search finds skip unchanged files from the real locales", () => {
const tZhReal = (key: string) => zhCN[key] ?? key;
const tEnReal = (key: string) => en[key] ?? key;
for (const [t, queries] of [
[tZhReal, ["跳过未改文件", "修改时间"]],
[tEnReal, ["skip unchanged", "rsync"]],
] as const) {
for (const query of queries) {
const hits = filterSettingsSearchCatalog(query, t);
assert.ok(hits.some((hit) => hit.entry.id === "sftp-skip-unchanged"), query);
}
}
});
test("settings search finds SSH connection keep-alive from the real locales", () => {
const tZhReal = (key: string) => zhCN[key] ?? key;
const tEnReal = (key: string) => en[key] ?? key;
for (const [t, queries] of [
[tZhReal, ["保活时间", "重新认证"]],
[tEnReal, ["keep-alive", "re-authentication"]],
] as const) {
for (const query of queries) {
const hits = filterSettingsSearchCatalog(query, t);
assert.ok(hits.some((hit) => hit.entry.id === "sftp-transport-idle-ttl"), query);
}
}
});
test("settings search finds temporary network device mode", () => {
const tEnReal = (key: string) => en[key] ?? key;
const tZhTW = (key: string) => zhTW[key] ?? key;
+21
View File
@@ -671,6 +671,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.sftp.autoSync.enableDesc",
sectionKey: "settings.sftp.autoSync",
},
{
id: "sftp-compressed-upload",
tab: "file-associations",
labelKey: "settings.sftp.compressedUpload.enable",
descriptionKey: "settings.sftp.compressedUpload.enableDesc",
sectionKey: "settings.sftp.compressedUpload",
},
{
id: "sftp-follow-terminal-cwd",
tab: "file-associations",
@@ -691,6 +698,20 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
labelKey: "settings.sftp.transferConcurrency",
descriptionKey: "settings.sftp.transferConcurrency.desc",
},
{
id: "sftp-skip-unchanged",
tab: "file-associations",
labelKey: "settings.sftp.skipUnchanged.enable",
descriptionKey: "settings.sftp.skipUnchanged.enableDesc",
sectionKey: "settings.sftp.transferConcurrency",
},
{
id: "sftp-transport-idle-ttl",
tab: "file-associations",
labelKey: "settings.ssh.transportIdleTtl",
descriptionKey: "settings.ssh.transportIdleTtl.desc",
sectionKey: "settings.sftp.transferConcurrency",
},
{
id: "sftp-default-opener",
tab: "file-associations",
+84
View File
@@ -0,0 +1,84 @@
"use strict";
// Extract only the public-key wire blob for fingerprinting, never CA trust or
// certificate validation. ssh2's getPublicSSH() preserves the full certificate,
// and its internal base-type helper is not exported. Keep this byte-only helper
// shared by Electron and the renderer so migration uses exactly the same bytes.
// Layouts: OpenSSH PROTOCOL.certkeys (v00/v01) and PROTOCOL.u2f (SK).
function extractCertificatePublicKey(blob) {
let offset = 0;
const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength);
const skip = (length) => {
if (offset + length > blob.length) throw new Error("Truncated SSH key");
offset += length;
};
const readString = () => {
skip(4);
const length = view.getUint32(offset - 4, false);
const start = offset;
skip(length);
return blob.subarray(start, offset);
};
const ascii = (bytes) => String.fromCharCode(...bytes);
try {
const typeBytes = readString();
if (typeBytes.length > 128) return null;
const match = ascii(typeBytes).match(/^(.*)-cert-v0([01])@openssh\.com$/);
if (!match) return null;
let baseType = match[1];
const v01 = match[2] === "1";
if (/^rsa-sha2-(256|512)$/.test(baseType)) baseType = "ssh-rsa";
if (baseType === "sk-ssh-ed25519" || baseType === "sk-ecdsa-sha2-nistp256") {
baseType += "@openssh.com";
}
let fields;
switch (baseType) {
case "ssh-rsa": fields = 2; break;
case "ssh-dss": fields = 4; break;
case "ssh-ed25519": fields = 1; break;
case "sk-ssh-ed25519@openssh.com": fields = 2; break;
case "sk-ecdsa-sha2-nistp256@openssh.com": fields = 3; break;
case "ecdsa-sha2-nistp256":
case "ecdsa-sha2-nistp384":
case "ecdsa-sha2-nistp521": fields = 2; break;
default: return null;
}
// v00 put its nonce after the constraints, not before the key material.
if (v01) readString();
const keyStart = offset;
for (let i = 0; i < fields; i += 1) {
const field = readString();
const isApplication = baseType.startsWith("sk-") && i === fields - 1;
if (!field.length && !isApplication) return null;
if (i === 0 && baseType.includes("ed25519") && field.length !== 32) return null;
if (i === 0 && baseType.includes("ecdsa")) {
const curve = baseType.replace(/^sk-/, "").replace(/@openssh\.com$/, "").replace(/^ecdsa-sha2-/, "");
if (field.length > 8 || ascii(field) !== curve) return null;
}
}
const keyEnd = offset;
// Check the outer envelope too; truncated/unsupported certificates retain
// their full-blob fingerprint rather than gaining an inner-key match.
if (v01) skip(8); // serial
skip(4); // host/user type
readString(); // key ID
readString(); // principals
skip(16); // validity
readString(); // critical options (v00 constraints)
readString(); // extensions (v00 nonce)
readString(); // reserved
if (!readString().length || !readString().length || offset !== blob.length) return null;
const result = new Uint8Array(4 + baseType.length + keyEnd - keyStart);
new DataView(result.buffer).setUint32(0, baseType.length, false);
for (let i = 0; i < baseType.length; i += 1) result[4 + i] = baseType.charCodeAt(i);
result.set(blob.subarray(keyStart, keyEnd), 4 + baseType.length);
return result;
} catch {
return null;
}
}
exports.extractCertificatePublicKey = extractCertificatePublicKey;
+1
View File
@@ -84,6 +84,7 @@ module.exports = {
// is excluded below, so the shared TOTP runtime must be listed.
'domain/totp.shared.cjs',
'domain/totpPrompt.shared.cjs',
'domain/sshPublicKey.shared.cjs',
'lib/**/*.cjs',
'lib/**/*.json',
'skills/**/*',
@@ -281,6 +281,26 @@ test('wall deadline during authorization reports authorization failure rather th
assert.deepEqual(pty.writes, []);
});
test('idle Enter-PSSession remoting prompt allows the command to be sent', async () => {
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
const prompt = '[192.168.1.10]: PS C:\\Users\\Administrator\\Documents> ';
trackSessionIdlePrompt(session, `PowerShell 7\r\n${prompt}`);
assert.equal(readyPrompt(session), prompt);
const result = await startPtyJob(pty, 'Write-Output ok', {
shellKind: 'powershell',
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 200,
timeoutMs: 1000,
typedInput: true,
}).resultPromise;
assert.equal(result.ok, true);
assert.equal(result.stdout, 'OK');
assert.equal(pty.writes.length, 1);
assert.match(pty.writes[0], /\$__NCMCP_/);
});
test('idle PowerShell whose raw tail was CSI-erased still receives the command', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
+4 -3
View File
@@ -50,11 +50,12 @@ function formatSyntheticEcho(command) {
}
// Default PowerShell prompt (e.g. `PS C:\Users\alice>`, `PS>`,
// `PS /home/alice>`). Anchored so command output that merely starts with
// `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
// `PS /home/alice>`), with optional Enter-PSSession / remoting `[host]: `
// and debug `[DBG]: ` prefixes. Anchored so command output that merely
// starts with `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
// `"PS >"` (which the default prompt never emits) so a script that prints
// such a line can't trick prompt-driven shell-kind selection.
const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
const POWERSHELL_PROMPT_PATTERN = /^(?:\[[^\]\r\n]+\]: )*PS(?:\s+\S.*)?>$/;
// Default cmd.exe prompt (e.g. `C:\>`, `C:\Users\alice>`, `D:\data>`).
// Drive letter + optional path + `>`. Rejects `C: >` (space before `>`) and
+40
View File
@@ -112,6 +112,46 @@ test("isDefaultPowerShellPromptLine matches default shapes and rejects look-alik
assert.equal(isDefaultPowerShellPromptLine(null), false);
});
test("PowerShell prompt helpers recognize remoting and debug prefixes", () => {
for (const prompt of [
"[192.168.1.10]: PS C:\\Users\\Administrator\\Documents>",
"[SERVER01]: PS C:\\>",
"[host.example.com]: PS C:\\Windows\\System32>",
"[fe80::1]: PS C:\\>",
"[DBG]: PS C:\\Users\\alice>",
"[SERVER01]: [DBG]: PS C:\\>",
]) {
assert.equal(isDefaultPowerShellPromptLine(prompt), true, prompt);
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${prompt}`), prompt, prompt);
}
});
test("PowerShell prompt helpers reject malformed remoting prefixes and look-alikes", () => {
for (const line of [
"[host] PS C:\\>",
"prefix [host]: PS C:\\>",
"[]: PS C:\\>",
"[ho]st]: PS C:\\>",
"[ho\rst]: PS C:\\>",
"[ho\nst]: PS C:\\>",
"[host]:PS C:\\>",
"[host]: PS >",
"PS >",
"PSO>",
"ZIPS>",
"",
null,
]) {
assert.equal(isDefaultPowerShellPromptLine(line), false, String(line));
assert.equal(extractTrailingIdlePrompt(line), "", String(line));
}
});
test("extractTrailingIdlePrompt preserves trailing space on a remoting prompt", () => {
const prompt = "[192.168.1.10]: PS C:\\Users\\Administrator\\Documents> ";
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${prompt}`), prompt);
});
test("extracts a trailing cmd.exe idle prompt", () => {
// Windows OpenSSH default shell is cmd.exe; without capturing `C:\...>`
// AI exec cannot select the cmd wrapper when shellKind is still unset.
@@ -24,21 +24,6 @@ const UI_DISALLOWED_TOOLS = ["AskUserQuestion"];
const MCP_MODE_TOOLS = [];
const SKILLS_MODE_TOOLS = ["Bash"];
// Deduped built-in catalog from @tencent-ai/agent-sdk 0.3.258 cli/product.json.
const CODEBUDDY_BUILTIN_TOOLS = [
"Agent", "Bash", "PowerShell", "Glob", "Grep", "Read", "Edit", "Write",
"NotebookEdit", "WebFetch", "ListMcpResources", "ReadMcpResource", "WaitForMcpServers",
"TaskCreate", "TaskGet", "TaskUpdate", "TaskList", "WebSearch", "EnterPlanMode",
"ExitPlanMode", "KillShell", "TaskStop", "TaskOutput", "SlashCommand", "Skill",
"SkillManage", "AskUserQuestion", "AskUserForStructuredInput", "LSP", "StructuredOutput",
"ToolSearch", "DeferExecuteTool", "ImageGen", "ImageEdit", "VideoGen", "Artifact",
"ArtifactControl", "ComputerUse", "TeamCreate", "TeamDelete", "SendMessage",
"SendUserMessage", "EnterWorktree", "LeaveWorktree", "CronCreate", "CronDelete",
"CronList", "DelegateTool", "WeChatReply", "WeComReply", "PushNotification",
"ReportFindings", "Workflow", "Monitor", "REPL", "A2AGetAgentCard", "A2ASendMessage",
"A2AGetTask", "A2ACancelTask",
];
const CODEBUDDY_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
function isCodebuddyImageAttachment(attachment) {
@@ -276,19 +261,18 @@ function buildCodebuddyQueryOptions({
allowDangerouslySkipPermissions: true,
extraArgs: { "dangerously-skip-permissions": null },
mcpServers: toSdkMcpServers(injectedMcpServers),
// Block built-ins explicitly in mcp mode: tools: [] becomes --tools "",
// which also hides injected MCP tools in CodeBuddy CLI v2.183.0.
// Leave allowedTools unset: an empty auto-approval list prevents MCP tool
// calls from running under bypassPermissions.
disallowedTools: builtinTools.length > 0
? [...UI_DISALLOWED_TOOLS]
: [...new Set([...UI_DISALLOWED_TOOLS, ...CODEBUDDY_BUILTIN_TOOLS])],
tools: builtinTools,
// `tools` is the built-in tool whitelist. In mcp mode it is [], so CodeBuddy
// built-ins stay disabled while injected Netcatty MCP tools remain visible.
// Do not mirror that empty list into allowedTools: the SDK treats
// allowedTools as an auto-approval list, and allowedTools: [] prevents MCP
// tool calls from running under bypassPermissions.
disallowedTools: [...UI_DISALLOWED_TOOLS],
// Keep the SDK isolated from user/project settings so local hooks, plugins,
// or extra MCP servers cannot expand Netcatty's controlled tool boundary.
settingSources: [],
env,
};
if (builtinTools.length > 0) options.tools = builtinTools;
const selection = splitCodebuddyModelSelection(model);
if (selection.model) options.model = selection.model;
if (abortController) options.abortController = abortController;
@@ -59,51 +59,15 @@ test("buildCodebuddyQueryOptions wires SDK options in isolated mode", () => {
assert.equal(opts.pathToCodebuddyCode, "/opt/codebuddy/bin/codebuddy");
assert.equal(opts.abortController, ac);
assert.equal(opts.resume, "sess-1");
assert.ok(!("tools" in opts));
assert.deepEqual(opts.tools, []);
// allowedTools must stay unset in mcp mode: tools:[] disables built-ins, while
// allowedTools:[] would prevent injected Netcatty MCP tools from running.
assert.ok(!("allowedTools" in opts));
assert.ok(opts.disallowedTools.includes("Bash"));
assert.ok(opts.disallowedTools.includes("AskUserQuestion"));
assert.equal(opts.mcpServers["netcatty-remote-hosts"].type, "stdio");
assert.deepEqual(opts.mcpServers["netcatty-remote-hosts"].env, { NETCATTY_MCP_PORT: "1" });
});
test("mcp and default mode block built-ins while preserving injected MCP servers", () => {
for (const toolIntegrationMode of ["mcp", undefined]) {
const opts = buildCodebuddyQueryOptions({
toolIntegrationMode,
injectedMcpServers: [{ name: "netcatty", command: "/abs/electron" }],
});
assert.ok(!("tools" in opts));
assert.ok(!("allowedTools" in opts));
assert.equal(opts.permissionMode, "bypassPermissions");
for (const name of ["Bash", "PowerShell", "Read", "Edit", "Write", "Agent", "Skill", "AskUserQuestion"]) {
assert.ok(opts.disallowedTools.includes(name), `${name} must stay disabled`);
}
assert.equal(opts.mcpServers.netcatty.command, "/abs/electron");
}
});
test("mcp mode omits --tools under the SDK serialization rule", () => {
for (const toolIntegrationMode of ["mcp", undefined]) {
const opts = buildCodebuddyQueryOptions({ toolIntegrationMode });
const args = [];
// @tencent-ai/agent-sdk lib/transport/process-transport.js
if (opts.tools !== undefined) {
args.push("--tools", opts.tools.join(","));
}
assert.deepEqual(args, []);
}
});
test("skills mode keeps the Bash whitelist and UI exclusions", () => {
const opts = buildCodebuddyQueryOptions({ toolIntegrationMode: "skills" });
assert.deepEqual(opts.tools, ["Bash"]);
assert.deepEqual(opts.disallowedTools, ["AskUserQuestion"]);
assert.ok(!opts.disallowedTools.includes("Bash"));
assert.ok(!("allowedTools" in opts));
});
test("built-in tools are mode-aware", () => {
assert.deepEqual(codebuddyBuiltinTools("mcp"), []);
assert.deepEqual(codebuddyBuiltinTools(undefined), []);
@@ -0,0 +1,76 @@
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
let sshKeygenSkip = false;
try {
execFileSync("ssh-keygen", ["-h"], { stdio: "ignore" });
} catch (error) {
if (error.code === "ENOENT") sshKeygenSkip = "ssh-keygen is not installed";
}
const sshString = (value) => {
const body = Buffer.from(value);
const length = Buffer.alloc(4);
length.writeUInt32BE(body.length);
return Buffer.concat([length, body]);
};
// Real local certificates, with no server/network or security-key device.
function mintHostCertificates(t, { type = "ed25519", bits, sk = false, user = false } = {}) {
const dir = fs.mkdtempSync(path.join(__dirname, ".hostcert-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const keygen = (...args) => execFileSync("ssh-keygen", ["-q", ...args], { stdio: "pipe" });
const ca = path.join(dir, "ca");
keygen("-t", "ed25519", "-N", "", "-f", ca);
const readKey = (file) => {
const line = fs.readFileSync(file, "utf8").trim();
const [keyType, body] = line.split(/\s+/);
return { line, keyType, blob: Buffer.from(body, "base64") };
};
const mint = (name, validity, serial) => {
const host = path.join(dir, name);
if (!fs.existsSync(host)) {
keygen("-t", type, ...(bits ? ["-b", String(bits)] : []), "-N", "", "-f", host);
if (sk) {
const plain = readKey(`${host}.pub`);
const keyMaterial = plain.blob.subarray(4 + plain.keyType.length);
const skType = `sk-${plain.keyType}@openssh.com`;
const blob = Buffer.concat([sshString(skType), keyMaterial, sshString("ssh:netcatty-test")]);
fs.writeFileSync(`${host}.pub`, `${skType} ${blob.toString("base64")}\n`);
}
}
keygen("-s", ca, "-I", "netcatty-hostcert", ...(user ? [] : ["-h"]),
"-n", "127.0.0.1", "-V", validity, "-z", String(serial), `${host}.pub`);
const cert = readKey(`${host}-cert.pub`);
const listing = execFileSync("ssh-keygen", ["-L", "-f", `${host}-cert.pub`], { encoding: "utf8" });
const publicKeyFingerprint = listing.match(/Public key: .* SHA256:(\S+)/)?.[1];
if (!publicKeyFingerprint) throw new Error("Missing ssh-keygen certificate fingerprint");
return { ...cert, publicKeyFingerprint, plain: readKey(`${host}.pub`) };
};
return {
first: mint("host", "20250101:20260101", 1),
renewed: mint("host", "20260101:20270101", 2),
different: mint("other", "20260101:20270101", 3),
};
}
// Legacy v00 is no longer emitted by ssh-keygen. This follows the original
// PROTOCOL.certkeys layout: key material first, nonce after constraints.
function legacyCertificate(keyType, keyFields) {
const blob = Buffer.concat([
sshString(`${keyType}-cert-v00@openssh.com`),
...keyFields.map(sshString),
Buffer.from([0, 0, 0, 2]),
sshString("legacy"), sshString(""), Buffer.alloc(16),
sshString(""), sshString("nonce"), sshString(""),
sshString("signing key"), sshString("signature"),
]);
return {
blob,
plain: Buffer.concat([sshString(keyType), ...keyFields.map(sshString)]),
line: `${keyType}-cert-v00@openssh.com ${blob.toString("base64")}`,
};
}
module.exports = { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip };
+17 -4
View File
@@ -1,6 +1,7 @@
const crypto = require("node:crypto");
const { randomUUID } = require("node:crypto");
const { utils: sshUtils } = require("ssh2");
const { extractCertificatePublicKey } = require("../../domain/sshPublicKey.shared.cjs");
const REQUEST_TTL_MS = 2 * 60 * 1000;
const hostKeyRequests = new Map();
@@ -65,8 +66,9 @@ const fingerprintFromPublicKey = (publicKey) => {
const parts = trimmed.split(/\s+/);
if (parts.length >= 2 && /^ssh-|^ecdsa-|^sk-/.test(parts[0])) {
try {
const blob = Buffer.from(parts[1], "base64");
return crypto.createHash("sha256")
.update(Buffer.from(parts[1], "base64"))
.update(extractCertificatePublicKey(blob) || blob)
.digest("base64")
.replace(/=+$/g, "");
} catch {
@@ -77,7 +79,16 @@ const fingerprintFromPublicKey = (publicKey) => {
};
const getKnownHostFingerprint = (knownHost) => {
return normalizeFingerprint(knownHost?.fingerprint)
const stored = normalizeFingerprint(knownHost?.fingerprint);
const parts = knownHost?.publicKey?.trim().split(/\s+/) || [];
if (parts.length >= 2 && /-cert-v0[01]@openssh\.com$/.test(parts[0])) {
const blob = Buffer.from(parts[1], "base64");
// Only replace an explicit fingerprint if it is the legacy full-cert hash.
// Fingerprint-only records cannot be migrated without the certificate body.
const legacy = crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/g, "");
if (!stored || stored === legacy) return fingerprintFromPublicKey(knownHost.publicKey);
}
return stored
|| fingerprintFromPublicKey(knownHost?.publicKey);
};
@@ -134,7 +145,7 @@ const classifyHostKey = ({ knownHosts = [], hostname, port = 22, keyType, finger
const describeHostKey = (rawKey) => {
const key = Buffer.isBuffer(rawKey) ? rawKey : Buffer.from(rawKey || "");
const fingerprint = crypto.createHash("sha256")
.update(key)
.update(extractCertificatePublicKey(key) || key)
.digest("base64")
.replace(/=+$/g, "");
let keyType = "unknown";
@@ -149,7 +160,9 @@ const describeHostKey = (rawKey) => {
try {
const parsed = sshUtils.parseKey(key);
const parsedKey = Array.isArray(parsed) ? parsed[0] : parsed;
if (parsedKey && !(parsedKey instanceof Error)) {
// Keep the presented certificate identity, even if a parser returns its
// base algorithm or underlying public key as metadata.
if (parsedKey && !(parsedKey instanceof Error) && !/-cert-v0[01]@openssh\.com$/.test(keyType)) {
keyType = parsedKey.type || keyType;
const publicSsh = parsedKey.getPublicSSH?.();
if (publicSsh) publicKey = publicSsh.toString("utf8");
+80
View File
@@ -1,11 +1,13 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip } = require("./fixtures/hostCertificates.test.fixture.cjs");
const {
classifyHostKey,
createHostVerifier,
describeHostKey,
getKnownHostFingerprint,
handleResponse,
normalizeFingerprint,
} = require("./hostKeyVerifier.cjs");
@@ -17,6 +19,84 @@ const makeRawPublicKey = (keyType, body = "trusted imported host key") => {
return Buffer.concat([length, type, Buffer.from(body)]);
};
const hashKey = (blob) => crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/g, "");
test("host certificate renewals trust the inner key, including legacy saved fingerprints", { skip: sshKeygenSkip }, (t) => {
const { first, renewed, different } = mintHostCertificates(t);
const original = describeHostKey(first.blob);
const live = describeHostKey(renewed.blob);
const changed = describeHostKey(different.blob);
assert.notDeepEqual(first.blob, renewed.blob);
assert.equal(original.fingerprint, first.publicKeyFingerprint);
assert.equal(live.fingerprint, original.fingerprint);
assert.notEqual(changed.fingerprint, live.fingerprint);
assert.equal(original.keyType, "ssh-ed25519-cert-v01@openssh.com");
assert.equal(original.publicKey, `${first.keyType} ${first.blob.toString("base64")}`);
assert.equal(describeHostKey(first.plain.blob).fingerprint, hashKey(first.plain.blob));
const stored = { id: "kh-cert", hostname: "cert.local", port: 2222, ...original };
const classify = (key, record = stored) => classifyHostKey({
knownHosts: [record], hostname: "cert.local", port: 2222, ...key,
});
assert.equal(classify(live).status, "trusted");
assert.equal(classify(changed).status, "changed");
assert.equal(classify(changed, { ...stored, keyType: "ssh-rsa" }).status, "unknown");
const legacy = { ...stored, fingerprint: hashKey(first.blob) };
assert.notEqual(legacy.fingerprint, live.fingerprint);
assert.equal(getKnownHostFingerprint(legacy), live.fingerprint);
assert.equal(classify(live, legacy).status, "trusted");
assert.equal(classify(changed, legacy).status, "changed");
assert.equal(classify(live, { ...legacy, publicKey: undefined }).status, "changed");
assert.equal(classify(live, { ...legacy, publicKey: `SHA256:${legacy.fingerprint}` }).status, "changed");
assert.equal(classify(live, { ...legacy, fingerprint: "unrelated-explicit-fingerprint" }).status, "changed");
assert.equal(classify(live, { ...legacy, fingerprint: undefined }).status, "trusted");
// Renewal is trusted on reconnect, but changing the raw certificate during
// an established connection must still be rejected synchronously on rekey.
const verifier = createHostVerifier({ hostname: stored.hostname, port: stored.port, knownHosts: [legacy] });
const results = [];
verifier(first.blob, (accepted) => results.push(accepted));
verifier(Buffer.from(first.blob), (accepted) => results.push(accepted));
verifier(renewed.blob, (accepted) => results.push(accepted));
assert.deepEqual(results, [true, true, false]);
});
for (const options of [
{ type: "rsa", bits: 2048 },
...[256, 384, 521].map((bits) => ({ type: "ecdsa", bits })),
{ type: "ed25519", sk: true },
{ type: "ecdsa", bits: 256, sk: true },
{ type: "ed25519", user: true },
]) {
test(`certificate fingerprint matches ssh-keygen for ${JSON.stringify(options)}`, { skip: sshKeygenSkip }, (t) => {
const { first, renewed, different } = mintHostCertificates(t, options);
assert.equal(describeHostKey(first.blob).fingerprint, first.publicKeyFingerprint);
assert.equal(describeHostKey(first.blob).fingerprint, hashKey(first.plain.blob));
assert.equal(describeHostKey(renewed.blob).fingerprint, first.publicKeyFingerprint);
assert.notEqual(describeHostKey(different.blob).fingerprint, first.publicKeyFingerprint);
});
}
test("v00 certificates extract the key before the nonce", () => {
for (const [type, fields] of [["ssh-rsa", ["exponent", "modulus"]], ["ssh-dss", ["p", "q", "g", "y"]]]) {
const cert = legacyCertificate(type, fields);
assert.equal(describeHostKey(cert.blob).fingerprint, hashKey(cert.plain));
assert.equal(describeHostKey(cert.blob).keyType, `${type}-cert-v00@openssh.com`);
}
});
test("malformed and unsupported certificates keep their full-blob fingerprint", () => {
const truncated = Buffer.concat([
sshString("ssh-ed25519-cert-v01@openssh.com"), sshString("nonce"), sshString(Buffer.alloc(32)),
]);
const badLength = Buffer.concat([sshString("ssh-ed25519-cert-v01@openssh.com"), Buffer.from([255, 255, 255, 255])]);
const unsupported = Buffer.concat([sshString("unknown-cert-v01@openssh.com"), sshString("nonce")]);
for (const blob of [truncated, badLength, unsupported]) {
assert.equal(describeHostKey(blob).fingerprint, hashKey(blob));
}
});
test("classifyHostKey prompts for unknown hosts", () => {
const result = classifyHostKey({
knownHosts: [],
+38
View File
@@ -0,0 +1,38 @@
const { randomUUID } = require("node:crypto");
// Keep the renderer alive until its ConnectionLog replay writes have committed.
function finalizeSessionLogs(webContents, { ipcMain, timeoutMs = 5000 } = {}) {
if (!webContents || webContents.isDestroyed?.() || webContents.isCrashed?.()
|| typeof webContents.send !== "function"
|| typeof ipcMain?.on !== "function"
|| typeof ipcMain?.removeListener !== "function") {
return Promise.resolve(false);
}
return new Promise((resolve) => {
const requestId = randomUUID();
const finish = (success) => {
clearTimeout(timer);
try {
ipcMain.removeListener("app:session-logs-finalized", onResult);
} catch {
// IPC teardown must not prevent the window from closing.
}
if (!success) console.warn("[SessionLogs] Renderer did not finalize logs before close");
resolve(success);
};
const onResult = (event, payload) => {
if (event.sender === webContents && payload?.requestId === requestId) {
finish(payload.success === true);
}
};
const timer = setTimeout(() => finish(false), timeoutMs);
try {
ipcMain.on("app:session-logs-finalized", onResult);
webContents.send("app:finalize-session-logs", { requestId });
} catch {
finish(false);
}
});
}
module.exports = { finalizeSessionLogs };
@@ -0,0 +1,193 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const { EventEmitter } = require("node:events");
const { finalizeSessionLogs } = require("./sessionLogShutdown.cjs");
const { createPreloadApi } = require("../preload/api.cjs");
const { handleBeforeQuit } = require("../main/appLockLifecycle.cjs");
const { readFileSync } = require("node:fs");
const vm = require("node:vm");
const tick = () => new Promise((resolve) => setImmediate(resolve));
function renderer(ipcMain = new EventEmitter()) {
const ipcRenderer = new EventEmitter();
const webContents = { send: (channel, payload) => ipcRenderer.emit(channel, {}, payload) };
ipcRenderer.send = (channel, payload) => ipcMain.emit(channel, { sender: webContents }, payload);
const api = createPreloadApi({ ipcRenderer });
return { api, ipcMain, ipcRenderer, webContents };
}
test("finalization waits for every terminal replay commit, including a hidden renderer", async () => {
const h = renderer();
const commits = [];
const off = h.api.onFinalizeSessionLogs(() => { throw new Error("unmounted terminal"); });
off();
for (let i = 0; i < 2; i++) {
h.api.onFinalizeSessionLogs(() => new Promise((resolve) => commits.push(resolve)));
}
let done = false;
const pending = finalizeSessionLogs(h.webContents, h).then((result) => { done = true; return result; });
await tick();
assert.equal(done, false);
commits[0](); await tick();
assert.equal(done, false);
commits[1]();
assert.equal(await pending, true);
assert.equal(h.ipcMain.listenerCount("app:session-logs-finalized"), 0);
});
test("empty renderers acknowledge immediately; failed captures acknowledge failure", async () => {
const h = renderer();
assert.equal(await finalizeSessionLogs(h.webContents, h), true);
h.api.onFinalizeSessionLogs(() => { throw new Error("capture failed"); });
assert.equal(await finalizeSessionLogs(h.webContents, h), false);
});
test("overlapping window close and app quit share the pending replay commit", async () => {
const h = renderer();
let finish;
let captures = 0;
h.api.onFinalizeSessionLogs(() => {
captures++;
return new Promise((resolve) => { finish = resolve; });
});
const first = finalizeSessionLogs(h.webContents, h);
let secondDone = false;
const second = finalizeSessionLogs(h.webContents, h).then((result) => { secondDone = true; return result; });
await tick();
assert.equal(captures, 1);
assert.equal(secondDone, false);
finish();
assert.equal(await first, true);
assert.equal(await second, true);
});
test("only the queried renderer and current request can acknowledge finalization", async () => {
const ipcMain = new EventEmitter();
let request;
const webContents = { send: (_channel, payload) => { request = payload; } };
let done = false;
const pending = finalizeSessionLogs(webContents, { ipcMain }).then((result) => { done = true; return result; });
ipcMain.emit("app:session-logs-finalized", { sender: {} }, { ...request, success: true });
ipcMain.emit("app:session-logs-finalized", { sender: webContents }, { requestId: "old", success: true });
await tick();
assert.equal(done, false);
ipcMain.emit("app:session-logs-finalized", { sender: webContents }, { ...request, success: true });
assert.equal(await pending, true);
});
test("unresponsive and destroyed renderers do not strand quit or leak listeners", async () => {
const ipcMain = new EventEmitter();
assert.equal(await finalizeSessionLogs({ send() {} }, { ipcMain, timeoutMs: 1 }), false);
assert.equal(await finalizeSessionLogs({ send() { throw new Error("destroyed"); } }, { ipcMain }), false);
assert.equal(await finalizeSessionLogs({ isDestroyed: () => true }, { ipcMain }), false);
assert.equal(ipcMain.listenerCount("app:session-logs-finalized"), 0);
});
test("unavailable finalize IPC returns immediately without scheduling timers or sending", async (t) => {
const timer = t.mock.method(globalThis, "setTimeout");
const ipcMain = new EventEmitter();
const send = t.mock.fn();
const on = t.mock.method(ipcMain, "on");
for (const unavailableIpc of [undefined, null, {}, { on() {} }, { removeListener() {} }, { on: true, removeListener() {} }, { on() {}, removeListener: true }]) {
assert.equal(await finalizeSessionLogs({ send }, { ipcMain: unavailableIpc }), false);
}
for (const webContents of [undefined, null, {}, { send: true }]) {
assert.equal(await finalizeSessionLogs(webContents, { ipcMain }), false);
}
assert.equal(timer.mock.callCount(), 0);
assert.equal(on.mock.callCount(), 0);
assert.equal(send.mock.callCount(), 0);
});
for (const failure of ["registration", "send", "cleanup"]) {
test(`finalize IPC ${failure} errors do not reject or leave a pending timeout`, async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const warn = t.mock.method(console, "warn", () => {});
const ipcMain = new EventEmitter();
const webContents = { send() { throw new Error("send failed"); } };
if (failure === "registration") {
ipcMain.on = (channel, listener) => {
EventEmitter.prototype.on.call(ipcMain, channel, listener);
throw new Error("registration failed");
};
} else if (failure === "cleanup") {
ipcMain.removeListener = (channel, listener) => {
EventEmitter.prototype.removeListener.call(ipcMain, channel, listener);
throw new Error("cleanup failed");
};
}
let result;
const pending = finalizeSessionLogs(webContents, { ipcMain }).then((success) => { result = success; });
await tick();
assert.equal(result, false, "failed setup must finish before the timeout");
await pending;
assert.equal(ipcMain.listenerCount("app:session-logs-finalized"), 0);
assert.equal(warn.mock.callCount(), 1);
t.mock.timers.tick(5000);
assert.equal(warn.mock.callCount(), 1, "the cleared timeout must not run after finalization");
});
}
test("tray quit finalizes only after the dirty guard accepts and waits before quitting", async () => {
const h = renderer();
let finishCapture;
let captures = 0;
let quits = 0;
h.api.onFinalizeSessionLogs(() => {
captures++;
return new Promise((resolve) => { finishCapture = resolve; });
});
let shutdown;
const options = {
event: { preventDefault() {} }, mainWindows: [{ webContents: h.webContents }],
queryDirtyEditors: async () => true,
commitQuit: () => { shutdown = finalizeSessionLogs(h.webContents, h).then(() => { quits++; }); },
dialog: { showMessageBoxSync() {} },
};
await handleBeforeQuit(options);
assert.equal(captures, 0);
await handleBeforeQuit({ ...options, queryDirtyEditors: async () => false });
await tick();
assert.equal(captures, 1);
assert.equal(quits, 0);
finishCapture(); await shutdown;
assert.equal(quits, 1);
});
test("production quit commit awaits renderer logs and file streams before plugins and process exit", async () => {
const source = readFileSync(require.resolve("../main.cjs"), "utf8");
const start = source.indexOf(" const commitQuit = () => {");
const end = source.indexOf('\n app.on("before-quit"', start);
assert.ok(start >= 0 && end > start);
const calls = [];
let finishCapture;
let finishStreams;
const hiddenWindow = { webContents: {} };
const context = {
console,
getWindowManager: () => ({ setIsQuitting: () => calls.push("quitting"), getAppContentWindows: () => [hiddenWindow] }),
electronModule: { ipcMain: {} },
finalizeSessionLogs: (wc) => {
assert.equal(wc, hiddenWindow.webContents);
calls.push("capture");
return new Promise((resolve) => { finishCapture = resolve; });
},
sessionLogStreamManager: { cleanupAll: () => {
calls.push("streams");
return new Promise((resolve) => { finishStreams = resolve; });
} },
appLockController: { setLocked: () => calls.push("lock") },
runPluginShutdown: async () => { calls.push("plugins"); return { timedOut: false }; },
app: { quit: () => calls.push("quit") },
quitGuardChannelBusy: false, quitConfirmed: false,
};
vm.runInNewContext(`${source.slice(start, end)}; commitQuit();`, context);
assert.deepEqual(calls, ["lock", "quitting", "capture"]);
assert.equal(context.quitGuardChannelBusy, true);
finishCapture(); await tick();
assert.deepEqual(calls, ["lock", "quitting", "capture", "streams"]);
assert.equal(context.quitConfirmed, false);
finishStreams(); await tick();
assert.deepEqual(calls, ["lock", "quitting", "capture", "streams", "plugins", "quit"]);
assert.equal(context.quitConfirmed, true);
});
@@ -16,10 +16,29 @@ const {
startStream,
startStreamToFile,
stopStream,
cleanupAll,
} = require("./sessionLogStreamManager.cjs");
const TEMP_ROOT = path.join(__dirname, ".tmp-session-log-stream-tests");
test("quit cleanup finishes all pending file captures before resolving", async () => {
const directory = path.join(TEMP_ROOT, `quit-${Date.now()}`);
try {
for (const sessionId of ["quit-one", "quit-two"]) {
startStreamToFile(sessionId, { filePath: path.join(directory, `${sessionId}.log`), format: "raw" });
appendData(sessionId, `output before quit: ${sessionId}\r\n`);
}
await cleanupAll();
for (const sessionId of ["quit-one", "quit-two"]) {
assert.equal(hasStream(sessionId), false);
assert.equal(fs.readFileSync(path.join(directory, `${sessionId}.log`), "utf8"), `output before quit: ${sessionId}\r\n`);
}
} finally {
await cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("txt stream live snapshots include pending ED2 cleared screens", async () => {
const directory = path.join(TEMP_ROOT, `stream-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+10
View File
@@ -1913,6 +1913,16 @@ function createSessionBackedSftpClient(sessionId, sshClient, options = {}) {
const attrs = await statAsync(sftp, remotePath);
return statResultFromAttrs(attrs);
},
async lstat(remotePath) {
const sftp = await requireSftpChannel(client);
// Copy preflights must inspect link nodes, never their followed targets.
// lstatAsync falls back to STAT when LSTAT is absent, so reject first.
if (typeof sftp.lstat !== "function") {
throw Object.assign(new Error("Remote server does not support LSTAT"), { code: "ENOTSUP" });
}
const attrs = await lstatAsync(sftp, remotePath);
return statResultFromAttrs(attrs);
},
async realPath(remotePath) {
const sftp = await requireSftpChannel(client);
return await realpathAsync(sftp, remotePath);
@@ -0,0 +1,94 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { Readable, PassThrough } = require("node:stream");
const sftpBridge = require("./sftpBridge.cjs");
const transferBridge = require("./transferBridge.cjs");
const missing = () => Object.assign(new Error("ENOENT"), { code: 2 });
async function openSessionClient(t, { sourceIsLink = false, targetExists = false } = {}) {
const commands = [];
const channel = new EventEmitter();
channel.readdir = (_path, callback) => callback(null, []);
channel.mkdir = (_path, callback) => callback(null);
channel.unlink = (_path, callback) => callback(null);
channel.stat = (remotePath, callback) => {
if (String(remotePath) === "/target/file" && !targetExists) return callback(missing());
callback(null, { size: 66, mode: 0o100640, mtime: 1 });
};
channel.lstat = (remotePath, callback) => {
if (String(remotePath) === "/target" && !targetExists) return callback(missing());
callback(null, { size: 66, mode: sourceIsLink ? 0o120777 : 0o100640, mtime: 1 });
};
channel.createReadStream = () => Readable.from([Buffer.alloc(66)]);
const connection = {
sftp(callback) { callback(null, channel); },
exec(command, callback) {
commands.push(command);
const stream = new PassThrough();
stream.stderr = new PassThrough();
callback(null, stream);
setImmediate(() => stream.emit("close", 0));
},
};
const clients = new Map();
sftpBridge.init({
electronModule: { webContents: { fromId: () => null } },
sessions: new Map([["copy-session", { conn: connection }]]),
sftpClients: clients,
});
transferBridge.init({ sftpClients: clients });
const { sftpId } = await sftpBridge.openSftpForSession(null, {
sessionId: "copy-session", fileProtocol: "sftp",
});
t.after(() => sftpBridge.closeSftp(null, { sftpId }));
return { client: clients.get(sftpId), channel, commands, sftpId };
}
test("same-host file copy executes cp using the actual session-backed client (#4207)", async (t) => {
const { commands, sftpId } = await openSessionClient(t);
const result = await transferBridge.startTransfer({ sender: { send() {} } }, {
transferId: "session-backed-file-copy",
sourcePath: "/source/file", targetPath: "/target/file",
sourceType: "sftp", targetType: "sftp",
sourceSftpId: sftpId, targetSftpId: sftpId,
sourceEncoding: "utf-8", targetEncoding: "utf-8",
totalBytes: 66, sameHost: true, resumable: false,
});
assert.deepEqual(commands, ["cp -a '/source/file' '/target/file'"]);
assert.equal(result.error, undefined);
});
for (const sourceIsLink of [false, true]) {
test(`session-backed LSTAT preserves link classification (${sourceIsLink})`, async (t) => {
const { client } = await openSessionClient(t, { sourceIsLink });
assert.equal((await client.lstat("/source/file")).isSymbolicLink, sourceIsLink);
assert.equal((await client.stat("/source/file")).isSymbolicLink, false);
});
}
for (const unsupported of ["missing-method", "server-error"]) {
test(`session-backed LSTAT never follows STAT on ${unsupported}`, async (t) => {
const { client, channel } = await openSessionClient(t);
let statCalls = 0;
channel.stat = () => { statCalls++; throw new Error("must not follow symlinks"); };
if (unsupported === "missing-method") delete channel.lstat;
else channel.lstat = (_path, callback) => callback(Object.assign(new Error("unsupported"), { code: 8 }));
await assert.rejects(() => client.lstat("/source/file"), error =>
error.code === (unsupported === "missing-method" ? "ENOTSUP" : 8));
assert.equal(statCalls, 0);
});
}
for (const targetExists of [false, true]) {
test(`session-backed directory cp preserves the destination merge guard (${targetExists})`, async (t) => {
const { commands, sftpId } = await openSessionClient(t, { targetExists });
const result = await transferBridge.sameHostCopyDirectory({ sender: { send() {} } }, {
transferId: `session-backed-directory-copy-${targetExists}`,
sftpId, sourcePath: "/source", targetPath: "/target", encoding: "utf-8",
});
assert.equal(result.success, !targetExists);
assert.deepEqual(commands, targetExists ? [] : ["cp -ra '/source/.' '/target/'"]);
});
}
@@ -41,6 +41,8 @@
* leaves the Mosh stats bar empty (graceful degradation), never weakens
* security.
*/
const { extractCertificatePublicKey } = require("../../../domain/sshPublicKey.shared.cjs");
function createSystemKnownHostsApi(ctx) {
const { fs, path, os, crypto, log } = ctx;
@@ -64,7 +66,7 @@ function createSystemKnownHostsApi(ctx) {
}
if (blob.length === 0) return "";
return stripFingerprintPadding(
crypto.createHash("sha256").update(blob).digest("base64"),
crypto.createHash("sha256").update(extractCertificatePublicKey(blob) || blob).digest("base64"),
);
};
@@ -176,7 +178,7 @@ function createSystemKnownHostsApi(ctx) {
// Parse one known_hosts line into { revoked, certAuthority, hostField,
// keyType, fingerprint } or null when it is a comment / blank / malformed.
// The fingerprint is the SHA-256 of the line's key blob.
// Certificates pin their underlying key, just like the live verifier.
const parseKnownHostsLine = (rawLine) => {
const line = String(rawLine || "").trim();
if (!line || line.startsWith("#")) return null;
@@ -1,6 +1,8 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { mintHostCertificates, sshKeygenSkip } = require("../fixtures/hostCertificates.test.fixture.cjs");
const { describeHostKey } = require("../hostKeyVerifier.cjs");
const path = require("node:path");
const os = require("node:os");
@@ -59,6 +61,22 @@ function hashedHostField(token, salt = crypto.randomBytes(20)) {
const HOME_KH = path.join(os.homedir(), ".ssh", "known_hosts");
test("system certificate entries pin the inner key across renewals without CA trust", { skip: sshKeygenSkip }, (t) => {
const { first, renewed, different } = mintHostCertificates(t);
const { api } = makeApi({ [HOME_KH]: `example.com ${first.line}\n` });
const liveFingerprint = describeHostKey(renewed.blob).fingerprint;
assert.equal(api.parseKnownHostsLine(`example.com ${first.line}`).fingerprint, liveFingerprint);
assert.equal(api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), true);
assert.equal(api.isHostKeyTrustedBySystem({
hostname: "example.com", fingerprint: describeHostKey(different.blob).fingerprint,
}), false);
const revoked = makeApi({ [HOME_KH]: `example.com ${first.line}\n@revoked example.com ${renewed.line}\n` });
assert.equal(revoked.api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), false);
const ca = makeApi({ [HOME_KH]: `@cert-authority example.com ${first.line}\n` });
assert.equal(ca.api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), false);
});
test("system known_hosts paths include the OpenSSH defaults for the platform", () => {
const { api } = makeApi();
const paths = api.getSystemKnownHostsPaths();
+44 -2
View File
@@ -7174,6 +7174,13 @@ async function startTransferNow(event, payload, onProgress) {
const singleChannelCopy = srcClient.__netcattySingleChannelSsh || sshClient?.__netcattySingleChannelSsh;
if (sshClient && typeof sshClient.exec === 'function' && !singleChannelCopy) {
try {
// cp -a preserves link nodes; the SFTP fallback follows target bytes.
// A failed lstat also falls through to that existing route.
const sourceAttrs = await runCancelablePreflight(() => srcClient.lstat(
encodePathForSession(sourceSftpId, sourcePath, resolvedSourceEnc),
));
if (sourceAttrs.isSymbolicLink) throw new Error("Source is a symbolic link");
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try {
await runTransferAbortableOperation(transfer, (signal) => ensureRemoteDirForSession(
@@ -7186,6 +7193,18 @@ async function startTransferNow(event, payload, onProgress) {
if (transfer.cancelled || transfer.signal?.aborted) throw new Error("Transfer cancelled");
}
const targetClient = sftpClients.get(targetSftpId);
let existingRemoteMode = null;
try {
const attrs = await runCancelablePreflight(() => targetClient.stat(
encodePathForSession(targetSftpId, targetPath, resolvedTargetEnc),
));
if (Number.isFinite(attrs?.mode)) existingRemoteMode = attrs.mode & 0o7777;
} catch {
// Missing destinations or failed stats need no permission restore.
if (transfer.cancelled || transfer.signal?.aborted) throw new Error("Transfer cancelled");
}
const escapedSource = sourcePath.replace(/'/g, "'\\''");
const escapedTarget = targetPath.replace(/'/g, "'\\''");
const command = `cp -a '${escapedSource}' '${escapedTarget}'`;
@@ -7194,6 +7213,13 @@ async function startTransferNow(event, payload, onProgress) {
if (result.code === 0) {
sendProgress(fileSize, fileSize);
sameHostDone = true;
await restoreRemoteUploadModeBestEffort(
targetClient,
targetSftpId,
targetPath,
resolvedTargetEnc,
existingRemoteMode,
);
} else if (result.code === 127) {
// Exit 127 = command not found — cache to skip future attempts
cpUnavailableSet.add(srcClient);
@@ -8204,11 +8230,28 @@ async function sameHostCopyDirectory(event, payload) {
}
if (cpUnavailableSet.has(client)) return { success: false };
const resolvedEncoding = resolveEncodingForRequest(sftpId, encoding);
if (resolvedEncoding && resolvedEncoding !== 'utf-8') {
return { success: false };
}
const sshClient = client.client;
if (!sshClient || typeof sshClient.exec !== 'function') {
return { success: false };
}
// Merges must use recursive SFTP transfers to preserve destination child modes.
// Only a clearly missing target is safe for the remote cp optimization.
try {
await runTransferCancelablePreflight(transfer, () => client.lstat(
encodePathForSession(sftpId, targetPath, resolvedEncoding),
));
return { success: false };
} catch (error) {
if (transfer.cancelled) throw new Error("Transfer cancelled");
if (!isMissingRemoteStageError(error)) return { success: false };
}
if (transfer.cancelled) throw new Error("Transfer cancelled");
const lifecyclePayload = {
...payload,
@@ -8233,8 +8276,7 @@ async function sameHostCopyDirectory(event, payload) {
if (transfer.cancelled) throw new Error("Transfer cancelled");
}
// Use "source/." to copy directory *contents* into target, preserving merge
// semantics consistent with the recursive per-file transfer path.
// Use "source/." to copy directory *contents* into the newly created target.
// Without "/.", `cp -ra source target` would create target/source/ when target exists.
const escapedSource = sourcePath.replace(/'/g, "'\\''");
const escapedTarget = targetPath.replace(/'/g, "'\\''");
@@ -106,6 +106,7 @@ test("same-host cp capability cache follows the live SFTP client, not a recycled
bridge.init({ sftpClients });
const makeClient = (exitCode, calls) => ({
async lstat() { throw Object.assign(new Error("ENOENT"), { code: 2 }); },
client: {
exec(_command, callback) {
calls.count += 1;
+325 -1
View File
@@ -1294,7 +1294,9 @@ test("server-to-server resume cancellation settles while source identity verific
t.after(async () => fs.promises.rm(tempPath, { force: true }));
let markPrefixStarted;
const prefixStarted = new Promise((resolve) => { markPrefixStarted = resolve; });
let execCalls = 0;
const sourceClient = {
client: { exec() { execCalls += 1; throw new Error("checkpointed copies must not exec cp"); } },
sftp: createFastSftp({
createReadStream() {
markPrefixStarted();
@@ -1317,7 +1319,7 @@ test("server-to-server resume cancellation settles while source identity verific
targetSftpId: "target",
totalBytes: 8,
resumable: true,
sameHost: false,
sameHost: true,
resumeStage: "download",
checkpointBytes: 4,
downloadCheckpointBytes: 4,
@@ -1332,6 +1334,7 @@ test("server-to-server resume cancellation settles while source identity verific
]);
assert.match(result.error || "", /cancel/i);
assert.equal(execCalls, 0, "same-host resume must verify its saved prefix instead of running cp");
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true);
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
});
@@ -13243,6 +13246,119 @@ test("same-host directory copy rejected by a committed close leaves no lease or
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
});
test("same-host directory copy skips cp when auto encoding resolves to non-UTF-8", async (t) => {
const {
sftpTransferSessionLeaseStore,
} = require("./sftpTransferSessionLease.cjs");
sftpTransferSessionLeaseStore.resetForTests();
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
const sftpId = "same-host-directory-auto-gb18030";
// Seed the session's resolved encoding before the copy requests auto.
sftpBridge.resolveEncodingForRequest(sftpId, "gb18030");
let execCalls = 0;
transferBridge.init({
sftpClients: new Map([[sftpId, {
client: {
exec(_command, callback) {
execCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
callback(null, stream);
setImmediate(() => stream.emit("close", 0));
},
},
}]]),
});
const sender = createSender();
const result = await transferBridge.sameHostCopyDirectory(
{ sender },
{
transferId: "same-host-directory-auto-gb18030-copy",
sftpId,
sourcePath: "/source",
targetPath: "/\u76ee\u6807",
encoding: "auto",
},
);
assert.equal(execCalls, 0);
assert.equal(result.success, false);
assert.deepEqual(sender.sent, []);
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
});
for (const targetState of ["existing", "missing", "stat-error"]) {
test(`same-host directory copy ${targetState} target uses cp only for a fresh destination`, async (t) => {
const { sftpTransferSessionLeaseStore } = require("./sftpTransferSessionLease.cjs");
sftpTransferSessionLeaseStore.resetForTests();
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
const sftpId = `same-host-directory-${targetState}`;
const commands = [];
const mkdirs = [];
let targetExists = targetState === "existing";
const client = {
async lstat(remotePath) {
assert.equal(String(remotePath), "/target");
assert.deepEqual(mkdirs, [], "check the destination before ensuring it");
if (targetState === "stat-error") {
throw Object.assign(new Error("Permission denied"), { code: 3 });
}
if (!targetExists) throw Object.assign(new Error("ENOENT"), { code: 2 });
return { isDirectory: true };
},
sftp: createFastSftp({
stat(_remotePath, callback) {
if (!targetExists) {
callback(Object.assign(new Error("ENOENT"), { code: 2 }));
return;
}
callback(null, { isDirectory: () => true });
},
mkdir(remotePath, callback) {
mkdirs.push(String(remotePath));
targetExists = true;
callback(null);
},
}),
client: {
exec(command, callback) {
commands.push(command);
assert.equal(targetExists, true, "create the fresh destination before cp");
const stream = new PassThrough();
stream.stderr = new PassThrough();
callback(null, stream);
setImmediate(() => stream.emit("close", 0));
},
},
};
const sftpClients = new Map([[sftpId, client]]);
transferBridge.init({ sftpClients });
sftpBridge.init({ sftpClients });
const sender = createSender();
const result = await transferBridge.sameHostCopyDirectory({ sender }, {
transferId: `${sftpId}-copy`, sftpId,
sourcePath: "/source", targetPath: "/target", encoding: "utf-8",
});
if (targetState === "missing") {
assert.deepEqual(result, { success: true });
assert.deepEqual(commands, ["cp -ra '/source/.' '/target/'"]);
assert.deepEqual(mkdirs, ["/target"]);
assert.equal(sender.sent.some(entry => entry.channel === "netcatty:transfer:complete"), true);
} else {
assert.deepEqual(result, { success: false });
assert.deepEqual(commands, []);
assert.deepEqual(mkdirs, []);
assert.deepEqual(sender.sent, []);
}
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
});
}
test("same-host directory copy cancellation aborts cp and releases its lease", async (t) => {
const {
sftpTransferSessionLeaseStore,
@@ -13259,6 +13375,7 @@ test("same-host directory copy cancellation aborts cp and releases its lease", a
stream.end = () => {};
stream.destroy = () => {};
const client = {
async lstat() { throw Object.assign(new Error("ENOENT"), { code: 2 }); },
client: {
exec(_command, callback) {
callback(null, stream);
@@ -13342,6 +13459,7 @@ test("same-host file cp cancellation releases the shared session lease", async (
stream.end = () => {};
stream.destroy = () => {};
const client = {
lstat: async () => ({ isSymbolicLink: false }),
client: {
exec(_command, callback) {
callback(null, stream);
@@ -15139,3 +15257,209 @@ test("pending channel-open skips the fastPut reopen and uses the shared channel"
assert.ok(sharedWrites >= 1);
assert.deepEqual(remoteFiles.get(targetPath), payload);
});
test("same-host file cp restores existing destination mode", async () => {
const commands = [];
const chmods = [];
let targetMode = 0o100600;
let downloads = 0;
let uploads = 0;
const sourceSftp = createFastSftp({
open() { downloads++; throw new Error("unexpected download"); },
});
const sourceClient = {
sftp: sourceSftp,
lstat: async () => ({ isSymbolicLink: false }),
stat: async () => ({ size: 64, mode: 0o100644 }),
client: {
sftp: (callback) => callback(null, sourceSftp),
exec(command, callback) {
commands.push(command);
targetMode = 0o100644;
const stream = new PassThrough();
stream.stderr = new PassThrough();
callback(null, stream);
setImmediate(() => stream.emit("close", 0));
},
},
};
const targetClient = {
sftp: createFastSftp({
open() { uploads++; throw new Error("unexpected upload"); },
}),
stat: async () => ({ size: 64, mode: targetMode }),
chmod: async (remotePath, mode) => {
chmods.push([String(remotePath), mode]);
targetMode = mode;
},
};
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
const result = await transferBridge.startTransfer({ sender: createSender() }, {
transferId: `same-host-cp-mode-${crypto.randomUUID()}`,
sourcePath: "/source/payload.bin", targetPath: "/target/payload.bin",
sourceType: "sftp", targetType: "sftp",
sourceSftpId: "source", targetSftpId: "target",
sourceEncoding: "utf-8", targetEncoding: "utf-8",
totalBytes: 64,
sameHost: true, resumable: false,
});
assert.equal(result.error, undefined, result.error);
assert.deepEqual(commands, ["cp -a '/source/payload.bin' '/target/payload.bin'"]);
assert.deepEqual(chmods, [["/target/payload.bin", 0o600]]);
assert.equal(targetMode, 0o600);
assert.equal(downloads, 0);
assert.equal(uploads, 0);
});
for (const sourceState of ["symlink", "lstat-error"]) {
test(`same-host file cp skips a source ${sourceState} and downloads/uploads target bytes`, async (t) => {
const transferId = `same-host-cp-fallback-${crypto.randomUUID()}`;
const sourcePath = "/opt/app/config";
const targetPath = "/tmp/app/config";
const payload = Buffer.from("contents of ../shared/config");
const commands = [];
let reads = 0;
let remote = Buffer.alloc(0);
let promoted = false;
const { sftp: sourceSftp } = createPipelinedDownloadSftp(payload);
const originalRead = sourceSftp.read;
sourceSftp.read = (...args) => { reads++; originalRead(...args); };
const sourceClient = {
sftp: sourceSftp,
async lstat(remotePath) {
assert.equal(String(remotePath), sourcePath);
if (sourceState === "lstat-error") throw new Error("lstat unsupported");
return { isSymbolicLink: true, size: "../shared/config".length };
},
stat: async () => ({ size: payload.length }),
client: {
sftp: (callback) => callback(null, sourceSftp),
exec(command, callback) {
commands.push(command);
callback(new Error("unexpected exec"));
},
},
};
const targetSftp = createFastSftp({
open(_path, _flags, callback) { callback(null, Buffer.from("target-handle")); },
write(_handle, buffer, offset, length, position, callback) {
if (remote.length < position + length) {
remote = Buffer.concat([remote, Buffer.alloc(position + length - remote.length)]);
}
buffer.copy(remote, position, offset, offset + length);
callback(null);
},
close(_handle, callback) { callback(null); },
});
const targetClient = {
sftp: targetSftp,
client: { sftp: (callback) => callback(null, targetSftp) },
async stat(remotePath) {
if (String(remotePath) === targetPath && remote.length === 0) {
throw Object.assign(new Error("ENOENT"), { code: 2 });
}
return { size: remote.length };
},
rename: async () => { promoted = true; },
delete: async () => {},
};
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "config");
t.after(async () => fs.promises.rm(localStage, { force: true }));
const result = await transferBridge.startTransfer({ sender: createSender() }, {
transferId, sourcePath, targetPath,
sourceType: "sftp", targetType: "sftp",
sourceSftpId: "source", targetSftpId: "target",
sourceEncoding: "utf-8", targetEncoding: "utf-8",
totalBytes: payload.length, sameHost: true, resumable: false,
});
assert.equal(result.error, undefined, result.error);
assert.deepEqual(commands, [], "source symlinks and lstat failures must bypass cp -a");
assert.ok(reads > 0, "fallback must download the followed source contents");
assert.deepEqual(remote, payload, "fallback must upload target bytes rather than the link node");
assert.equal(promoted, true);
});
}
for (const cpResult of [0, 127, 1, "exec-error"]) {
test(`fresh same-host file cp result ${cpResult} preserves copy/fallback behavior (#4207)`, async (t) => {
const transferId = `same-host-cp-${crypto.randomUUID()}`;
const payload = Buffer.from("same-host ordinary SFTP copy");
const commands = [];
const chmods = [];
let reads = 0;
let remote = Buffer.alloc(0);
let promoted = false;
const { sftp: sourceSftp } = createPipelinedDownloadSftp(payload);
const originalRead = sourceSftp.read;
sourceSftp.read = (...args) => { reads++; originalRead(...args); };
const sourceClient = {
sftp: sourceSftp,
lstat: async () => ({ isSymbolicLink: false }),
stat: async () => ({ size: payload.length }),
client: {
sftp: (callback) => callback(null, sourceSftp),
exec(command, callback) {
commands.push(command);
if (cpResult === "exec-error") { callback(new Error("exec denied")); return; }
const stream = new PassThrough();
stream.stderr = new PassThrough();
callback(null, stream);
setImmediate(() => stream.emit("close", cpResult));
},
},
};
const targetSftp = createFastSftp({
open(_path, _flags, callback) { callback(null, Buffer.from("target-handle")); },
write(_handle, buffer, offset, length, position, callback) {
if (remote.length < position + length) {
remote = Buffer.concat([remote, Buffer.alloc(position + length - remote.length)]);
}
buffer.copy(remote, position, offset, offset + length);
callback(null);
},
close(_handle, callback) { callback(null); },
});
const targetClient = {
sftp: targetSftp,
client: { sftp: (callback) => callback(null, targetSftp) },
stat: async (remotePath) => {
if (String(remotePath) === "/target/payload.bin" && remote.length === 0) {
throw Object.assign(new Error("ENOENT"), { code: 2 });
}
return { size: remote.length };
},
chmod: async (...args) => { chmods.push(args); },
rename: async () => { promoted = true; },
delete: async () => {},
};
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "payload.bin");
t.after(async () => fs.promises.rm(localStage, { force: true }));
const sender = createSender();
const result = await transferBridge.startTransfer({ sender }, {
transferId,
sourcePath: "/source/payload.bin",
targetPath: "/target/payload.bin",
sourceType: "sftp", targetType: "sftp",
sourceSftpId: "source", targetSftpId: "target",
sourceEncoding: "utf-8", targetEncoding: "utf-8",
totalBytes: payload.length,
sameHost: true, resumable: false,
});
assert.equal(result.error, undefined, result.error);
assert.deepEqual(commands, ["cp -a '/source/payload.bin' '/target/payload.bin'"]);
assert.equal(sender.sent.some(entry => entry.channel === "netcatty:transfer:complete"), true);
if (cpResult === 0) {
assert.deepEqual(chmods, [], "fresh cp must not restore destination permissions");
assert.equal(reads, 0, "successful cp must not download the source");
assert.equal(remote.length, 0, "successful cp must not upload through SFTP");
assert.equal(fs.existsSync(localStage), false);
} else {
assert.ok(reads > 0, "cp failure must fall back to SFTP download");
assert.deepEqual(remote, payload, "fallback must upload the downloaded contents");
assert.equal(promoted, true);
}
});
}
@@ -4,6 +4,7 @@ const {
} = require("./windowsWindowChrome.cjs");
const { attachDisplayRecovery } = require("./displayRecovery.cjs");
const perfTrace = require("../../perfTrace.cjs");
const { finalizeSessionLogs } = require("../sessionLogShutdown.cjs");
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
@@ -534,11 +535,12 @@ function createMainWindowApi(ctx) {
: false;
})();
Promise.resolve(dirtyEditorQuery)
.then((hasDirty) => {
.then(async (hasDirty) => {
if (hasDirty) {
thisWindowCloseRequested = false;
return;
}
await finalizeSessionLogs(win.webContents, { ipcMain: electronModule.ipcMain });
dirtyEditorCloseConfirmed = true;
try {
win.close();
@@ -10,10 +10,18 @@ for (const peer of [true, false]) {
let ownsTabs = true;
let ownerChecks = 0;
let dirtyChecks = 0;
const ipcMain = new EventEmitter();
let finishLogCapture;
let logCaptures = 0;
let closeToTray = false;
class Window extends EventEmitter {
constructor() {
super(); this.destroyed = false;
this.webContents = Object.assign(new EventEmitter(), { id: 71, isDestroyed: () => this.destroyed, setWindowOpenHandler() {}, openDevTools() {}, send() {} });
this.webContents = Object.assign(new EventEmitter(), { id: 71, isDestroyed: () => this.destroyed, setWindowOpenHandler() {}, openDevTools() {}, send: (channel, payload) => {
if (channel !== "app:finalize-session-logs") return;
logCaptures++;
finishLogCapture = () => ipcMain.emit("app:session-logs-finalized", { sender: this.webContents }, { requestId: payload.requestId, success: true });
} });
}
close() { const event = { prevented: false, preventDefault() { this.prevented = true; } }; this.emit("close", event); if (!event.prevented) { this.destroyed = true; this.emit("closed"); } }
isDestroyed() { return this.destroyed; }
@@ -30,7 +38,11 @@ for (const peer of [true, false]) {
V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} },
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
__dirname, URL, require, console, setTimeout, clearTimeout,
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }),
getGlobalShortcutBridge: () => ({ handleWindowClose: (event) => {
if (!closeToTray) return false;
event.preventDefault();
return true;
} }),
getMainWindowCount: () => 1,
debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return null; },
getDevRendererBaseUrl: (url) => url, getWindowBoundsState() { return null; },
@@ -43,10 +55,18 @@ for (const peer of [true, false]) {
closeEditorTabsForSource: () => { ownerChecks++; return new Promise((resolve) => { finish = resolve; }); },
queryDirtyEditors: async () => { dirtyChecks++; return false; },
});
const win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain: {} }, {
const win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain }, {
preload: "/tmp/preload.cjs", devServerUrl: "http://localhost:5173", isDev: true, isMac: true,
electronDir: __dirname, route: peer ? "session-window" : undefined, registerAsMainWindow: !peer,
});
if (!peer) {
closeToTray = true;
win.close(); await tick();
assert.equal(win.destroyed, false);
assert.equal(logCaptures, 0, "hiding to tray must keep the session log live");
assert.equal(ownerChecks, 0);
closeToTray = false;
}
win.close(); win.close();
assert.equal(ownerChecks, 1, "repeat close must share the ownership check");
assert.equal(dirtyChecks, 0, "the short dirty query must not race a detached Save/Cancel prompt");
@@ -54,10 +74,14 @@ for (const peer of [true, false]) {
finish({ success: true, cancelled: true }); await tick();
assert.equal(win.destroyed, false, "Cancel keeps the source save/dock renderer alive");
assert.equal(dirtyChecks, 0);
assert.equal(logCaptures, 0, "Cancel must leave the live log ongoing");
win.close();
ownsTabs = false;
finish({ success: true, cancelled: false }); await tick();
assert.equal(dirtyChecks, 1);
assert.equal(logCaptures, 1);
assert.equal(win.destroyed, false, "renderer must survive until replay persistence acknowledges completion");
finishLogCapture(); await tick();
assert.equal(win.destroyed, true, "close proceeds only after all owned editors settled");
});
}
+12 -6
View File
@@ -237,6 +237,7 @@ const {
} = require("./main/appLockLifecycle.cjs");
const ptyProcessTree = require("./bridges/ptyProcessTree.cjs");
const { queryDirtyEditors } = require("./bridges/dirtyEditorGuard.cjs");
const { finalizeSessionLogs } = require("./bridges/sessionLogShutdown.cjs");
// GPU settings
// NOTE: Do not disable Chromium sandbox by default.
@@ -1665,7 +1666,17 @@ if (!gotLock) {
}
getWindowManager().setIsQuitting(true);
quitGuardChannelBusy = true;
void runPluginShutdown()
void (async () => {
try {
await Promise.all(getWindowManager().getAppContentWindows().map((win) => (
finalizeSessionLogs(win.webContents, { ipcMain: electronModule.ipcMain })
)));
await sessionLogStreamManager.cleanupAll();
} catch (error) {
console.warn("[SessionLogs] Shutdown failed; continuing app quit:", error);
}
return runPluginShutdown();
})()
.then(({ timedOut }) => {
if (timedOut) console.warn("[Plugins] Shutdown deadline elapsed; continuing app quit");
})
@@ -1727,11 +1738,6 @@ if (!gotLock) {
// Cleanup all PTY sessions and port forwarding tunnels before quitting
app.on("will-quit", () => {
try {
sessionLogStreamManager.cleanupAll();
} catch (err) {
console.warn("Error during session log stream cleanup:", err);
}
try {
terminalBridge.cleanupAllSessions();
} catch (err) {
+17
View File
@@ -7,6 +7,19 @@ const fs = require("node:fs");
const { stageRendererFileToTemp } = require("./stageUploadFile.cjs");
function createPreloadApi(ctx) {
const sessionLogFinalizers = new Set();
let pendingSessionLogFinalization = null;
ctx.ipcRenderer?.on?.("app:finalize-session-logs", async (_event, { requestId }) => {
const pending = pendingSessionLogFinalization ??= Promise.allSettled(
[...sessionLogFinalizers].map((finalize) => Promise.resolve().then(finalize)),
);
const results = await pending;
if (pendingSessionLogFinalization === pending) pendingSessionLogFinalization = null;
ctx.ipcRenderer.send("app:session-logs-finalized", {
requestId,
success: results.every((result) => result.status === "fulfilled"),
});
});
const terminalDataBacklog = ctx.terminalDataBacklog || null;
const displayDataListeners = ctx.displayDataListeners || new Map();
const closedTerminalDataSessions = ctx.closedTerminalDataSessions || null;
@@ -1544,6 +1557,10 @@ function createPreloadApi(ctx) {
reportDirtyEditorsResult: (hasDirty, reason, options) => ipcRenderer.send("app:dirty-editors-result", {
hasDirty, reason, requestId: options?.requestId, pending: options?.pending === true,
}),
onFinalizeSessionLogs: (finalize) => {
sessionLogFinalizers.add(finalize);
return () => sessionLogFinalizers.delete(finalize);
},
// Port Forwarding API
startPortForward: async (options) => {
+1
View File
@@ -299,6 +299,7 @@ declare global {
// Quit guard: subscribe to main-process quit requests that query for dirty editors.
// Each response must echo the request ID, including pending-transfer acknowledgements.
onCheckDirtyEditors?(listener: (request: { requestId: string; deadline: number }) => void): () => void;
onFinalizeSessionLogs?(listener: () => Promise<void>): () => void;
// Report the dirty-check result back to the main process.
reportDirtyEditorsResult?(hasDirty: boolean, reason?: "editors" | "transfers", options?: { requestId?: string; pending?: boolean }): void;
+2 -1
View File
@@ -111,7 +111,7 @@ export default defineConfig(() => {
build: {
// The shared runtime is also required by Electron; convert it for browser builds.
commonjsOptions: {
include: [/node_modules/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack|RemoteTreePolicy|ViewStylePolicy|GitHubRepository)\.cjs$/],
include: [/node_modules/, /domain\/sshPublicKey\.shared\.cjs$/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack|RemoteTreePolicy|ViewStylePolicy|GitHubRepository)\.cjs$/],
},
chunkSizeWarningLimit: 3000,
target: 'esnext', // Required for top-level await in WASM modules
@@ -134,6 +134,7 @@ export default defineConfig(() => {
include: [
// Keep the renderer alias pre-bundled so dev serves ESM instead of raw CommonJS.
'@/domain/systemManager/containerRuntime.shared.cjs',
'@/domain/sshPublicKey.shared.cjs',
'@/domain/pluginRuntimePolicy.cjs',
'@/domain/pluginGitHubRepository.cjs',
'@/domain/pluginPermissionDescriptions.cjs',