Compare commits

...
Author SHA1 Message Date
bincxz 072377777f fix(sftp): align session-backed sudo open with openSftp fallbacks
Reject sudo+forced-SCP before channel open, and fall back to the standard
SFTP subsystem on exit 127 so reused SSH transports do not re-prompt MFA
on hosts without a standalone sftp-server (e.g. ESXi).
2026-08-12 18:57:00 +08:00
陈大猫 27b62b2372 Merge pull request #2938 from myderr/codex/fix-sftp-sudo-mfa-reuse
fix(sftp): 复用 SSH 会话打开 sudo SFTP
2026-08-12 18:25:49 +08:00
bincxz 627e652f56 merge: sync main and resolve SFTP connection test conflict
Keep sudo MFA session reuse plus home-dir candidate recovery from #2941.
2026-08-12 18:25:28 +08:00
陈大猫 17c2eb6346 Merge pull request #2941 from binaricat/codex/fix-sftp-root-provisional
fix(sftp): keep home candidates when realpath root is not listable
2026-08-12 18:22:52 +08:00
ydm 01102e2d66 merge: 同步 main 并解决 SFTP 复用冲突 2026-08-12 18:19:45 +08:00
bincxz 81dcf849fb fix(sftp): keep home candidates when realpath root is not listable
Accept realpath('/') as home only after a successful readdir so virtual/chroot
roots from #2934 still work, while non-listable root no longer skips
/home/<user> and /root probing. Also retry those candidates if listing fails.
2026-08-12 18:18:14 +08:00
陈大猫 1dfe1379cd Merge pull request #2934 from pplulee/codex/fix-sftp-virtual-root
修复 SFTP 虚拟根目录会话的传输失败
2026-08-12 18:10:02 +08:00
ydm 27ec58fa40 fix(sftp): 修复侧栏自动连接去重边界 2026-08-12 18:09:59 +08:00
陈大猫 7ac878d00e Merge pull request #2939 from binaricat/fix/show-tab-number-badges-exhaustive-deps
fix: include setShowTabNumberBadgesState in storage sync deps
2026-08-12 18:02:00 +08:00
bincxz f90f40e624 fix: include setShowTabNumberBadgesState in storage sync deps
The storage event listener uses setShowTabNumberBadgesState but
omitted it from the useEffect dependency array, which triggered
react-hooks/exhaustive-deps.
2026-08-12 18:01:07 +08:00
ydm dfc9ed7f61 fix(sftp): 复用 SSH 会话打开 sudo SFTP 2026-08-12 17:58:45 +08:00
陈大猫 2c2d7e0b55 Merge pull request #2937 from binaricat/codex/fix-theme-header-button-alignment
Fix theme header button alignment
2026-08-12 17:54:23 +08:00
bincxz ef364b69fd Fix theme header button alignment 2026-08-12 17:50:10 +08:00
陈大猫 700d603e64 Merge pull request #2925 from binaricat/cursor/issue-2922-31559806317
fix(ai): keep host_open sessions after sidebar scope sync
2026-08-12 17:40:46 +08:00
netcatty-bot f2b3facd9f fix: address Codex review on PR #2925 2026-08-12 09:32:41 +00:00
bincxz 7f08bec0b9 Merge remote-tracking branch 'origin/main' into codex/pr-2925-review
# Conflicts:
#	electron/bridges/terminalBridge.sessionActivity.test.cjs
2026-08-12 16:50:25 +08:00
bincxz bbd4c698a5 fix(ai): harden host_open session lifecycle 2026-08-12 16:42:43 +08:00
陈大猫 4beccc3d61 Merge pull request #2928 from binaricat/cursor/issue-2926-31561097764
fix(terminal): re-run onConnect scripts on manual reconnect
2026-08-12 16:42:39 +08:00
pplulee 60e057318f fix: handle SFTP virtual root sessions 2026-08-12 16:29:35 +08:00
bincxz 33b3beaea0 Merge remote-tracking branch 'origin/main' into codex/pr-2928 2026-08-12 16:14:56 +08:00
bincxz e65af2fd7b fix(terminal): safely rerun connect scripts on manual reconnect 2026-08-12 16:14:43 +08:00
陈大猫 5d63a9476c Merge pull request #2932 from binaricat/codex/fix-2924-dropbear-stats-limit
fix(ssh): keep server stats safe for Dropbear
2026-08-12 15:35:20 +08:00
bincxz 58c72ace32 fix(ssh): keep server stats safe for Dropbear 2026-08-12 15:29:14 +08:00
陈大猫 75cbda7edc Merge pull request #2917 from binaricat/cursor/issue-2899-31554398579
fix(ssh): stop parked reconnect PID probes
2026-08-12 14:55:52 +08:00
bincxz 6f6d8dc36d fix(ssh): recover cwd safely after parked reconnect 2026-08-12 14:42:25 +08:00
netcatty-bot d0f819efb0 fix: address Codex review on PR #2917 2026-08-12 06:36:25 +00:00
netcatty-bot 9671468412 fix: address Codex review on PR #2925 2026-08-12 06:36:20 +00:00
陈大猫 380e96eae5 Merge pull request #2929 from binaricat/codex/fix-docker-tmux-popup-timeout
fix(terminal): start command popups after vault hydration
2026-08-12 14:12:01 +08:00
netcatty-bot e4f9303de5 fix: address Codex review on PR #2925 2026-08-12 06:04:52 +00:00
netcatty-bot 02ce011934 fix: address Codex review on PR #2917 2026-08-12 06:03:20 +00:00
bincxz 8e45763e4c fix(terminal): start command popups after vault hydration 2026-08-12 13:53:54 +08:00
netcatty-bot 0d92c5d738 fix: address Codex review on PR #2928 2026-08-12 05:26:14 +00:00
netcatty-bot c8a393f12b fix: address Codex review on PR #2925 2026-08-12 05:25:45 +00:00
bincxz 6bf4268e8e fix(ssh): scope parked reconnect cwd guard 2026-08-12 13:17:50 +08:00
netcatty-bot 7114288d2f fix: address Codex review on PR #2925 2026-08-12 04:46:21 +00:00
bincxz f02b645af7 fix(ssh): avoid stale cwd after parked reconnect 2026-08-12 12:44:11 +08:00
netcatty-bot eb3cd06278 fix: address Codex review on PR #2928 2026-08-12 04:42:43 +00:00
bincxz 7ed0b32489 Merge remote-tracking branch 'origin/main' into codex/pr-2917-final
# Conflicts:
#	electron/bridges/sshBridge.connectionReuse.test.cjs
2026-08-12 12:36:46 +08:00
陈大猫 2fe46cbf6f Merge pull request #2918 from binaricat/cursor/issue-2916-31555268705
fix(workspace): allow dragging hosts into focus-mode sidebar
2026-08-12 12:24:21 +08:00
bincxz 53ebbec58c fix(workspace): expand focus sidebar host drop target 2026-08-12 12:16:52 +08:00
netcatty-bot 0382c089e1 fix: address Codex review on PR #2925 2026-08-12 04:05:21 +00:00
netcatty-bot 253ce57d84 fix: address Codex review on PR #2917 2026-08-12 04:02:40 +00:00
netcatty-bot 9beb7d3108 fix(#2926): automated Cursor CLI fix 2026-08-12 04:00:36 +00:00
陈大猫 525a78e83e Merge pull request #2921 from binaricat/cursor/issue-2919-31558580686
fix(ai): sync side panel color-scheme for light Codex composer
2026-08-12 11:57:04 +08:00
陈大猫 0e94950a5c Merge pull request #2927 from binaricat/cursor/issue-2923-31560157438
fix(ssh): skip idle-park reconnect PID discovery that breaks bastions
2026-08-12 11:56:18 +08:00
陈大猫 a2ea0f6464 Merge pull request #2913 from binaricat/cursor/issue-2912-31541806461
fix(system): exclude NFS/CIFS mounts from overview disk totals
2026-08-12 11:52:44 +08:00
netcatty-bot 42b174abb3 fix(#2923): automated Cursor CLI fix 2026-08-12 03:49:12 +00:00
bincxz e2ad0f526e fix(workspace): complete host drop paths 2026-08-12 11:44:35 +08:00
netcatty-bot f8c7dd44db fix(#2922): automated Cursor CLI fix 2026-08-12 03:43:57 +00:00
bincxz cfeee2ec89 fix(ssh): skip redundant PID discovery on reconnect 2026-08-12 11:39:58 +08:00
bincxz be3e971064 fix(system): filter legacy distributed filesystem sources 2026-08-12 11:35:37 +08:00
陈大猫 35bb83f57c Merge pull request #2914 from binaricat/cursor/issue-2863-31551484962
fix(zmodem): fail skipped uploads and auto-overwrite on terminal drag-drop
2026-08-12 11:34:37 +08:00
netcatty-bot 5279ac2836 fix(#2919): automated Cursor CLI fix 2026-08-12 03:16:44 +00:00
bincxz 793ba54bda fix(system): cover generic FUSE and scoped IPv6 mounts 2026-08-12 11:14:40 +08:00
bincxz a1ca1a3443 fix(zmodem): preserve drag-drop overwrite targets 2026-08-12 11:14:03 +08:00
netcatty-bot 3c528c6ab5 fix: address Codex review on PR #2917 2026-08-12 03:13:17 +00:00
netcatty-bot 6dd703afce fix: address Codex review on PR #2918 2026-08-12 03:12:52 +00:00
netcatty-bot 10c399f47b fix(#2916): automated Cursor CLI fix 2026-08-12 02:13:50 +00:00
netcatty-bot a4ec7bab61 fix: address Codex review on PR #2914 2026-08-12 02:11:24 +00:00
netcatty-bot 79215d8896 fix(#2899): automated Cursor CLI fix 2026-08-12 02:02:41 +00:00
netcatty-bot 2012fc53c7 fix: address Codex review on PR #2914 2026-08-12 01:24:14 +00:00
netcatty-bot 2be738b8c2 fix(#2863): automated Cursor CLI fix 2026-08-12 01:04:52 +00:00
netcatty-bot 93d7f8e1ec fix: address Codex review on PR #2913 2026-08-11 23:16:26 +00:00
netcatty-bot 924c9a0efa fix: address Codex review on PR #2913 2026-08-11 22:56:23 +00:00
netcatty-bot 0d9da150ec fix(#2912): automated Cursor CLI fix 2026-08-11 22:39:50 +00:00
陈大猫 d307a2ebc3 Merge pull request #2910 from binaricat/automation/nix-release-v1.1.78
Update Nix release metadata for v1.1.78
2026-08-12 01:14:30 +08:00
github-actions[bot] d97439f878 Update Nix release metadata for v1.1.78 2026-08-11 17:10:46 +00:00
101 changed files with 5982 additions and 406 deletions

No files matched your search

+32 -8
View File
@@ -34,6 +34,7 @@ import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocu
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
@@ -317,6 +318,29 @@ function AppViewInner({ domains }: AppViewProps) {
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
}, [setAddToWorkspaceDialog]);
const validProxyProfileIds = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
resolveEffectiveTerminalHost({
host,
groupConfigs,
proxyProfiles,
validProxyProfileIds,
})
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
const host = hosts.find((entry) => entry.id === hostId);
if (!host) return;
const ws = workspaces.find((entry) => entry.id === workspaceId);
if (!ws) return;
const rootDir = ws.root.type === 'split' ? ws.root.direction : 'vertical';
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
const isPeerSessionWindow = typeof window !== 'undefined'
&& window.location.hash.startsWith('#/session-window');
const externalMcpToggle = useExternalMcpToggleState();
@@ -471,6 +495,7 @@ function AppViewInner({ domains }: AppViewProps) {
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderWorkTabs}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
onAppendHostToWorkspace={handleAppendHostToWorkspace}
showSftpTab={showSftpTab}
showHostTreeSidebar={showHostTreeSidebar}
switchTabKeyBinding={keyBindings.find((binding) => binding.action === 'switchToTab') ?? null}
@@ -659,6 +684,7 @@ function AppViewInner({ domains }: AppViewProps) {
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
onRequestAddToWorkspace={handleRequestAddToWorkspace}
onAppendHostToWorkspace={handleAppendHostToWorkspace}
onUpdateSplitSizes={updateSplitSizes}
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
@@ -780,13 +806,7 @@ function AppViewInner({ domains }: AppViewProps) {
<AddToWorkspaceDialog
open
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
// Filter serial hosts only in append mode — appendHostToWorkspace
// has no serial code path. Create mode goes through
// createWorkspaceFromTargets, which builds a SerialConfig-backed
// session for serial hosts, so those should remain pickable.
hosts={addToWorkspaceDialog.mode === 'append'
? hosts.filter((h) => h.protocol !== 'serial')
: hosts}
hosts={hosts}
workspaceTitle={
addToWorkspaceDialog.mode === 'append'
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
@@ -805,7 +825,11 @@ function AppViewInner({ domains }: AppViewProps) {
if (target.kind === 'local') {
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
} else {
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
appendHostToWorkspace(
addToWorkspaceDialog.workspaceId,
resolveWorkspaceAppendHost(target.host),
rootDir,
);
}
}
} else {
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
test('workspace append resolves group defaults before creating host sessions', () => {
assert.match(source, /resolveEffectiveTerminalHost\(\{/);
assert.match(source, /groupConfigs,/);
assert.match(source, /proxyProfiles,/);
assert.match(
source,
/appendHostToWorkspace\(workspaceId, resolveWorkspaceAppendHost\(host\), rootDir\)/,
);
assert.match(
source,
/appendHostToWorkspace\([\s\S]*?resolveWorkspaceAppendHost\(target\.host\),[\s\S]*?rootDir/,
);
});
@@ -1,11 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
runConnectScriptsSequential,
selectScriptOverlayRun,
setScriptRuns,
waitForScriptRun,
} from './scriptAutomationCoordinator.ts';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import type { Snippet } from '@/domain/models';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
const runId = 'run-already-done';
@@ -64,3 +67,182 @@ test('selectScriptOverlayRun does not resurface older completed runs after dismi
dismissedRunIds.add(latestRun.runId);
assert.equal(selectScriptOverlayRun([olderRun, latestRun], 'sess1', dismissedRunIds), undefined);
});
test('runConnectScriptsSequential cancels only its own queued run and waits for stop', async () => {
const originalGet = netcattyBridge.get;
const sessionId = 'sess-connect-abort';
let queuedRunId: string | undefined;
let releaseStop: (() => void) | undefined;
const scriptStopCalls: string[] = [];
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
setScriptRuns([{
runId: 'unrelated-active-run',
scriptId: 'manual-script',
sessionId,
status: 'running',
startedAt: Date.now(),
logs: [],
}]);
netcattyBridge.get = () => ({
scriptRun: async (params) => {
queuedRunId = params.runId;
assert.equal(params.returnWhenQueued, true);
return { runId: params.runId!, runIds: [params.runId!] };
},
scriptStop: (id: string) => new Promise((resolve) => {
scriptStopCalls.push(id);
releaseStop = () => resolve({ ok: true });
}),
}) as ReturnType<typeof netcattyBridge.get>;
const controller = new AbortController();
let stopCurrentRun: (() => Promise<void>) | null = null;
const snippet: Snippet = {
id: 'connect-script',
label: 'Connect',
command: 'nct.session.sleep(60)',
kind: 'script',
};
try {
const running = runConnectScriptsSequential({
scripts: [snippet],
sessionId,
signal: controller.signal,
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
});
void running.catch(() => {});
await Promise.resolve();
controller.abort();
for (let attempt = 0; attempt < 10 && scriptStopCalls.length === 0; attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
assert.deepEqual(scriptStopCalls, [queuedRunId]);
assert.notEqual(queuedRunId, 'unrelated-active-run');
assert.equal(typeof stopCurrentRun, 'function');
let settled = false;
void running.finally(() => { settled = true; }).catch(() => {});
await Promise.resolve();
assert.equal(settled, false);
releaseStop?.();
await assert.rejects(
() => running,
(error: unknown) => error instanceof DOMException && error.name === 'AbortError',
);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runConnectScriptsSequential retries the exact stop after a transient failure', async () => {
const originalGet = netcattyBridge.get;
const storage = new Map<string, string>();
const stopCalls: string[] = [];
let stopCurrentRun: (() => Promise<void>) | null = null;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
netcattyBridge.get = () => ({
scriptRun: async (params) => ({ runId: params.runId!, runIds: [params.runId!] }),
scriptStop: async (id: string) => {
stopCalls.push(id);
return { ok: stopCalls.length > 1 };
},
}) as ReturnType<typeof netcattyBridge.get>;
const controller = new AbortController();
try {
const running = runConnectScriptsSequential({
scripts: [{ id: 'retry-stop', label: 'Retry stop', command: 'await nct.sleep(60)', kind: 'script' }],
sessionId: 'sess-retry-stop',
signal: controller.signal,
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
});
void running.catch(() => {});
for (let attempt = 0; attempt < 10 && !stopCurrentRun; attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
controller.abort();
await assert.rejects(() => running, /could not be stopped/);
assert.equal(typeof stopCurrentRun, 'function');
await stopCurrentRun!();
assert.equal(stopCalls.length, 2);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runConnectScriptsSequential does not treat a script error named Aborted as cancellation', async () => {
const originalGet = netcattyBridge.get;
const storage = new Map<string, string>();
const scriptStopCalls: string[] = [];
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
netcattyBridge.get = () => ({
scriptRun: async (params) => {
setScriptRuns([{
runId: params.runId!,
scriptId: params.scriptId,
sessionId: params.sessionId!,
status: 'failed',
startedAt: Date.now() - 10,
endedAt: Date.now(),
error: 'Aborted',
logs: [],
}]);
return { runId: params.runId!, runIds: [params.runId!] };
},
scriptStop: async (id: string) => {
scriptStopCalls.push(id);
return { ok: true };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
await assert.rejects(
() => runConnectScriptsSequential({
scripts: [{ id: 'fails', label: 'Fails', command: "throw new Error('Aborted')", kind: 'script' }],
sessionId: 'sess-real-error',
signal: new AbortController().signal,
}),
/Aborted/,
);
assert.deepEqual(scriptStopCalls, []);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
@@ -83,6 +83,8 @@ export function getActiveScriptRunForSession(sessionId: string): ScriptRun | und
}
export async function runAutomationScript(params: {
runId?: string;
returnWhenQueued?: boolean;
snippet: Snippet;
sessionId: string;
sessionIds?: string[];
@@ -104,6 +106,8 @@ export async function runAutomationScript(params: {
throw new Error('Script bridge unavailable');
}
return bridge.scriptRun({
runId: params.runId,
returnWhenQueued: params.returnWhenQueued,
scriptId: params.snippet.id,
scriptLabel: params.snippet.label,
content: params.snippet.command,
@@ -176,6 +180,7 @@ export async function runConnectScriptsSequential(params: {
scripts: Snippet[];
sessionId: string;
signal?: AbortSignal;
onCancelableRunChange?: (stopCurrentRun: (() => Promise<void>) | null) => void;
onScriptStart?: (snippet: Snippet) => void;
onScriptComplete?: (snippet: Snippet) => void;
sessionMeta?: {
@@ -185,18 +190,63 @@ export async function runConnectScriptsSequential(params: {
username?: string;
};
}): Promise<void> {
for (const snippet of params.scripts) {
const throwIfAborted = () => {
if (params.signal?.aborted) {
throw new Error('Aborted');
throw new DOMException('Connect script run cancelled', 'AbortError');
}
};
for (const snippet of params.scripts) {
throwIfAborted();
const runId = crypto.randomUUID();
let stopPromise: Promise<void> | undefined;
let stopped = false;
params.onScriptStart?.(snippet);
const { runId } = await runAutomationScript({
const queueAccepted = runAutomationScript({
runId,
returnWhenQueued: true,
snippet,
sessionId: params.sessionId,
sessionMeta: params.sessionMeta,
});
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
const stopThisRun = () => {
if (stopPromise) return stopPromise;
const attempt = queueAccepted.then(async () => {
const result = await stopScriptRun(runId);
if (!result.ok) {
throw new Error(`Connect script run could not be stopped: ${runId}`);
}
stopped = true;
});
stopPromise = attempt;
void attempt.catch(() => {
if (stopPromise === attempt) stopPromise = undefined;
});
return attempt;
};
params.onCancelableRunChange?.(stopThisRun);
const onAbort = () => {
void stopThisRun().catch(() => {});
};
params.signal?.addEventListener('abort', onAbort, { once: true });
try {
await queueAccepted;
throwIfAborted();
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
} catch (err) {
if (params.signal?.aborted) {
await stopThisRun();
throw new DOMException('Connect script run cancelled', 'AbortError');
}
throw err;
} finally {
params.signal?.removeEventListener('abort', onAbort);
if (!params.signal?.aborted || stopped) {
params.onCancelableRunChange?.(null);
}
}
}
}
+35 -5
View File
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "../../domain/models";
import { applyGroupDefaults } from "../../domain/groupConfig";
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
import {
createHostTerminalSession,
createSerialTerminalSession,
@@ -74,7 +74,34 @@ test("serial session factories snapshot effective legacy Backspace behavior", ()
assert.equal(explicitDefaultSession.serialConfig?.backspaceBehavior, "default");
});
test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", () => {
test("workspace host factory creates a complete serial session", () => {
const session = createWorkspaceHostTerminalSession("session-serial", host({
protocol: "serial",
hostname: "COM7",
port: 57600,
username: "",
serialConfig: {
path: "COM7",
baudRate: 57600,
dataBits: 7,
stopBits: 2,
parity: "even",
},
}), "workspace-1");
assert.equal(session.workspaceId, "workspace-1");
assert.equal(session.protocol, "serial");
assert.deepEqual(session.serialConfig, {
path: "COM7",
baudRate: 57600,
dataBits: 7,
stopBits: 2,
parity: "even",
backspaceBehavior: "default",
});
});
test("workspace append snapshots serial Backspace behavior inherited from a group", () => {
const savedHost = host({
protocol: "serial",
hostname: "COM3",
@@ -87,12 +114,15 @@ test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", (
backspaceBehavior: "default",
}),
});
const effectiveHost = applyGroupDefaults(savedHost, {
backspaceBehavior: "ctrl-h",
const effectiveHost = resolveEffectiveTerminalHost({
host: savedHost,
groupConfigs: [{ path: "network", backspaceBehavior: "ctrl-h" }],
proxyProfiles: [],
});
const session = createHostTerminalSession("session-1", effectiveHost);
const session = createWorkspaceHostTerminalSession("session-1", effectiveHost, "workspace-1");
assert.equal(session.workspaceId, "workspace-1");
assert.equal(session.serialConfig?.backspaceBehavior, "ctrl-h");
});
+1
View File
@@ -568,6 +568,7 @@ export function useSettingsStorageSync({
setShowRecentHostsState,
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
setShowTabNumberBadgesState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -162,7 +162,6 @@ export async function openTransferSftpSession(
if (
!wantDedicated
&& options?.sourceSessionId
&& !host.sftpSudo
&& bridge.openSftpForSession
) {
try {
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "../../../domain/models";
import { ensureRemoteSftpSession } from "./ensureRemoteSftpSession";
import { ensureRemoteSftpSession, probeSftpSession } from "./ensureRemoteSftpSession";
import type { SftpPane } from "./types";
const host = {
@@ -37,6 +37,24 @@ const remotePane = (connectionId: string): SftpPane => ({
const noopReleaseConnection = async () => {};
test("SFTP session probe accepts a virtual root directory", async () => {
const calls: Array<[string, string]> = [];
const ok = await probeSftpSession({
realpathSftp: async (sftpId, remotePath) => {
calls.push([sftpId, remotePath]);
return "/";
},
}, "sftp-jumpserver");
assert.equal(ok, true);
assert.deepEqual(calls, [["sftp-jumpserver", "."]]);
});
test("SFTP session probe does not require home discovery", async () => {
const ok = await probeSftpSession(undefined, "sftp-live");
assert.equal(ok, true);
});
test("returns an existing mapped SFTP session without reconnecting", async () => {
let connectCalls = 0;
const sftpId = await ensureRemoteSftpSession({
@@ -3,6 +3,24 @@ import type { MutableRefObject } from "react";
import { isSessionError } from "./errors";
import type { SftpPane } from "./types";
export interface SftpSessionProbeBridge {
realpathSftp?: (sftpId: string, path: string) => Promise<string>;
}
/**
* Probe the SFTP protocol itself instead of using remote home discovery.
* Restricted/chroot servers can deny SSH exec or expose '/' as their virtual
* root while the SFTP session remains fully usable.
*/
export async function probeSftpSession(
bridge: SftpSessionProbeBridge | null | undefined,
sftpId: string,
): Promise<boolean> {
if (!bridge?.realpathSftp) return true;
await bridge.realpathSftp(sftpId, ".");
return true;
}
export interface EnsureRemoteSftpSessionParams {
side: "left" | "right";
getActivePane: (side: "left" | "right") => SftpPane | null;
@@ -3,8 +3,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
buildSftpConnectInFlightKey,
buildSftpHomeDirCandidates,
runSftpConnectOnceByKey,
createSftpConnectionId,
createPinnedReconnectSideResolver,
openSftpConnectionOnce,
@@ -15,12 +19,62 @@ import {
resolvePinnedReconnectSide,
} from "./useSftpConnections.ts";
test("home dir candidates prefer user home then root", () => {
assert.deepEqual(buildSftpHomeDirCandidates("deploy"), ["/home/deploy", "/root"]);
assert.deepEqual(buildSftpHomeDirCandidates("root"), ["/root"]);
assert.deepEqual(buildSftpHomeDirCandidates(undefined), ["/root"]);
assert.deepEqual(buildSftpHomeDirCandidates(null), ["/root"]);
});
test("connection ids stay unique even when connects start in the same millisecond", () => {
const ids = ["uuid-a", "uuid-b"];
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-a");
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-b");
});
test("runSftpConnectOnceByKey shares an in-flight connect for the same tab and endpoint", async () => {
const inFlight = new Map<string, Promise<void>>();
let runs = 0;
let releaseFirst: (() => void) | undefined;
const first = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
runs += 1;
await new Promise<void>((resolve) => {
releaseFirst = resolve;
});
});
const second = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
runs += 1;
});
assert.equal(runs, 1);
releaseFirst?.();
await Promise.all([first, second]);
assert.equal(runs, 1);
assert.equal(inFlight.size, 0);
});
test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs", () => {
const first = buildSftpConnectInFlightKey({
side: "left",
tabId: "new-tab-a",
targetConnectionKey: "host-key",
sourceSessionId: "ssh-session-1",
initialPath: "/home",
forceNewTab: true,
});
const second = buildSftpConnectInFlightKey({
side: "left",
tabId: "new-tab-b",
targetConnectionKey: "host-key",
sourceSessionId: "ssh-session-1",
initialPath: "/home",
forceNewTab: true,
});
assert.notEqual(first, second);
});
const openOptions = {
sessionId: "sftp-request-1",
hostname: "192.168.9.138",
@@ -73,6 +127,43 @@ test("openSftpWithSessionPreference falls back to normal SFTP when session reuse
assert.deepEqual(calls, ["openForSession:ssh-session-1", "openSftp:sftp-request-1"]);
});
test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fresh auth", async () => {
const calls: string[] = [];
let passedOptions: NetcattySSHOptions | undefined;
const sftpId = await openSftpWithSessionPreference({
bridge: {
openSftpForSession: async (sessionId: string, options?: NetcattySSHOptions) => {
calls.push(`openForSession:${sessionId}`);
passedOptions = options;
return "sudo-session-backed-sftp";
},
openSftp: async () => {
calls.push("openSftp");
return "fresh-sftp";
},
},
sourceSessionId: "ssh-session-1",
openOptions: {
...openOptions,
sudo: true,
password: "sudo-pass",
},
});
assert.equal(sftpId, "sudo-session-backed-sftp");
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
assert.equal(passedOptions?.sudo, true);
assert.equal(passedOptions?.password, "sudo-pass");
});
test("remote browse connect does not discard sourceSessionId for sudo hosts", () => {
const source = readFileSync(new URL("./useSftpConnections.ts", import.meta.url), "utf8");
assert.doesNotMatch(
source,
/host\.sftpSudo\s*\?\s*undefined\s*:\s*options\?\.sourceSessionId/,
);
});
test("openSftpWithSessionPreference opens normal SFTP without a source session", async () => {
const calls: string[] = [];
const sftpId = await openSftpWithSessionPreference({
+87 -26
View File
@@ -97,6 +97,13 @@ export async function releaseSftpConnectionMetadata(params: {
params.onRemoteSessionClosed?.(sftpId);
}
/** Hardcoded home-path candidates when SSH exec / listable realpath fail. */
export function buildSftpHomeDirCandidates(username?: string | null): string[] {
if (username === "root") return ["/root"];
if (username) return [`/home/${username}`, "/root"];
return ["/root"];
}
export function createSftpConnectionId(
side: "left" | "right",
randomUUID: () => string = () => crypto.randomUUID(),
@@ -176,6 +183,41 @@ export function createPinnedReconnectSideResolver(
};
}
export function runSftpConnectOnceByKey(
inFlight: Map<string, Promise<void>>,
key: string,
run: () => Promise<void>,
): Promise<void> {
const existing = inFlight.get(key);
if (existing) return existing;
const promise = run().finally(() => {
if (inFlight.get(key) === promise) {
inFlight.delete(key);
}
});
inFlight.set(key, promise);
return promise;
}
export function buildSftpConnectInFlightKey(params: {
side: "left" | "right";
tabId: string;
targetConnectionKey: string;
sourceSessionId?: string;
initialPath?: string;
forceNewTab?: boolean;
}): string {
return [
params.side,
params.tabId,
params.targetConnectionKey,
params.sourceSessionId ?? "",
params.initialPath ?? "",
params.forceNewTab ? "force-new-tab" : "",
].join("\u0000");
}
interface UseSftpConnectionsResult {
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
@@ -249,6 +291,7 @@ export const useSftpConnections = ({
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
const setPendingHostKeyVerification = useCallback((
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
@@ -426,21 +469,31 @@ export const useSftpConnections = ({
// immediately, avoiding race conditions with deferred effects.
options?.onTabCreated?.(activeTabId);
const connectionId = createSftpConnectionId(side);
const connectInFlightKey = buildSftpConnectInFlightKey({
side,
tabId: activeTabId,
targetConnectionKey,
sourceSessionId: host === "local" ? undefined : options?.sourceSessionId,
initialPath: effectiveInitialPath,
forceNewTab: options?.forceNewTab,
});
navSeqRef.current[side] += 1;
const connectRequestId = navSeqRef.current[side];
const getTargetPane = () => {
const targetSide = resolveTargetSide();
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
return tabs.find((tab) => tab.id === activeTabId) ?? null;
};
const isTargetConnectionCurrent = () => {
const pane = getTargetPane();
if (!pane) return false;
if (pane.connection?.id === connectionId) return true;
return !pane.connection && navSeqRef.current[side] === connectRequestId;
};
return runSftpConnectOnceByKey(connectInFlightRef.current, connectInFlightKey, async () => {
const connectionId = createSftpConnectionId(side);
navSeqRef.current[side] += 1;
const connectRequestId = navSeqRef.current[side];
const getTargetPane = () => {
const targetSide = resolveTargetSide();
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
return tabs.find((tab) => tab.id === activeTabId) ?? null;
};
const isTargetConnectionCurrent = () => {
const pane = getTargetPane();
if (!pane) return false;
if (pane.connection?.id === connectionId) return true;
return !pane.connection && navSeqRef.current[side] === connectRequestId;
};
const isTargetConnectionAtPath = (path: string) => {
const connection = getTargetPane()?.connection;
if (!connection) return navSeqRef.current[side] === connectRequestId;
@@ -573,9 +626,7 @@ export const useSftpConnections = ({
sharedHostCache?.path,
);
// Sudo never reuses a terminal shell; normalize before UI state so
// reusedConnection (spinner/overlay) matches the actual open path.
const sourceSessionId = host.sftpSudo ? undefined : options?.sourceSessionId;
const sourceSessionId = options?.sourceSessionId;
const connection: SftpConnection = {
id: connectionId,
@@ -698,15 +749,7 @@ export const useSftpConnections = ({
}
if (!detected) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
} else if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
candidates.push("/root");
} else {
candidates.push("/root");
}
const candidates = buildSftpHomeDirCandidates(credentials.username);
const statSftp = bridge?.statSftp;
if (statSftp) {
for (const candidate of candidates) {
@@ -784,6 +827,23 @@ export const useSftpConnections = ({
// root also failed
}
}
// Last resort: home candidates. Covers provisional "/" from realpath
// when discovery treated root as home and listing it failed, so
// /home/<user> or /root can still recover the session.
if (!fallbackSucceeded) {
for (const candidate of buildSftpHomeDirCandidates(credentials.username)) {
if (candidate === startPath) continue;
try {
files = await listRemoteFiles(sftpId, candidate, filenameEncoding);
startPath = candidate;
homeDir = candidate;
fallbackSucceeded = true;
break;
} catch {
// Ignore missing/permission errors
}
}
}
if (!fallbackSucceeded) {
throw new Error("Cannot list any remote directory");
}
@@ -855,6 +915,7 @@ export const useSftpConnections = ({
unsubSftpProgress?.();
}
}
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { createLatestPayloadSync } from './useExternalMcpSessionSync.ts';
const hookSource = readFileSync(new URL('./useExternalMcpSessionSync.ts', import.meta.url), 'utf8');
const appSource = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
const preloadSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
const handlerSource = readFileSync(
new URL('../../electron/bridges/aiBridge/agentProcessHandlers.cjs', import.meta.url),
'utf8',
);
test('app live session sync stays mounted and is independent of External MCP enablement', () => {
assert.match(appSource, /useExternalMcpSessionSync\(\{/);
assert.match(hookSource, /bridge\.aiMcpUpdateLiveSessions\?\.\(nextPayload\)/);
assert.match(hookSource, /liveSyncRef\.current\.push\(payload, serialized\)/);
assert.match(hookSource, /bridge\.aiMcpUpdateSessions\?\.\(/);
assert.match(hookSource, /externalSyncRef\.current\.push\(payload, serialized\)/);
assert.doesNotMatch(
hookSource,
/if \(!enabled\)[\s\S]{0,300}aiMcpUpdateLiveSessions/,
);
});
test('live session sync has a complete renderer to main-process IPC path', () => {
assert.match(preloadSource, /netcatty:ai:mcp:update-live-sessions/);
assert.match(handlerSource, /netcatty:ai:mcp:update-live-sessions/);
assert.match(handlerSource, /mcpServerBridge\.updateLiveSessionMetadata/);
});
test('latest payload sync converges to A-B-A while B is in flight', async () => {
let releaseB: (() => void) | undefined;
const sent: string[] = [];
const sync = createLatestPayloadSync(async (payload: string) => {
sent.push(payload);
if (payload === 'B') {
await new Promise<void>((resolve) => {
releaseB = resolve;
});
}
return { ok: true };
});
await sync.push('A');
const sendingB = sync.push('B');
void sync.push('A');
releaseB?.();
await sendingB;
assert.deepEqual(sent, ['A', 'B', 'A']);
});
test('cancelling a payload sync drops a queued update', async () => {
let releaseA: (() => void) | undefined;
const sent: string[] = [];
const sync = createLatestPayloadSync(async (payload: string) => {
sent.push(payload);
if (payload === 'A') {
await new Promise<void>((resolve) => {
releaseA = resolve;
});
}
return { ok: true };
});
const sendingA = sync.push('A');
void sync.push('B');
sync.cancel();
releaseA?.();
await sendingA;
assert.deepEqual(sent, ['A']);
});
+67 -18
View File
@@ -17,6 +17,49 @@ type UseExternalMcpSessionSyncOptions = {
portForwardingRules: PortForwardingRule[];
};
export function createLatestPayloadSync<T>(
send: (payload: T) => Promise<unknown>,
) {
let desired: { serialized: string; payload: T } | null = null;
let acknowledged = '';
let inFlight: Promise<void> | null = null;
let cancelled = false;
const pump = async () => {
while (!cancelled && desired && desired.serialized !== acknowledged) {
const target = desired;
try {
const result = await send(target.payload);
if (cancelled) break;
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
if (desired === target) break;
continue;
}
acknowledged = target.serialized;
} catch {
if (desired === target) break;
}
}
};
return {
push(payload: T, serialized = JSON.stringify(payload)): Promise<void> {
if (cancelled) return Promise.resolve();
desired = { payload, serialized };
if (!inFlight) {
inFlight = pump().finally(() => {
inFlight = null;
});
}
return inFlight;
},
cancel(): void {
cancelled = true;
desired = null;
},
};
}
function isMainAppWindow(): boolean {
if (typeof window === 'undefined') return false;
const hash = window.location.hash || '';
@@ -43,7 +86,6 @@ export function useExternalMcpSessionSync({
void enabledTick;
return readExternalMcpStoredEnabled();
}, [enabledTick]);
const syncGenerationRef = useRef(0);
useEffect(() => {
const bump = () => setEnabledTick((value) => value + 1);
@@ -77,34 +119,41 @@ export function useExternalMcpSessionSync({
});
}, [sessions, sessionHostsMap, hosts, portForwardingRules]);
const lastSentSerializedRef = useRef('');
const liveSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
const externalSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
useEffect(() => {
if (!isMainAppWindow()) return;
const bridge = netcattyBridge.get();
if (!bridge?.aiMcpUpdateLiveSessions) return;
const serialized = JSON.stringify(payload);
liveSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
Promise.resolve(bridge.aiMcpUpdateLiveSessions?.(nextPayload))
));
void liveSyncRef.current.push(payload, serialized);
}, [payload]);
useEffect(() => {
if (!isMainAppWindow()) return;
if (!enabled) {
syncGenerationRef.current += 1;
lastSentSerializedRef.current = '';
// Recreate the synchronizer after re-enabling because disabling can
// clear the main-process scope even when the renderer payload is equal.
externalSyncRef.current?.cancel();
externalSyncRef.current = null;
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.aiMcpUpdateSessions) return;
const serialized = JSON.stringify(payload);
if (serialized === lastSentSerializedRef.current) return;
const generation = ++syncGenerationRef.current;
const timeoutId = window.setTimeout(() => {
void Promise.resolve(bridge.aiMcpUpdateSessions?.(payload, EXTERNAL_MCP_CHAT_SESSION_ID))
.then((result) => {
if (generation !== syncGenerationRef.current) return;
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
return;
}
lastSentSerializedRef.current = serialized;
})
.catch(() => {
// Leave the ref unset so the next effect can retry.
});
externalSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
Promise.resolve(bridge.aiMcpUpdateSessions?.(
nextPayload,
EXTERNAL_MCP_CHAT_SESSION_ID,
))
));
void externalSyncRef.current.push(payload, serialized);
}, 250);
return () => {
-5
View File
@@ -797,11 +797,6 @@ export const useSessionState = ({
host: Host,
direction: SplitDirection = 'vertical',
): string | null => {
// Serial hosts use a different session constructor; they currently
// only enter workspaces via createSerialSession + drag, so reject
// them here to avoid a partially-constructed session.
if (host.protocol === 'serial') return null;
// Cheap early-exit using the ref when the workspace is clearly
// absent. The authoritative check lives inside the setWorkspaces
// updater below so we also cover the concurrent-close race.
@@ -105,4 +105,8 @@ test('workspace host creation paths use the host session snapshot factory', () =
source,
/createWorkspaceHostTerminalSession\(newSessionId,\s*host,\s*workspaceId\)/,
);
assert.doesNotMatch(
source,
/if \(host\.protocol === 'serial'\) return null/,
);
});
+13 -24
View File
@@ -30,7 +30,7 @@ import { buildSftpHostCredentials } from "./sftp/useSftpHostCredentials";
import { useSftpFileWatch } from "./sftp/useSftpFileWatch";
import { useSftpSessionCleanup } from "./sftp/useSftpSessionCleanup";
import { useSftpSessionErrors } from "./sftp/useSftpSessionErrors";
import { ensureRemoteSftpSession } from "./sftp/ensureRemoteSftpSession";
import { ensureRemoteSftpSession, probeSftpSession } from "./sftp/ensureRemoteSftpSession";
import { openTransferSftpSession } from "./sftp/dedicatedTransferResume";
import {
createTransferPoolKeyCache,
@@ -316,9 +316,7 @@ export const useSftpState = (
// Prefer borrowing the live (or parked) terminal SSH transport so MFA is
// not repeated. Transport leases keep the shared conn alive after the
// terminal tab closes until the transfer SFTP lease is returned.
const sourceSessionId = !host.sftpSudo
? resolveTransferSourceSessionId?.(host.id, host)
: undefined;
const sourceSessionId = resolveTransferSourceSessionId?.(host.id, host);
if (sourceSessionId) {
try {
logger.info(
@@ -608,16 +606,7 @@ export const useSftpState = (
forceReconnect: ensureOptions?.forceReconnect,
releaseConnection,
tabId,
probeSession: async (sftpId) => {
// Lightweight liveness check; any session-error from the bridge
// triggers a reconnect in ensureRemoteSftpSession.
if (!bridge?.getSftpHomeDir) return true;
const result = await bridge.getSftpHomeDir(sftpId);
if (result && result.success === false) {
throw new Error(result.error || "SFTP session not found");
}
return true;
},
probeSession: (sftpId) => probeSftpSession(bridge, sftpId),
});
},
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
@@ -704,6 +693,12 @@ export const useSftpState = (
const pane = getActivePane(side);
if (!pane?.connection || pane.connection.isLocal) continue;
if (sftpSessionsRef.current.has(pane.connection.id)) continue;
const connectedHost = connectedHostByTabIdRef.current.get(pane.id) ?? null;
const vaultHost = hosts.find((host) => host.id === pane.connection?.hostId) ?? null;
const targetHost = connectedHost && connectedHost !== "local" ? connectedHost : vaultHost;
const resolvedSourceSessionId = targetHost
? resolveBrowseSourceSessionId?.(targetHost.id, targetHost)
: undefined;
try {
await ensureRemoteSftpSession({
side,
@@ -713,16 +708,10 @@ export const useSftpState = (
connect,
resolveConnectedHost: (id) => connectedHostByTabIdRef.current.get(id) ?? null,
resolveHostById: (hostId) => hosts.find((host) => host.id === hostId) ?? null,
resolveSourceSessionId: resolveBrowseSourceSessionId,
probeSession: async (sftpId) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSftpHomeDir) return true;
const result = await bridge.getSftpHomeDir(sftpId);
if (result && result.success === false) {
throw new Error(result.error || "SFTP session not found");
}
return true;
},
resolveSourceSessionId: resolvedSourceSessionId
? () => resolvedSourceSessionId
: resolveBrowseSourceSessionId,
probeSession: (sftpId) => probeSftpSession(netcattyBridge.get(), sftpId),
releaseConnection,
});
logger.info(`[SFTP] Restored browse session on ${side}`);
+5 -1
View File
@@ -759,7 +759,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
: -1}
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
draggable={!isMultiSelectMode}
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
onDragStart={(e) => {
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("host-id", host.id);
}}
onClick={() => {
const action = resolveHostActivateAction({
behavior: normalizedHostClickBehavior,
+8 -1
View File
@@ -86,6 +86,7 @@ import { scheduleDeferredTerminalFocus } from "./systemManager/tmuxActionFocus";
import {
connectionKeyMatchesHost,
findReusableSftpSidePanelTab,
isPendingSameEndpointSshSession,
shouldAcceptPendingSftpUpload,
shouldResetSftpSidePanelSourceSession,
shouldSkipSftpSidePanelAutoConnect,
@@ -560,6 +561,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
activeHost.username,
activeHost.sftpFileProtocol,
);
const pendingSameEndpointSession = sessions.find((session) => (
isPendingSameEndpointSshSession(session, activeHost)
));
if (!activeSessionId && pendingSameEndpointSession) {
return;
}
const sessionChanged = shouldResetSftpSidePanelSourceSession(
lastSourceSessionIdRef.current,
activeSessionId,
@@ -737,7 +744,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
},
});
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive]);
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive, sessions]);
useEffect(() => {
if (!activeHost || !isVisible) return;
+125 -24
View File
@@ -225,8 +225,16 @@ import {
} from "./terminal/terminalRuntimeMount";
import type { CreateXTermRuntimeContext } from "./terminal/runtime/createXTermRuntime";
import { TerminalView } from "./terminal/TerminalView";
import {
cancelConnectAutomationBatch,
createConnectAutomationBatch,
trackConnectAutomationStop,
type ConnectAutomationBatch,
} from "./terminal/connectAutomationBatch";
import {
getInitialTerminalStatus,
resolveTerminalVaultInitialized,
shouldResetConnectAutomationOnReconnect,
shouldSuppressHostStartupCommandOnReconnect,
shouldStartTerminalBackend,
} from "./terminal/restoredSessionGate";
@@ -270,6 +278,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
workspaceId,
restoreState,
vaultInitializedOverride,
pendingInitialCwd,
shellType,
lastCwd,
@@ -359,10 +368,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, []);
// Reactive vault-ready flag so restored panes re-arm boot after hydration
// (module isVaultInitialized() alone is not a React dependency).
const vaultInitialized = useVaultSnapshotField("isVaultInitialized");
const sharedVaultInitialized = useVaultSnapshotField("isVaultInitialized");
const vaultInitialized = resolveTerminalVaultInitialized(
sharedVaultInitialized,
vaultInitializedOverride,
);
const connectScriptsConsumedRef = useRef(false);
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
const connectScriptsInFlightRef = useRef(false);
const connectScriptsBatchRef = useRef<ConnectAutomationBatch | null>(null);
const pendingScriptRunIdRef = useRef<string | null>(null);
const pendingScriptHandledRef = useRef<Snippet | null>(null);
const pendingScriptRef = useRef(pendingScript);
@@ -1603,7 +1617,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalCwdTracker,
]);
const cleanupSession = async () => {
const cleanupSession = async (options?: { retainOwnership?: boolean }) => {
const closingSessionId = sessionRef.current;
xtermRuntimeRef.current?.flushKittyKeyboardReleases();
sessionRef.current = null;
@@ -1631,7 +1645,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Still notify main so in-flight SSH passphrase prompts for this UI
// sessionId are aborted even before a backend session was attached.
try {
await terminalBackend.closeSession(sessionId, { bootEpoch: resolveCloseBootEpoch() });
await terminalBackend.closeSession(sessionId, {
bootEpoch: resolveCloseBootEpoch(),
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
});
} catch (err) {
logger.warn("Failed to cancel pending session boot on disconnect", err);
}
@@ -1740,7 +1757,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
clearTerminalSessionFlowAck(closingSessionId);
}
try {
await terminalBackend.closeSession(closingSessionId, { bootEpoch: resolveCloseBootEpoch() });
await terminalBackend.closeSession(closingSessionId, {
bootEpoch: resolveCloseBootEpoch(),
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
});
} catch (err) {
logger.warn("Failed to close SSH session", err);
}
@@ -2511,9 +2531,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!shouldEvaluateConnect && !hasPendingWork) return;
if (connectScriptsInFlightRef.current) return;
const batch = createConnectAutomationBatch();
connectScriptsBatchRef.current = batch;
connectScriptsInFlightRef.current = true;
const batchStillActive = () => (
!batch.controller.signal.aborted && connectScriptsBatchRef.current === batch
);
// Defer until xterm has rendered login output and the main-process output tap
// has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer).
let timerFired = false;
const timer = window.setTimeout(() => {
timerFired = true;
if (!batchStillActive()) return;
const runPending = Boolean(pendingOne);
const connectQueueNow = connectScriptsConsumedRef.current
? []
@@ -2542,6 +2572,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
})) {
connectScriptsConsumedRef.current = true;
}
if (connectScriptsBatchRef.current === batch) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
return;
}
@@ -2550,11 +2584,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)),
);
connectScriptsInFlightRef.current = true;
void runConnectScriptsSequential({
scripts: scriptsToRun,
sessionId,
signal: batch.controller.signal,
onCancelableRunChange: (stopCurrentRun) => (
trackConnectAutomationStop(batch, stopCurrentRun)
),
sessionMeta: {
connected: true,
name: scriptSessionName,
@@ -2562,6 +2598,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
username: host.username,
},
onScriptComplete: (snippet) => {
if (!batchStillActive()) return;
if (snippet.id && connectIdsInBatch.has(snippet.id)) {
connectScriptsCompletedIdsRef.current.add(snippet.id);
}
@@ -2575,6 +2612,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
},
})
.then(() => {
if (!batchStillActive()) return;
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
const doneAfterRun = resolvedAfterRun.length === 0
|| resolvedAfterRun.every(
@@ -2585,6 +2623,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
})
.catch(async (err) => {
if (!batchStillActive()) return;
const message = err instanceof Error ? err.message : String(err);
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
connectScriptsConsumedRef.current = true;
@@ -2600,6 +2639,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
await runConnectScriptsSequential({
scripts: [pendingScriptToMark],
sessionId,
signal: batch.controller.signal,
onCancelableRunChange: (stopCurrentRun) => (
trackConnectAutomationStop(batch, stopCurrentRun)
),
sessionMeta: {
connected: true,
name: scriptSessionName,
@@ -2607,6 +2650,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
username: host.username,
},
onScriptComplete: (snippet) => {
if (!batchStillActive()) return;
if (snippet.id) {
pendingScriptRunIdRef.current = snippet.id;
} else {
@@ -2615,16 +2659,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
},
});
} catch (pendingErr) {
if (!batchStillActive()) return;
const pendingMessage = pendingErr instanceof Error ? pendingErr.message : String(pendingErr);
toast.error(pendingMessage.includes('Observer mode') ? t('scripts.observer.blocked') : pendingMessage);
}
})
.finally(() => {
connectScriptsInFlightRef.current = false;
if (
!batch.controller.signal.aborted
&& connectScriptsBatchRef.current === batch
) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
});
}, 400);
return () => window.clearTimeout(timer);
return () => {
window.clearTimeout(timer);
if (!timerFired && connectScriptsBatchRef.current === batch) {
batch.controller.abort();
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
} else if (statusRef.current !== "connected") {
void cancelConnectAutomationBatch(batch).catch(() => {});
}
};
}, [effectiveTerminalProtocol, host, isPendingScriptAlreadyHandled, moshShellReady, pendingScript, pendingScriptId, scriptSessionName, sessionId, snippets, status, t]);
useEffect(() => {
@@ -3249,7 +3309,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setPendingHostKeyRequestId(null);
setError(null);
setProgressLogs((prev) => [...prev, "Disconnected by user."]);
void cleanupSession();
void cleanupSession({ retainOwnership: true });
updateStatus("disconnected");
setChainProgress(null);
setIsDisconnectedDialogDismissed(false);
@@ -3352,20 +3412,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
if (!termRef.current) return;
if (mode === "manual") {
clearAutoReconnect();
prepareRestoredReconnect();
// A clone's first connection can fail (auth/host-key/transport) before the
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
// intent for non-restored sessions, so re-arm it here; the callback no-ops
// once the cwd was consumed or when there is no pending inherited cwd.
prepareInitialCwdIntent();
} else {
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
}
// Claim the retry before awaiting close. A close/cancel/unmount during the
// awaited backend cleanup invalidates this token and stops the continuation.
// Claim the retry before awaiting either script cancellation or backend
// cleanup. A second reconnect/cancel invalidates this continuation.
const retryToken = Symbol("retry");
retryTokenRef.current = retryToken;
reconnectPreparationTokenRef.current = retryToken;
@@ -3376,8 +3424,61 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
const connectAutomationBatch = connectScriptsBatchRef.current;
if (connectAutomationBatch) {
try {
await cancelConnectAutomationBatch(connectAutomationBatch);
} catch (error) {
finishReconnectPreparation();
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
if (mode === "auto" && retryTokenStillCurrent()) {
// Return to disconnected so the existing auto-reconnect loop can
// schedule another attempt, including another exact stop request.
updateStatus("disconnected");
}
return;
}
if (!retryTokenStillCurrent()) {
finishReconnectPreparation();
return;
}
if (connectScriptsBatchRef.current === connectAutomationBatch) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
}
if (mode === "manual") {
clearAutoReconnect();
// Manual reconnect skips the disconnected status transition that normally
// clears these refs, so onConnect scripts would otherwise stay consumed.
if (shouldResetConnectAutomationOnReconnect(
restoreState === "restored-disconnected" ? "restored" : "manual",
)) {
// The prior batch is now stopped, so its callbacks cannot mutate these
// newly allocated guards for the fresh manual connection.
connectScriptsConsumedRef.current = false;
connectScriptsCompletedIdsRef.current = new Set();
connectScriptsInFlightRef.current = false;
}
prepareRestoredReconnect();
// A clone's first connection can fail (auth/host-key/transport) before the
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
// intent for non-restored sessions, so re-arm it here; the callback no-ops
// once the cwd was consumed or when there is no pending inherited cwd.
prepareInitialCwdIntent();
} else {
// An automatic reconnect replaces the transport but remains the same user
// session. Stop old automation above, then preserve the existing policy of
// not starting onConnect scripts again on the replacement transport.
connectScriptsConsumedRef.current = true;
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
}
try {
await cleanupSession();
await cleanupSession({ retainOwnership: true });
} catch (error) {
finishReconnectPreparation();
throw error;
+2
View File
@@ -220,6 +220,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCreateWorkspaceFromSessions,
onAddSessionToWorkspace,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onUpdateSplitSizes,
onSetDraggingSessionId,
onToggleWorkspaceViewMode,
@@ -2107,6 +2108,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCopySession,
onCopySessionToNewWindow,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onSessionData,
onSetDraggingSessionId,
onSetWorkspaceFocusedSession,
+4
View File
@@ -141,6 +141,10 @@ test('popup terminals resolve complete host config and pass jump hosts into Term
assert.match(source, /onDeleteSnippets=\{deleteSelectedSnippets\}/);
});
test('popup terminals use their window-local vault readiness', () => {
assert.match(source, /vaultInitializedOverride=\{vaultInitialized\}/);
});
test('popup provider tree mounts the plugin authentication host', () => {
assert.match(source, /import \{ PluginAuthenticationHost \} from '\.\/plugins\/PluginAuthenticationHost';/);
assert.match(
+1
View File
@@ -467,6 +467,7 @@ function TerminalPopupPageInner() {
accentMode={settings.accentMode}
customAccent={settings.customAccent}
terminalSettings={settings.terminalSettings}
vaultInitializedOverride={vaultInitialized}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
sessionId={sessionId}
startupCommand={isAttachMode ? undefined : config.startupCommand}
+8
View File
@@ -361,6 +361,14 @@ test("workspace session drag data is recognized with a dedicated drag type", ()
assert.equal(getWorkspaceSessionDragId(transfer), "session-1");
});
test("TopTabs wires host drops to workspace tabs without replacing tab reorder", () => {
assert.match(topTabsSource, /appendHostFromWorkspaceDrop/);
assert.match(topTabsSource, /workspaceMap\.has\(targetTabId\)/);
assert.match(topTabsSource, /dropEffect = 'copy'/);
assert.match(topTabsSource, /isHostDropTarget=\{hostDropWorkspaceId === workspace\.id\}/);
assert.match(appViewSource, /<TopTabs[\s\S]*?onAppendHostToWorkspace=\{handleAppendHostToWorkspace\}/);
});
test("workspace session drag id falls back to the legacy session id", () => {
const transfer = {
types: ["session-id"],
+52 -3
View File
@@ -7,6 +7,10 @@ import { buildTabShortcutNumberById } from '../application/app/tabShortcutTarget
import { useShortcutModifierHeld } from '../application/state/useShortcutModifierHeld';
import type { EditorTabChrome } from '../application/state/editorTabStore';
import { collectSessionIds } from '../domain/workspace';
import {
appendHostFromWorkspaceDrop,
resolveFocusSidebarDragKind,
} from '../domain/focusSidebarHostDrop';
import type { DynamicTabTitleMode, KeyBinding } from '../domain/models';
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
@@ -156,6 +160,7 @@ interface TopTabsProps {
sessionId: string,
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
) => void;
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
switchTabKeyBinding: Pick<KeyBinding, 'mac' | 'pc'> | null;
@@ -201,6 +206,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onEndSessionDrag,
onReorderTabs,
onRemoveSessionFromWorkspace,
onAppendHostToWorkspace,
showSftpTab,
showHostTreeSidebar,
switchTabKeyBinding,
@@ -260,6 +266,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
// Tab reorder drag state
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
const [isDraggingForReorder, setIsDraggingForReorder] = useState(false);
const [hostDropWorkspaceId, setHostDropWorkspaceId] = useState<string | null>(null);
const draggedTabIdRef = useRef<string | null>(null);
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
@@ -521,12 +528,28 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
draggedTabIdRef.current = null;
setDropIndicator(null);
setIsDraggingForReorder(false);
setHostDropWorkspaceId(null);
onEndSessionDrag();
}, [onEndSessionDrag]);
const handleTabDragOver = useCallback((e: React.DragEvent, tabId: string) => {
const dragKind = resolveFocusSidebarDragKind({ types: e.dataTransfer.types });
if (dragKind === 'host-append') {
if (!onAppendHostToWorkspace || !workspaceMap.has(tabId)) {
setHostDropWorkspaceId(null);
return;
}
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
setDropIndicator(null);
setHostDropWorkspaceId(tabId);
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setHostDropWorkspaceId(null);
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
setDropIndicator(null);
@@ -544,16 +567,40 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
// Always update drop indicator on drag over to ensure it doesn't get stuck
setDropIndicator({ tabId, position });
}, []);
}, [onAppendHostToWorkspace, workspaceMap]);
const handleTabDragLeave = useCallback((_e: React.DragEvent) => {
const handleTabDragLeave = useCallback((e: React.DragEvent) => {
const next = e.relatedTarget;
if (next instanceof Node && e.currentTarget.contains(next)) return;
setHostDropWorkspaceId(null);
// Don't clear drop indicator on drag leave - let onDragOver manage it
// This prevents the indicator from flickering/disappearing during fast drags
// The indicator will be cleared when drag ends or on drop
}, []);
const handleTabDrop = useCallback((e: React.DragEvent, targetTabId: string) => {
const dragKind = resolveFocusSidebarDragKind({ types: e.dataTransfer.types });
if (dragKind === 'host-append') {
const handled = Boolean(
onAppendHostToWorkspace
&& workspaceMap.has(targetTabId)
&& appendHostFromWorkspaceDrop({
types: e.dataTransfer.types,
getData: (type) => e.dataTransfer.getData(type),
workspaceId: targetTabId,
onAppendHostToWorkspace,
}),
);
setHostDropWorkspaceId(null);
setDropIndicator(null);
if (!handled) return;
e.preventDefault();
e.stopPropagation();
return;
}
e.preventDefault();
setHostDropWorkspaceId(null);
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
const draggedSessionId = getWorkspaceSessionDragId(e.dataTransfer);
const draggedSession = sessions.find((s) => s.id === draggedSessionId);
@@ -582,7 +629,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
setDropIndicator(null);
setIsDraggingForReorder(false);
}, [dropIndicator, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaces]);
}, [dropIndicator, onAppendHostToWorkspace, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaceMap, workspaces]);
const handleTabBarDrop = useCallback((e: React.DragEvent) => {
if (!hasWorkspaceSessionDrag(e.dataTransfer)) return;
@@ -872,6 +919,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
shiftStyle={shiftStyle}
showDropIndicatorBefore={showDropIndicatorBefore}
showDropIndicatorAfter={showDropIndicatorAfter}
isHostDropTarget={hostDropWorkspaceId === workspace.id}
onTabDragStart={handleTabDragStart}
onTabDragEnd={handleTabDragEnd}
onTabDragOver={handleTabDragOver}
@@ -1208,6 +1256,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
prev.onCopySession === next.onCopySession &&
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
prev.onEditHost === next.onEditHost &&
prev.onAppendHostToWorkspace === next.onAppendHostToWorkspace &&
prev.onCopyWorkspace === next.onCopyWorkspace &&
prev.onOpenSettings === next.onOpenSettings &&
prev.externalMcpEnabled === next.externalMcpEnabled &&
+1 -6
View File
@@ -50,7 +50,7 @@ test("sftp host picker uses single-line quick switcher row layout", () => {
assert.doesNotMatch(source, /text-xs text-muted-foreground truncate/);
});
test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
test("sftp host picker passes sourceSessionId for sudo connected hosts", () => {
assert.match(source, /sftpSourceSessionIdForHost/);
assert.match(
source,
@@ -60,11 +60,6 @@ test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
source,
/onSelectHost\(\s*item\.entry\.host,\s*sourceSessionId \? \{ sourceSessionId \} : undefined,\s*\)/,
);
// Must not always pass sessionId as a reuse hint (sudo would hide the spinner).
assert.doesNotMatch(
source,
/onSelectHost\(item\.entry\.host, \{ sourceSessionId: item\.entry\.sessionId \}\)/,
);
});
test("sftp host picker virtualizes the host list", () => {
@@ -4,6 +4,7 @@ import type { SftpPane } from "../../application/state/sftp/types";
import {
connectionKeyMatchesHost,
findReusableSftpSidePanelTab,
isPendingSameEndpointSshSession,
isRemoteSftpTabHealthy,
shouldAcceptPendingSftpUpload,
shouldResetSftpSidePanelSourceSession,
@@ -77,6 +78,35 @@ test("shouldSkipSftpSidePanelAutoConnect rejects when the active tab has no endp
);
});
test("isPendingSameEndpointSshSession only waits for actively connecting SSH sessions", () => {
const host = {
id: "host-1",
hostname: "server.example",
port: 22,
username: "root",
};
const baseSession = {
hostId: "host-1",
hostname: "server.example",
port: 22,
username: "root",
protocol: "ssh",
};
assert.equal(
isPendingSameEndpointSshSession({ ...baseSession, status: "connecting" }, host),
true,
);
assert.equal(
isPendingSameEndpointSshSession({ ...baseSession, status: "disconnected" }, host),
false,
);
assert.equal(
isPendingSameEndpointSshSession({ ...baseSession, status: "connected" }, host),
false,
);
});
test("connectionKeyMatchesHost accepts host-id prefix keys", () => {
assert.equal(connectionKeyMatchesHost("host-1:server:22:ssh::root", "host-1"), true);
assert.equal(connectionKeyMatchesHost("host-2:server:22:ssh::root", "host-1"), false);
@@ -33,6 +33,36 @@ export function shouldSkipSftpSidePanelAutoConnect(
return isRemoteSftpTabHealthy(activeTab, hasBackendSession);
}
export function isPendingSameEndpointSshSession(
session: {
hostId?: string | null;
status?: string;
hostname?: string | null;
port?: number | null;
username?: string | null;
protocol?: string | null;
moshEnabled?: boolean;
etEnabled?: boolean;
},
host: {
id: string;
hostname: string;
port?: number | null;
username: string;
},
): boolean {
return Boolean(
session.hostId === host.id
&& session.status === "connecting"
&& session.hostname === host.hostname
&& (session.port ?? 22) === (host.port ?? 22)
&& session.username === host.username
&& (session.protocol === "ssh" || session.protocol === undefined)
&& !session.moshEnabled
&& !session.etEnabled,
);
}
/** Whether a stored endpoint key still belongs to the live connection's host. */
export function connectionKeyMatchesHost(
connectionKey: string | null | undefined,
+1 -1
View File
@@ -394,7 +394,7 @@ test("manual disconnect keeps the session pane for reconnect", () => {
assert.match(body, /isBootActiveRef\.current = false/);
assert.match(body, /setIsCancelling\(true\)/);
assert.match(body, /updateStatus\("disconnected"\)/);
assert.match(body, /void cleanupSession\(\)/);
assert.match(body, /void cleanupSession\(\{ retainOwnership: true \}\)/);
assert.match(source, /trackSessionCleanup/);
assert.doesNotMatch(body, /onCloseSession/);
assert.match(source, /handleDisconnect: \(attachExistingSession \|\| compactToolbar\) \? undefined : handleDisconnect/);
@@ -13,3 +13,10 @@ test("hidden selected theme uses the normal theme item row", () => {
assert.match(source, /hiddenSelectedTheme && \(\s*<ThemeItem/);
assert.doesNotMatch(source, /terminal\.hiddenTheme\.title[\s\S]*terminal\.hiddenTheme\.desc/);
});
test("theme side panel tabs are vertically centered in the header", () => {
assert.match(
source,
/TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1\.5 gap-0\.5 border-b'/,
);
});
+1 -1
View File
@@ -311,7 +311,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
>
{/* Tab Bar */}
<div
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex px-1.5 gap-0.5 border-b')}
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1.5 gap-0.5 border-b')}
style={{ borderColor: 'var(--terminal-panel-border)' }}
>
<button
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
cancelConnectAutomationBatch,
createConnectAutomationBatch,
trackConnectAutomationStop,
} from "./connectAutomationBatch.ts";
test("cancel waits for the stop operation registered by the abort listener", async () => {
const batch = createConnectAutomationBatch();
let releaseStop: (() => void) | undefined;
let stopFinished = false;
batch.controller.signal.addEventListener("abort", () => {
trackConnectAutomationStop(batch, () => new Promise<void>((resolve) => {
releaseStop = () => {
stopFinished = true;
resolve();
};
}));
}, { once: true });
const cancelling = cancelConnectAutomationBatch(batch);
await Promise.resolve();
assert.equal(stopFinished, false);
releaseStop?.();
await cancelling;
assert.equal(stopFinished, true);
});
test("cancel surfaces a backend stop failure", async () => {
const batch = createConnectAutomationBatch();
batch.controller.signal.addEventListener("abort", () => {
trackConnectAutomationStop(batch, async () => {
throw new Error("stop failed");
});
}, { once: true });
await assert.rejects(() => cancelConnectAutomationBatch(batch), /stop failed/);
});
test("cancel can retry a stop operation after a transient failure", async () => {
const batch = createConnectAutomationBatch();
let attempts = 0;
trackConnectAutomationStop(batch, async () => {
attempts += 1;
if (attempts === 1) throw new Error("temporary stop failure");
});
await assert.rejects(
() => cancelConnectAutomationBatch(batch),
/temporary stop failure/,
);
await cancelConnectAutomationBatch(batch);
assert.equal(attempts, 2);
});
@@ -0,0 +1,25 @@
export interface ConnectAutomationBatch {
controller: AbortController;
stopCurrentRun: (() => Promise<void>) | null;
}
export const createConnectAutomationBatch = (): ConnectAutomationBatch => ({
controller: new AbortController(),
stopCurrentRun: null,
});
export const trackConnectAutomationStop = (
batch: ConnectAutomationBatch,
stopCurrentRun: (() => Promise<void>) | null,
): void => {
batch.stopCurrentRun = stopCurrentRun;
};
export const cancelConnectAutomationBatch = async (
batch: ConnectAutomationBatch,
): Promise<void> => {
batch.controller.abort();
if (batch.stopCurrentRun) {
await batch.stopCurrentRun();
}
};
@@ -4,6 +4,8 @@ import { readFileSync } from "node:fs";
import {
getInitialTerminalStatus,
resolveTerminalVaultInitialized,
shouldResetConnectAutomationOnReconnect,
shouldSuppressHostStartupCommandOnReconnect,
shouldStartTerminalBackend,
} from "./restoredSessionGate.ts";
@@ -41,12 +43,108 @@ test("terminal boot waits for vaultInitialized before creating a backend session
);
});
test("terminal popup can supply its own completed vault hydration state", () => {
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const popupSource = readFileSync(new URL("../TerminalPopupPage.tsx", import.meta.url), "utf8");
assert.match(
terminalSource,
/const vaultInitialized = resolveTerminalVaultInitialized\(\s*sharedVaultInitialized,\s*vaultInitializedOverride,\s*\);/,
);
assert.match(popupSource, /vaultInitializedOverride=\{vaultInitialized\}/);
assert.equal(resolveTerminalVaultInitialized(false, true), true);
assert.equal(resolveTerminalVaultInitialized(false), false);
assert.equal(resolveTerminalVaultInitialized(true, false), false);
});
test("host startup command policy distinguishes restored and automatic reconnects", () => {
assert.equal(shouldSuppressHostStartupCommandOnReconnect("restored"), false);
assert.equal(shouldSuppressHostStartupCommandOnReconnect("manual"), false);
assert.equal(shouldSuppressHostStartupCommandOnReconnect("automatic"), true);
});
test("connect automation reset policy distinguishes manual and automatic reconnects", () => {
assert.equal(shouldResetConnectAutomationOnReconnect("manual"), true);
assert.equal(shouldResetConnectAutomationOnReconnect("restored"), true);
assert.equal(shouldResetConnectAutomationOnReconnect("automatic"), false);
});
test("manual reconnect resets connect automation before opening a new session", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const reconnectIndex = source.indexOf("const startReconnect = ");
const cancelBatchIndex = source.indexOf(
"await cancelConnectAutomationBatch(connectAutomationBatch)",
reconnectIndex,
);
const manualBranchIndex = source.indexOf('if (mode === "manual")', reconnectIndex);
const stopFailureRetryIndex = source.indexOf(
'if (mode === "auto" && retryTokenStillCurrent())',
cancelBatchIndex,
);
const stopFailureDisconnectedIndex = source.indexOf(
'updateStatus("disconnected")',
stopFailureRetryIndex,
);
const resetConsumedIndex = source.indexOf(
"connectScriptsConsumedRef.current = false",
manualBranchIndex,
);
const resetCompletedIndex = source.indexOf(
"connectScriptsCompletedIdsRef.current = new Set()",
manualBranchIndex,
);
const resetInFlightIndex = source.indexOf(
"connectScriptsInFlightRef.current = false",
manualBranchIndex,
);
const connectingIndex = source.indexOf('updateStatus("connecting")', manualBranchIndex);
const autoElseIndex = source.indexOf("} else {", manualBranchIndex);
const autoSuppressIndex = source.indexOf(
'shouldSuppressHostStartupCommandOnReconnect("automatic")',
autoElseIndex,
);
assert.notEqual(reconnectIndex, -1);
assert.notEqual(cancelBatchIndex, -1);
assert.notEqual(manualBranchIndex, -1);
assert.notEqual(stopFailureRetryIndex, -1);
assert.notEqual(stopFailureDisconnectedIndex, -1);
assert.notEqual(resetConsumedIndex, -1);
assert.notEqual(resetCompletedIndex, -1);
assert.notEqual(resetInFlightIndex, -1);
assert.notEqual(connectingIndex, -1);
assert.notEqual(autoElseIndex, -1);
assert.notEqual(autoSuppressIndex, -1);
assert.ok(
cancelBatchIndex < manualBranchIndex
&& cancelBatchIndex < resetConsumedIndex
&& cancelBatchIndex < resetCompletedIndex
&& cancelBatchIndex < resetInFlightIndex,
"every reconnect must await onConnect cancellation before manual guards are cleared",
);
assert.ok(
stopFailureRetryIndex < stopFailureDisconnectedIndex
&& stopFailureDisconnectedIndex < manualBranchIndex,
"automatic reconnect must return to disconnected when stopping the old batch fails",
);
assert.ok(
resetConsumedIndex < connectingIndex && resetCompletedIndex < connectingIndex,
"manual reconnect must clear connect-automation consumption before status becomes connecting",
);
assert.ok(
autoElseIndex < autoSuppressIndex && autoSuppressIndex < connectingIndex,
"automatic reconnect must keep the existing connect-automation consumption decision",
);
assert.ok(
!source.slice(autoElseIndex, connectingIndex).includes("connectScriptsConsumedRef.current = false"),
"automatic reconnect must not reset connect-automation refs",
);
assert.match(
source.slice(autoElseIndex, connectingIndex),
/connectScriptsConsumedRef\.current = true/,
"automatic reconnect must stop the old batch without queuing it again",
);
});
test("restored disconnected sessions still create a terminal runtime before backend startup", () => {
const source = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
const runtimeIndex = source.indexOf("const runtime = createXTermRuntime");
@@ -7,6 +7,11 @@ export const getInitialTerminalStatus = (): TerminalSession["status"] => (
"connecting"
);
export const resolveTerminalVaultInitialized = (
sharedVaultInitialized: boolean,
windowVaultInitialized?: boolean,
): boolean => windowVaultInitialized ?? sharedVaultInitialized;
/**
* Backend start waits for vault hydration so restored sessions cannot dial SSH
* with empty keys while hosts have already been published mid-init.
@@ -16,3 +21,11 @@ export const shouldStartTerminalBackend = (): boolean => isVaultInitialized();
export const shouldSuppressHostStartupCommandOnReconnect = (
mode: TerminalReconnectMode,
): boolean => mode === "automatic";
/**
* Manual and restored reconnects are a fresh "connect" for automation scripts.
* Automatic reconnect after a drop must not re-run onConnect scripts mid-session.
*/
export const shouldResetConnectAutomationOnReconnect = (
mode: TerminalReconnectMode,
): boolean => mode !== "automatic";
+2
View File
@@ -136,6 +136,8 @@ export interface TerminalProps {
sessionId: string;
workspaceId?: string;
restoreState?: TerminalSession["restoreState"];
/** Secondary windows hydrate their own vault state outside the main snapshot store. */
vaultInitializedOverride?: boolean;
pendingInitialCwd?: string;
shellType?: TerminalSession["shellType"];
lastCwd?: string;
@@ -164,7 +164,7 @@ test("reconnect preparation cancels an in-flight disconnected hibernate", () =>
const claimIndex = reconnectBody.indexOf(
"reconnectPreparationTokenRef.current = retryToken",
);
const cleanupIndex = reconnectBody.indexOf("await cleanupSession()");
const cleanupIndex = reconnectBody.indexOf("await cleanupSession({ retainOwnership: true })");
const startNewSessionIndex = reconnectBody.indexOf("const startNewSession = () =>");
assert.ok(claimIndex >= 0 && claimIndex < cleanupIndex);
+7
View File
@@ -40,6 +40,13 @@ test("terminal memo refreshes when restored local shell type changes", () => {
);
});
test("terminal memo refreshes when a popup vault finishes hydrating", () => {
assert.equal(
terminalPropsAreEqual(baseProps, { ...baseProps, vaultInitializedOverride: true }),
false,
);
});
test("terminal memo refreshes when terminal context reader callback changes", () => {
assert.equal(
terminalPropsAreEqual(baseProps, {
+1
View File
@@ -36,6 +36,7 @@ export const terminalPropsAreEqual = (
&& prev.terminalSettings === next.terminalSettings
&& prev.sessionId === next.sessionId
&& prev.restoreState === next.restoreState
&& prev.vaultInitializedOverride === next.vaultInitializedOverride
&& prev.shellType === next.shellType
&& prev.lastCwd === next.lastCwd
&& prev.restoreTerminalCwd === next.restoreTerminalCwd
@@ -7,3 +7,31 @@ const source = readFileSync(new URL('./TerminalFocusSidebar.tsx', import.meta.ur
test('focus sidebar row memo refreshes when dynamic title mode changes', () => {
assert.match(source, /prev\.dynamicTabTitleMode === next\.dynamicTabTitleMode/);
});
test('focus sidebar accepts host-id drops for append-to-workspace', () => {
assert.match(source, /onAppendHostToWorkspace/);
assert.match(source, /resolveFocusSidebarDragKind/);
assert.match(source, /appendHostFromWorkspaceDrop/);
assert.match(source, /dropEffect = 'copy'/);
assert.match(source, /data-host-drop-active/);
});
test('the entire focus sidebar is the host drop target', () => {
const sidebarStart = source.indexOf('data-section="terminal-workspace-sidebar"');
const scrollAreaStart = source.indexOf('<ScrollArea className="flex-1">', sidebarStart);
assert.ok(sidebarStart >= 0 && scrollAreaStart > sidebarStart);
const sidebarOpeningTag = source.slice(sidebarStart, scrollAreaStart);
assert.match(sidebarOpeningTag, /data-focus-sidebar-drop-zone/);
assert.match(sidebarOpeningTag, /onDragOver=\{handleFocusSidebarHostDragOver\}/);
assert.match(sidebarOpeningTag, /onDrop=\{handleFocusSidebarHostDrop\}/);
assert.doesNotMatch(source.slice(scrollAreaStart), /\sdata-focus-sidebar-drop-zone(?:\s|>)/);
});
test('focus sidebar clears host-drop feedback when the drag leaves from a session row', () => {
assert.match(source, /data-focus-sidebar-drop-zone/);
assert.match(source, /onDragLeave=\{handleFocusSidebarHostDragLeave\}/);
assert.match(source, /onHostDragLeave=\{handleFocusSidebarHostDragLeave\}/);
assert.match(source, /dropZone\?\.contains\(next\)/);
assert.match(source, /clearFocusSidebarHostDrop\(\)/);
});
+111 -14
View File
@@ -7,6 +7,11 @@ import {
} from '../../application/state/sessionPresentationStore';
import { useStoredNumber } from '../../application/state/useStoredNumber';
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
import {
appendHostFromWorkspaceDrop,
FOCUS_SIDEBAR_SESSION_DRAG_TYPE,
resolveFocusSidebarDragKind,
} from '../../domain/focusSidebarHostDrop';
import { resolveWorkspaceFocusSessionOrder } from '../../domain/workspace';
import { resolveSessionTabTitle } from '../../domain/sessionTabTitle';
import type { DynamicTabTitleMode } from '../../domain/models';
@@ -27,6 +32,7 @@ interface TerminalFocusSidebarProps {
focusedSessionId: string | undefined;
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
onRequestAddToWorkspace?: (workspaceId: string) => void;
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
onCloseSession: (sessionId: string) => void;
onCopySession?: (sessionId: string) => void;
onCopySessionToNewWindow?: (sessionId: string) => void;
@@ -71,6 +77,7 @@ type WorkspaceFocusSessionRowProps = {
onSelect: (sessionId: string) => void;
onDragStart: (event: DragEvent, sessionId: string) => void;
onDragOver: (event: DragEvent, sessionId: string) => void;
onHostDragLeave: (event: DragEvent<HTMLDivElement>) => void;
onDrop: (event: DragEvent, sessionId: string) => void;
onDragEnd: () => void;
dynamicTabTitleMode?: DynamicTabTitleMode;
@@ -96,6 +103,7 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
onSelect,
onDragStart,
onDragOver,
onHostDragLeave,
onDrop,
onDragEnd,
dynamicTabTitleMode,
@@ -142,9 +150,7 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
onContextMenu={() => onSelect(session.id)}
onDragStart={(event) => onDragStart(event, session.id)}
onDragOver={(event) => onDragOver(event, session.id)}
onDragLeave={(event) => {
event.stopPropagation();
}}
onDragLeave={onHostDragLeave}
onDrop={(event) => onDrop(event, session.id)}
onDragEnd={onDragEnd}
onMouseEnter={(event) => {
@@ -247,6 +253,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
focusedSessionId,
onReorderWorkspaceSessions,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onCloseSession,
onCopySession,
onCopySessionToNewWindow,
@@ -262,6 +269,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
}) => {
const [focusSidebarSearch, setFocusSidebarSearch] = useState('');
const [focusSidebarDragSessionId, setFocusSidebarDragSessionId] = useState<string | null>(null);
const [focusSidebarHostDropActive, setFocusSidebarHostDropActive] = useState(false);
const [focusSidebarDropIndicator, setFocusSidebarDropIndicator] = useState<{
sessionId: string;
position: 'before' | 'after';
@@ -343,10 +351,23 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
const handleFocusSidebarDragStart = useCallback((event: DragEvent, sessionId: string) => {
event.stopPropagation();
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('workspace-focus-session-id', sessionId);
event.dataTransfer.setData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE, sessionId);
setFocusSidebarDragSessionId(sessionId);
setFocusSidebarHostDropActive(false);
}, []);
const clearFocusSidebarHostDrop = useCallback(() => {
setFocusSidebarHostDropActive(false);
}, []);
const handleFocusSidebarHostDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
event.stopPropagation();
const dropZone = event.currentTarget.closest<HTMLElement>('[data-focus-sidebar-drop-zone]');
const next = event.relatedTarget;
if (next instanceof Node && dropZone?.contains(next)) return;
clearFocusSidebarHostDrop();
}, [clearFocusSidebarHostDrop]);
const getFocusSidebarContainerDropTarget = useCallback((
container: HTMLElement,
clientY: number,
@@ -379,21 +400,45 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
return lastSessionId ? { sessionId: lastSessionId, position: 'after' } : null;
}, []);
const acceptFocusSidebarHostDrag = useCallback((event: DragEvent) => {
if (!onAppendHostToWorkspace) return false;
if (resolveFocusSidebarDragKind({
types: event.dataTransfer.types,
activeSessionDragId: focusSidebarDragSessionId,
}) !== 'host-append') {
return false;
}
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
setFocusSidebarDropIndicator(null);
setFocusSidebarHostDropActive(true);
return true;
}, [focusSidebarDragSessionId, onAppendHostToWorkspace]);
const handleFocusSidebarHostDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
acceptFocusSidebarHostDrag(event);
}, [acceptFocusSidebarHostDrag]);
const handleFocusSidebarDragOver = useCallback((event: DragEvent, targetSessionId: string) => {
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
if (acceptFocusSidebarHostDrag(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId || draggedSessionId === targetSessionId) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
setFocusSidebarHostDropActive(false);
const rect = event.currentTarget.getBoundingClientRect();
const position = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
setFocusSidebarDropIndicator({ sessionId: targetSessionId, position });
}, [focusSidebarDragSessionId]);
}, [acceptFocusSidebarHostDrag, focusSidebarDragSessionId]);
const handleFocusSidebarContainerDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
if (acceptFocusSidebarHostDrag(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId) return;
const target = getFocusSidebarContainerDropTarget(event.currentTarget, event.clientY, draggedSessionId);
@@ -401,11 +446,36 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setFocusSidebarHostDropActive(false);
setFocusSidebarDropIndicator(target);
}, [focusSidebarDragSessionId, getFocusSidebarContainerDropTarget]);
}, [acceptFocusSidebarHostDrag, focusSidebarDragSessionId, getFocusSidebarContainerDropTarget]);
const appendHostFromFocusSidebarDrop = useCallback((event: DragEvent) => {
if (!onAppendHostToWorkspace) return false;
const handled = appendHostFromWorkspaceDrop({
types: event.dataTransfer.types,
getData: (type) => event.dataTransfer.getData(type),
activeSessionDragId: focusSidebarDragSessionId,
workspaceId: activeWorkspace.id,
onAppendHostToWorkspace,
});
if (!handled) return false;
event.preventDefault();
event.stopPropagation();
setFocusSidebarHostDropActive(false);
setFocusSidebarDropIndicator(null);
return true;
}, [activeWorkspace.id, focusSidebarDragSessionId, onAppendHostToWorkspace]);
const handleFocusSidebarHostDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
appendHostFromFocusSidebarDrop(event);
}, [appendHostFromFocusSidebarDrop]);
const handleFocusSidebarContainerDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
if (appendHostFromFocusSidebarDrop(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId) return;
const target = focusSidebarDropIndicator
@@ -416,8 +486,10 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
onReorderWorkspaceSessions?.(activeWorkspace.id, draggedSessionId, target.sessionId, target.position);
setFocusSidebarDragSessionId(null);
setFocusSidebarDropIndicator(null);
setFocusSidebarHostDropActive(false);
}, [
activeWorkspace.id,
appendHostFromFocusSidebarDrop,
focusSidebarDragSessionId,
focusSidebarDropIndicator,
getFocusSidebarContainerDropTarget,
@@ -425,7 +497,9 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
]);
const handleFocusSidebarDrop = useCallback((event: DragEvent, targetSessionId: string) => {
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
if (appendHostFromFocusSidebarDrop(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId || draggedSessionId === targetSessionId) return;
event.preventDefault();
@@ -437,11 +511,19 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
onReorderWorkspaceSessions?.(activeWorkspace.id, draggedSessionId, targetSessionId, position);
setFocusSidebarDragSessionId(null);
setFocusSidebarDropIndicator(null);
}, [activeWorkspace.id, focusSidebarDragSessionId, focusSidebarDropIndicator, onReorderWorkspaceSessions]);
setFocusSidebarHostDropActive(false);
}, [
activeWorkspace.id,
appendHostFromFocusSidebarDrop,
focusSidebarDragSessionId,
focusSidebarDropIndicator,
onReorderWorkspaceSessions,
]);
const handleFocusSidebarDragEnd = useCallback(() => {
setFocusSidebarDragSessionId(null);
setFocusSidebarDropIndicator(null);
setFocusSidebarHostDropActive(false);
}, []);
const handleSelectSession = useCallback((sessionId: string) => {
@@ -469,14 +551,27 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
return (
<div
className="flex-shrink-0 flex flex-col relative"
className={cn(
'flex-shrink-0 flex flex-col relative transition-[box-shadow,background-color]',
focusSidebarHostDropActive && 'ring-1 ring-inset',
)}
style={{
width: focusSidebarWidth,
backgroundColor: theme.termBg,
backgroundColor: focusSidebarHostDropActive
? `color-mix(in srgb, ${theme.termFg} 8%, ${theme.termBg})`
: theme.termBg,
color: theme.termFg,
boxShadow: focusSidebarHostDropActive
? `inset 0 0 0 1px color-mix(in srgb, ${theme.termFg} 28%, transparent)`
: undefined,
['--terminal-workspace-sidebar-border' as string]: `1px solid ${theme.separator}`,
}}
data-section="terminal-workspace-sidebar"
data-focus-sidebar-drop-zone
data-host-drop-active={focusSidebarHostDropActive ? 'true' : 'false'}
onDragOver={handleFocusSidebarHostDragOver}
onDragLeave={handleFocusSidebarHostDragLeave}
onDrop={handleFocusSidebarHostDrop}
>
<div
className="absolute top-0 right-[-3px] h-full w-2 cursor-ew-resize z-30"
@@ -534,7 +629,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
<ScrollArea className="flex-1">
<div
className="p-2 space-y-1"
className="p-2 space-y-1 min-h-full rounded-md"
onDragOver={handleFocusSidebarContainerDragOver}
onDrop={handleFocusSidebarContainerDrop}
>
@@ -563,6 +658,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
onSelect={handleSelectSession}
onDragStart={handleFocusSidebarDragStart}
onDragOver={handleFocusSidebarDragOver}
onHostDragLeave={handleFocusSidebarHostDragLeave}
onDrop={handleFocusSidebarDrop}
onDragEnd={handleFocusSidebarDragEnd}
dynamicTabTitleMode={dynamicTabTitleMode}
@@ -592,6 +688,7 @@ function terminalFocusSidebarPropsEqual(
if (prev.t !== next.t) return false;
if (prev.onReorderWorkspaceSessions !== next.onReorderWorkspaceSessions) return false;
if (prev.onRequestAddToWorkspace !== next.onRequestAddToWorkspace) return false;
if (prev.onAppendHostToWorkspace !== next.onAppendHostToWorkspace) return false;
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
if (prev.onToggleWorkspaceViewMode !== next.onToggleWorkspaceViewMode) return false;
const prevWs = prev.activeWorkspace;
@@ -22,6 +22,7 @@ const {
getTerminalHostTreeInitialLayoutWidth,
getTerminalHostTreeLayoutTargetWidth,
getTerminalHostTreeMeasuredLayoutWidth,
resolveTerminalHostTreeDragCapabilities,
getTerminalHostTreeSidebarPanelStyle,
getTerminalHostTreeSidebarShellStyle,
isTerminalHostTreeSidebarVisible,
@@ -186,6 +187,25 @@ test('host tree host inline rename rejects empty names without changing hosts',
assert.equal(result.hosts, hosts);
});
test('filtered host rows can drag outward without enabling filtered tree reorder', () => {
assert.deepEqual(resolveTerminalHostTreeDragCapabilities({
kind: 'host',
canReorder: false,
isInlineEditing: false,
}), {
draggable: true,
canAcceptDrop: false,
});
assert.deepEqual(resolveTerminalHostTreeDragCapabilities({
kind: 'group',
canReorder: false,
isInlineEditing: false,
}), {
draggable: false,
canAcceptDrop: false,
});
});
test('host tree hover card is hidden while the same host is inline editing', () => {
assert.equal(shouldShowTerminalHostHoverCard('host-1', null), true);
assert.equal(shouldShowTerminalHostHoverCard('host-1', 'host-2'), true);
@@ -217,3 +237,17 @@ test('host tree row icons, labels, and protocol badges share centered line boxes
assert.match(source, /flex h-5 shrink-0 items-center">\s*\{isExpanded/);
assert.match(source, /flex min-w-0 flex-1 items-center truncate leading-5">\{node\.name\}/);
});
test('filtered host rows can still start host-id drag for focus-sidebar append', () => {
const source = readFileSync(new URL('./TerminalHostTreeSidebar.tsx', import.meta.url), 'utf8');
const hostRowStart = source.indexOf('data-row-type="host"');
const groupRowStart = source.indexOf('data-row-type="group"');
assert.ok(hostRowStart >= 0 && groupRowStart > hostRowStart);
const hostRowBlock = source.slice(hostRowStart, groupRowStart);
assert.match(hostRowBlock, /draggable=\{dragCapabilities\.draggable\}/);
assert.match(hostRowBlock, /effectAllowed = canReorder \? 'copyMove' : 'copy'/);
assert.match(hostRowBlock, /if \(!dragCapabilities\.draggable\) return;/);
assert.match(source, /const canReorder = Boolean\(menuActions\) && !searchActive && !tagsActive;/);
assert.match(source, /canAcceptDrop: input\.canReorder/);
});
@@ -104,6 +104,17 @@ type HostTreeDropTarget =
| { kind: 'root' }
| { kind: 'group'; path: string };
export function resolveTerminalHostTreeDragCapabilities(input: {
kind: HostTreeFlatRow['kind'];
canReorder: boolean;
isInlineEditing: boolean;
}): { draggable: boolean; canAcceptDrop: boolean } {
return {
draggable: !input.isInlineEditing && (input.kind === 'host' || input.canReorder),
canAcceptDrop: input.canReorder,
};
}
interface TerminalHostTreeSidebarProps {
enabled?: boolean;
surfaceVisible?: boolean;
@@ -309,7 +320,7 @@ type HostTreeFlatRowProps = {
activeHostId?: string | null;
expandedPaths: Set<string>;
searchActive: boolean;
canDrag: boolean;
canReorder: boolean;
isDragOver: boolean;
isInlineEditing: boolean;
inlineEditInitialName?: string;
@@ -331,7 +342,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
activeHostId,
expandedPaths,
searchActive,
canDrag,
canReorder,
isDragOver,
isInlineEditing,
inlineEditInitialName,
@@ -348,6 +359,11 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
menuActions,
}) => {
const { t } = useI18n();
const dragCapabilities = resolveTerminalHostTreeDragCapabilities({
kind: row.kind,
canReorder,
isInlineEditing,
});
if (row.kind === 'host') {
const isActive = activeHostId === row.host.id;
@@ -373,14 +389,18 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
paddingLeft: row.depth * 16 + 8,
backgroundColor: isActive ? theme.rowActiveBg : (isDragOver ? theme.rowDropBg : undefined),
}}
draggable={canDrag && !isInlineEditing}
// Host rows stay draggable under search/tag filters so focus-sidebar
// append can still receive host-id; in-tree reorder/drop stays disabled.
draggable={dragCapabilities.draggable}
onDragStart={(event) => {
if (!canDrag || isInlineEditing) return;
if (!dragCapabilities.draggable) return;
event.dataTransfer.setData(HOST_TREE_DRAG_HOST_ID, row.host.id);
event.dataTransfer.effectAllowed = 'move';
// copyMove: tree reorder uses move; focus-sidebar append uses copy.
// Filtered trees disable reorder, so allow copy-only for append.
event.dataTransfer.effectAllowed = canReorder ? 'copyMove' : 'copy';
}}
onDragOver={(event) => {
if (!canDrag) return;
if (!dragCapabilities.canAcceptDrop) return;
event.preventDefault();
event.stopPropagation();
if (hasDragType(event.dataTransfer, HOST_TREE_DRAG_HOST_ID)) {
@@ -393,7 +413,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
}}
onDragLeave={onDragLeaveRow}
onDrop={(event) => {
if (!canDrag) return;
if (!dragCapabilities.canAcceptDrop) return;
event.preventDefault();
event.stopPropagation();
clearHostTreeDropIndicators(event.currentTarget.ownerDocument);
@@ -507,14 +527,14 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
color: theme.termFg,
backgroundColor: isDragOver ? theme.rowDropBg : undefined,
}}
draggable={canDrag && !isInlineEditing}
draggable={dragCapabilities.draggable}
onDragStart={(event) => {
if (!canDrag || isInlineEditing) return;
if (!dragCapabilities.draggable) return;
event.dataTransfer.setData(HOST_TREE_DRAG_GROUP_PATH, node.path);
event.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(event) => {
if (!canDrag) return;
if (!dragCapabilities.canAcceptDrop) return;
event.preventDefault();
event.stopPropagation();
const isDraggingGroup = hasDragType(event.dataTransfer, HOST_TREE_DRAG_GROUP_PATH);
@@ -529,7 +549,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
}}
onDragLeave={onDragLeaveRow}
onDrop={(event) => {
if (!canDrag) return;
if (!dragCapabilities.canAcceptDrop) return;
event.preventDefault();
event.stopPropagation();
clearHostTreeDropIndicators(event.currentTarget.ownerDocument);
@@ -615,7 +635,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
if (prev.row !== next.row) return false;
if (prev.expandedPaths !== next.expandedPaths) return false;
if (prev.searchActive !== next.searchActive) return false;
if (prev.canDrag !== next.canDrag) return false;
if (prev.canReorder !== next.canReorder) return false;
if (prev.isDragOver !== next.isDragOver) return false;
if (prev.isInlineEditing !== next.isInlineEditing) return false;
if (prev.inlineEditInitialName !== next.inlineEditInitialName) return false;
@@ -739,7 +759,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
});
}, [expandedPaths, filteredTree, filteredUngrouped, treeExpandAll]);
const canDrag = Boolean(menuActions) && !searchActive && !tagsActive;
const canReorder = Boolean(menuActions) && !searchActive && !tagsActive;
const handleNewRootGroup = useCallback(() => {
if (!menuActions) return;
@@ -826,10 +846,10 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
}, [clearDragOver]);
const handleRootDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!canDrag) return;
if (!canReorder) return;
event.preventDefault();
setDragOverTarget({ kind: 'root' });
}, [canDrag]);
}, [canReorder]);
const handleRootDragLeave = useCallback((event: React.DragEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget;
@@ -841,11 +861,11 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
}, [clearDragOver]);
const handleRootDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!canDrag) return;
if (!canReorder) return;
event.preventDefault();
clearHostTreeDropIndicators(event.currentTarget.ownerDocument);
handleDropToParent(null, event.dataTransfer);
}, [canDrag, handleDropToParent]);
}, [canReorder, handleDropToParent]);
const handleListPointerDownCapture = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!menuActions) return;
@@ -907,7 +927,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
activeHostId={activeHostId}
expandedPaths={expandedPaths}
searchActive={treeExpandAll}
canDrag={canDrag}
canReorder={canReorder}
isDragOver={isRowDragOver(row)}
isInlineEditing={
(row.kind === 'group' && inlineEdit?.groupPath === row.node.path)
@@ -934,7 +954,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
/>
), [
activeHostId,
canDrag,
canReorder,
clearDragOver,
expandedPaths,
inlineEdit,
@@ -15,6 +15,7 @@ function TerminalLayerFocusSidebarSectionInner({ ctx }: { ctx: FocusSidebarConte
focusedSessionId={ctx.focusedSessionId}
onReorderWorkspaceSessions={ctx.onReorderWorkspaceSessions}
onRequestAddToWorkspace={ctx.onRequestAddToWorkspace}
onAppendHostToWorkspace={ctx.onAppendHostToWorkspace}
onCloseSession={ctx.handleCloseSession}
onCopySession={ctx.onCopySession}
onCopySessionToNewWindow={ctx.onCopySessionToNewWindow}
@@ -165,6 +165,17 @@ test('side panel content scopes app color utilities to the resolved terminal the
assert.match(sectionSource, /\.\.\.sidePanelCssVars/);
});
test('side panel sets color-scheme from the terminal theme so native inputs match light panels', () => {
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
// When a light terminal theme remaps --background under a dark app chrome,
// form controls (Codex/AI composer textarea) still follow color-scheme.
assert.match(
sectionSource,
/colorScheme:\s*resolvedSidePanelTerminalTheme\.type/,
);
});
test('a visible tool without a ready pane host stays in the hidden parking host', () => {
const parkingHost = { id: 'parking' };
const paneHost = { id: 'pane' };
@@ -987,6 +987,9 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
data-side-panel-tab={isSidePanelOpenForCurrentTab ? (activeSidePanelTab ?? undefined) : undefined}
style={{
...sidePanelCssVars,
// Keep native form controls (AI/Codex composer textarea) in sync when a
// light terminal theme remaps --background under dark app chrome.
colorScheme: resolvedSidePanelTerminalTheme.type,
backgroundColor: sidePanelTheme.termBg,
color: sidePanelTheme.termFg,
...(isSidePanelOpenForCurrentTab && sidePanelPosition === 'left'
@@ -671,6 +671,7 @@ export interface TerminalLayerProps {
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
onRequestAddToWorkspace?: (workspaceId: string) => void;
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
onUpdateSplitSizes: (workspaceId: string, splitId: string, sizes: number[]) => void;
onSetDraggingSessionId: (id: string | null) => void;
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
@@ -537,6 +537,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
onUpdateSessionDynamicTitle: s.onUpdateSessionDynamicTitle,
onUpdateSessionCodingCliProvider: s.onUpdateSessionCodingCliProvider,
onRequestAddToWorkspace: s.onRequestAddToWorkspace,
onAppendHostToWorkspace: s.onAppendHostToWorkspace,
onSetWorkspaceFocusedSession: s.onSetWorkspaceFocusedSession,
onStartSessionRename: s.onStartSessionRename,
onSubmitSessionRename: s.onSubmitSessionRename,
@@ -379,3 +379,26 @@ test("terminal layer focus sidebar re-renders when dynamic tab title mode change
false,
);
});
test("terminal layer focus sidebar re-renders when host-append handler changes", () => {
const baseCtx = {
isFocusMode: true,
activeWorkspace: workspace(),
focusedSessionId: "session-1",
resolvedPreviewTheme: {},
sessionHostsMap: new Map(),
sessions: [],
terminalSettings: { dynamicTabTitleMode: "agent" },
onAppendHostToWorkspace: undefined as
| ((workspaceId: string, hostId: string) => void)
| undefined,
};
assert.equal(
terminalLayerFocusSidebarPropsEqual(
baseCtx,
{ ...baseCtx, onAppendHostToWorkspace: () => {} },
),
false,
);
});
@@ -486,6 +486,7 @@ export function terminalLayerFocusSidebarPropsEqual(prev: Ctx, next: Ctx): boole
&& eq(prev, next, 't')
&& eq(prev, next, 'onReorderWorkspaceSessions')
&& eq(prev, next, 'onRequestAddToWorkspace')
&& eq(prev, next, 'onAppendHostToWorkspace')
&& eq(prev, next, 'handleCloseSession')
&& eq(prev, next, 'onCopySession')
&& eq(prev, next, 'onCopySessionToNewWindow')
+4
View File
@@ -886,6 +886,7 @@ interface WorkspaceTopTabProps {
shiftStyle: React.CSSProperties;
showDropIndicatorBefore: boolean;
showDropIndicatorAfter: boolean;
isHostDropTarget: boolean;
onTabDragStart: (e: React.DragEvent, tabId: string) => void;
onTabDragEnd: () => void;
onTabDragOver: (e: React.DragEvent, tabId: string) => void;
@@ -936,6 +937,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
shiftStyle,
showDropIndicatorBefore,
showDropIndicatorAfter,
isHostDropTarget,
onTabDragStart,
onTabDragEnd,
onTabDragOver,
@@ -969,6 +971,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
data-tab-id={workspace.id}
data-tab-type="workspace"
data-state={isActive ? 'active' : 'inactive'}
data-host-drop-active={isHostDropTarget ? 'true' : 'false'}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseDown={handleTabMiddleMouseDown}
@@ -983,6 +986,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
isHostDropTarget && "ring-1 ring-inset",
tabAnimationClass,
)}
style={{
+2 -1
View File
@@ -281,7 +281,8 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
const handleHostDragStart = React.useCallback((e: React.DragEvent, hostId: string) => {
e.dataTransfer.effectAllowed = "move";
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("host-id", hostId);
draggingHostIdRef.current = hostId;
setDraggingHostId(hostId);
+97
View File
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
appendHostFromWorkspaceDrop,
FOCUS_SIDEBAR_HOST_DRAG_TYPE,
FOCUS_SIDEBAR_SESSION_DRAG_TYPE,
readHostIdFromDataTransfer,
resolveFocusSidebarDragKind,
} from './focusSidebarHostDrop.ts';
test('resolveFocusSidebarDragKind prefers in-flight session reorder', () => {
assert.equal(
resolveFocusSidebarDragKind({
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
activeSessionDragId: 'session-1',
}),
'session-reorder',
);
});
test('resolveFocusSidebarDragKind detects session mime even without local state', () => {
assert.equal(
resolveFocusSidebarDragKind({
types: [FOCUS_SIDEBAR_SESSION_DRAG_TYPE],
activeSessionDragId: null,
}),
'session-reorder',
);
});
test('resolveFocusSidebarDragKind accepts vault or host-tree host drags', () => {
assert.equal(
resolveFocusSidebarDragKind({
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
activeSessionDragId: null,
}),
'host-append',
);
});
test('resolveFocusSidebarDragKind ignores unrelated drag payloads', () => {
assert.equal(
resolveFocusSidebarDragKind({
types: ['text/plain', 'tab-reorder-id'],
activeSessionDragId: null,
}),
null,
);
});
test('readHostIdFromDataTransfer returns trimmed host id', () => {
assert.equal(
readHostIdFromDataTransfer((type) => (type === FOCUS_SIDEBAR_HOST_DRAG_TYPE ? ' host-42 ' : '')),
'host-42',
);
});
test('readHostIdFromDataTransfer returns null when host id is missing', () => {
assert.equal(readHostIdFromDataTransfer(() => ''), null);
assert.equal(readHostIdFromDataTransfer(() => ' '), null);
});
test('appendHostFromWorkspaceDrop appends the dropped host to the target workspace once', () => {
const calls: Array<[string, string]> = [];
assert.equal(appendHostFromWorkspaceDrop({
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
getData: (type) => (type === FOCUS_SIDEBAR_HOST_DRAG_TYPE ? 'host-42' : ''),
workspaceId: 'workspace-7',
onAppendHostToWorkspace: (workspaceId, hostId) => calls.push([workspaceId, hostId]),
}), true);
assert.deepEqual(calls, [['workspace-7', 'host-42']]);
});
test('appendHostFromWorkspaceDrop leaves session and invalid drags alone', () => {
const calls: Array<[string, string]> = [];
const onAppendHostToWorkspace = (workspaceId: string, hostId: string) => {
calls.push([workspaceId, hostId]);
};
assert.equal(appendHostFromWorkspaceDrop({
types: [FOCUS_SIDEBAR_SESSION_DRAG_TYPE, FOCUS_SIDEBAR_HOST_DRAG_TYPE],
getData: (type) => (type === FOCUS_SIDEBAR_HOST_DRAG_TYPE ? 'host-42' : 'session-1'),
workspaceId: 'workspace-7',
onAppendHostToWorkspace,
}), false);
assert.equal(appendHostFromWorkspaceDrop({
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
getData: () => ' ',
workspaceId: 'workspace-7',
onAppendHostToWorkspace,
}), false);
assert.deepEqual(calls, []);
});
+55
View File
@@ -0,0 +1,55 @@
export const FOCUS_SIDEBAR_HOST_DRAG_TYPE = 'host-id';
export const FOCUS_SIDEBAR_SESSION_DRAG_TYPE = 'workspace-focus-session-id';
export type FocusSidebarDragKind = 'session-reorder' | 'host-append' | null;
export function dataTransferHasType(
types: ArrayLike<string> | readonly string[],
type: string,
): boolean {
return Array.from(types as ArrayLike<string>).includes(type);
}
/**
* Decide how the focus-mode workspace sidebar should treat an in-progress drag.
* Session reorder wins when this sidebar started the drag, so a host mime that
* happens to be present cannot steal reorder gestures.
*/
export function resolveFocusSidebarDragKind(input: {
types: ArrayLike<string> | readonly string[];
activeSessionDragId?: string | null;
}): FocusSidebarDragKind {
if (
input.activeSessionDragId
|| dataTransferHasType(input.types, FOCUS_SIDEBAR_SESSION_DRAG_TYPE)
) {
return 'session-reorder';
}
if (dataTransferHasType(input.types, FOCUS_SIDEBAR_HOST_DRAG_TYPE)) {
return 'host-append';
}
return null;
}
export function readHostIdFromDataTransfer(
getData: (type: string) => string,
): string | null {
const hostId = getData(FOCUS_SIDEBAR_HOST_DRAG_TYPE)?.trim();
return hostId || null;
}
export function appendHostFromWorkspaceDrop(input: {
types: ArrayLike<string> | readonly string[];
getData: (type: string) => string;
workspaceId: string;
activeSessionDragId?: string | null;
onAppendHostToWorkspace: (workspaceId: string, hostId: string) => void;
}): boolean {
if (resolveFocusSidebarDragKind(input) !== 'host-append') return false;
const hostId = readHostIdFromDataTransfer(input.getData);
if (!hostId) return false;
input.onAppendHostToWorkspace(input.workspaceId, hostId);
return true;
}
+34 -6
View File
@@ -117,23 +117,23 @@ test("listSftpConnectedHosts includes hosts with SFTP sudo for picker display",
assert.equal(result.length, 1);
assert.equal(result[0]?.sessionId, "s-sudo");
assert.equal(result[0]?.host.sftpSudo, true);
// Transfer/open must still refuse shell reuse for sudo.
// Sudo SFTP still opens a new subsystem/channel, but it should borrow the
// already-authenticated SSH transport before falling back to fresh auth.
assert.equal(
resolveSftpTransferSourceSessionId(sessions, hostsById, "a", result[0]?.host),
undefined,
"s-sudo",
);
// Picker/connect must not pass sessionId as a reuse hint for sudo hosts.
assert.equal(sftpSourceSessionIdForHost(result[0]?.host, result[0]?.sessionId), undefined);
assert.equal(sftpSourceSessionIdForHost(result[0]?.host, result[0]?.sessionId), "s-sudo");
});
test("sftpSourceSessionIdForHost keeps non-sudo reuse and drops sudo", () => {
test("sftpSourceSessionIdForHost keeps sudo and non-sudo reuse hints", () => {
assert.equal(
sftpSourceSessionIdForHost(host({ id: "a", label: "Alpha" }), "s1"),
"s1",
);
assert.equal(
sftpSourceSessionIdForHost(host({ id: "a", label: "Alpha", sftpSudo: true }), "s1"),
undefined,
"s1",
);
assert.equal(sftpSourceSessionIdForHost(undefined, "s1"), "s1");
assert.equal(sftpSourceSessionIdForHost(host({ id: "a", label: "Alpha" }), undefined), undefined);
@@ -313,6 +313,34 @@ test("resolveSftpTransferSourceSessionId matches among multi-tab same hostId end
assert.equal(resolveSftpTransferSourceSessionId(sessions, hostsById, "h1"), "s-new");
});
test("resolveSftpTransferSourceSessionId ignores SFTP browse connection ids", () => {
const vault = host({ id: "h1", label: "Prod", hostname: "prod.example.test", username: "alice", port: 22 });
const hostsById = new Map([["h1", vault]]);
const sessions = [
session({
id: "terminal-ssh-1",
hostId: "h1",
status: "connected",
hostname: "prod.example.test",
username: "alice",
port: 22,
}),
session({
id: "sftp-left-accidental-browse-id",
hostId: "h1",
status: "connected",
hostname: "prod.example.test",
username: "alice",
port: 22,
}),
];
assert.equal(
resolveSftpTransferSourceSessionId(sessions, hostsById, "h1", vault),
"terminal-ssh-1",
);
});
test("sftpPickerSessionsEqual ignores title-only changes", () => {
const prev = [session({ id: "s1", hostId: "a", status: "connected", dynamicTitle: "old" })];
const next = [session({ id: "s1", hostId: "a", status: "connected", dynamicTitle: "new" })];
+6 -8
View File
@@ -26,6 +26,7 @@ export type SftpPickerSessionFields = Pick<
* Connecting sessions and Mosh/ET transports have no reusable ssh2 shell conn.
*/
const isReusableSftpSourceSession = (session: SftpPickerSessionFields): boolean => {
if (session.id.startsWith("sftp-")) return false;
if (session.status !== "connected") return false;
if (session.moshEnabled || session.etEnabled) return false;
const protocol = session.protocol;
@@ -99,8 +100,8 @@ export const sftpPickerSessionsEqual = (
* One entry per hostId — keeps the most recently listed SSH terminal session.
*
* Includes hosts with sftpSudo: they still belong under "Connected" when a
* terminal is open, but connect paths must not reuse the shell (see
* resolveSftpTransferSourceSessionId and useSftpConnections sudo guards).
* terminal is open, and SFTP can borrow the already-authenticated SSH transport
* before silently falling back to a fresh connection.
*/
export const listSftpConnectedHosts = (
sessions: ReadonlyArray<SftpPickerSessionFields>,
@@ -131,15 +132,14 @@ export const listSftpConnectedHosts = (
};
/**
* Drop terminal-session reuse when the host requires a dedicated sudo SFTP
* channel. Used by the picker and browse connect path so UI state
* (`reusedConnection`) and the actual open agree.
* Return the live terminal session id that SFTP should try before fresh auth.
* The main process validates that the hinted session really matches the target.
*/
export const sftpSourceSessionIdForHost = (
host: Pick<Host, "sftpSudo"> | null | undefined,
sourceSessionId: string | undefined,
): string | undefined => {
if (!sourceSessionId || host?.sftpSudo) return undefined;
if (!sourceSessionId) return undefined;
return sourceSessionId;
};
@@ -160,7 +160,6 @@ export const resolveSftpTransferSourceSessionId = (
hostId: string,
host?: Pick<Host, "hostname" | "username" | "port" | "sftpSudo" | "protocol">,
): string | undefined => {
if (host?.sftpSudo) return undefined;
let lastMatch: string | undefined;
for (const session of sessions) {
if (session.hostId !== hostId) continue;
@@ -168,7 +167,6 @@ export const resolveSftpTransferSourceSessionId = (
const vaultHost = hostsById.get(session.hostId);
if (!vaultHost) continue;
if (vaultHost.protocol === "serial" || isPluginHostProtocol(vaultHost.protocol)) continue;
if (vaultHost.sftpSudo) continue;
const liveHost = hostForLiveSession(vaultHost, session);
if (host && !sftpHostEndpointsEqual(liveHost, host)) continue;
lastMatch = session.id;
+53
View File
@@ -28,6 +28,59 @@ test("aggregateMountedDiskUsage skips rclone/CloudDrive/ufs network FUSE capacit
);
});
test("aggregateMountedDiskUsage skips NFS/CIFS/SMB and typed mergerfs network mounts", () => {
assert.deepEqual(
aggregateMountedDiskUsage([
{ capacityKey: "/dev/sda1", filesystemType: "ext4", mountPoint: "/", used: 20, total: 100 },
{ capacityKey: "nas.local:/volume1/media", filesystemType: "nfs4", mountPoint: "/mnt/nas", used: 8000, total: 20000 },
{ capacityKey: "//nas.local/share", filesystemType: "cifs", mountPoint: "/mnt/smb", used: 4000, total: 10000 },
{ capacityKey: "mergerfs", filesystemType: "fuse.mergerfs", mountPoint: "/mnt/pool", used: 9000, total: 30000 },
{ capacityKey: "union", filesystemType: "mergerfs", mountPoint: "/mnt/union", used: 1000, total: 5000 },
{ capacityKey: "CloudFS", filesystemType: "fuse", mountPoint: "/mnt/CloudNAS/openlist", used: 0, total: 10000 },
{ capacityKey: "gluster", filesystemType: "fuse.glusterfs", mountPoint: "/mnt/gluster", used: 7000, total: 15000 },
{ capacityKey: "ceph-fuse", filesystemType: "fuse.ceph-fuse", mountPoint: "/mnt/ceph", used: 6000, total: 12000 },
{ capacityKey: "unionfs", filesystemType: "fuse.unionfs-fuse", mountPoint: "/mnt/unionfs", used: 2000, total: 8000 },
]),
{ used: 20, total: 100, percent: 20 },
);
});
test("aggregateMountedDiskUsage skips NFS-style sources when filesystem type is unavailable", () => {
assert.deepEqual(
aggregateMountedDiskUsage([
{ capacityKey: "/dev/sda1", filesystemType: "-", mountPoint: "/", used: 20, total: 100 },
{ capacityKey: "192.168.1.10:/export", filesystemType: "-", mountPoint: "/mnt/nfs", used: 5000, total: 10000 },
{ capacityKey: "[2001:db8::1]:/export", filesystemType: "-", mountPoint: "/mnt/nfs6", used: 3000, total: 9000 },
{ capacityKey: "[fe80::1%eth0]:/export", filesystemType: "-", mountPoint: "/mnt/nfs6-scoped", used: 3000, total: 9000 },
{ capacityKey: "//filer/backup", filesystemType: "-", mountPoint: "/mnt/cifs", used: 2000, total: 8000 },
{ capacityKey: "ceph-fuse", filesystemType: "-", mountPoint: "/mnt/ceph", used: 6000, total: 12000 },
{ capacityKey: "gluster", filesystemType: "-", mountPoint: "/mnt/gluster", used: 7000, total: 15000 },
]),
{ used: 20, total: 100, percent: 20 },
);
});
test("aggregateMountedDiskUsage keeps fuse-overlayfs and local loop roots", () => {
assert.deepEqual(
aggregateMountedDiskUsage([
{ capacityKey: "overlay", filesystemType: "fuse.fuse-overlayfs", mountPoint: "/", used: 4, total: 16 },
]),
{ used: 4, total: 16, percent: 25 },
);
assert.deepEqual(
aggregateMountedDiskUsage([
{ capacityKey: "overlayfs:/overlay", mountPoint: "/", used: 0.25, total: 1 },
]),
{ used: 0.25, total: 1, percent: 25 },
);
assert.deepEqual(
aggregateMountedDiskUsage([
{ capacityKey: "/dev/loop0", filesystemType: "ext4", mountPoint: "/", used: 3, total: 12 },
]),
{ used: 3, total: 12, percent: 25 },
);
});
test("aggregateMountedDiskUsage trusts a reported local filesystem type over its source name", () => {
assert.deepEqual(
aggregateMountedDiskUsage([
+37 -1
View File
@@ -21,6 +21,18 @@ export function isNetworkOrFuseCapacityKey(capacityKey: string | undefined): boo
if (lower === "/dev/fuse" || lower.endsWith("/fuse")) return true;
if (lower === "rclone" || lower.startsWith("rclone:")) return true;
if (lower.includes("clouddrive")) return true;
// CIFS/SMB (`//server/share`) and NFS (`host:/export` or `[ipv6]:/export`).
// Keep synthetic local keys such as APFS pools and overlay/overlayfs roots
// out of this heuristic.
if (lower.startsWith("//")) return true;
if (
!lower.startsWith("apfs:")
&& !lower.startsWith("overlay:")
&& !lower.startsWith("overlayfs:")
&& /^([a-z0-9._-]+|\[[0-9a-f:]+(?:%[a-z0-9._-]+)?\]):\//.test(lower)
) {
return true;
}
if (
lower === "sshfs"
|| lower === "s3fs"
@@ -28,6 +40,11 @@ export function isNetworkOrFuseCapacityKey(capacityKey: string | undefined): boo
|| lower === "mergerfs"
|| lower === "unionfs"
|| lower === "unionfs-fuse"
|| lower === "ceph"
|| lower === "ceph-fuse"
|| lower === "cephfs"
|| lower === "gluster"
|| lower === "glusterfs"
|| lower === "ufs"
) {
return true;
@@ -39,12 +56,31 @@ export function isNetworkOrFuseFilesystemType(filesystemType: string | undefined
const type = filesystemType?.trim().toLowerCase();
if (!type) return false;
if (type.includes("clouddrive")) return true;
if (/^fuse\.(rclone|sshfs|s3fs|gcsfuse|ufs)$/.test(type)) return true;
if (
/^fuse\.(rclone|sshfs|s3fs|gcsfuse|ufs|mergerfs|unionfs|unionfs-fuse|ceph|ceph-fuse|cephfs|glusterfs)$/
.test(type)
) {
return true;
}
return [
"fuse",
"rclone",
"sshfs",
"s3fs",
"gcsfuse",
"mergerfs",
"unionfs",
"unionfs-fuse",
"nfs",
"nfs4",
"cifs",
"smb",
"smb3",
"smbfs",
"afs",
"ceph",
"cephfs",
"glusterfs",
].includes(type);
}
@@ -27,6 +27,16 @@ function registerAgentProcessHandlers(ctx) {
return { ok: true, count: list.length };
});
// App-owned live session state is independent of the optional External MCP
// surface. It lets host_open-owned chat scopes observe connection changes
// even when the opened terminal never mounts its own AI side panel.
ipcMain.handle("netcatty:ai:mcp:update-live-sessions", async (event, { sessions: sessionList }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
return mcpServerBridge.updateLiveSessionMetadata(
Array.isArray(sessionList) ? sessionList : [],
);
});
// Merge (do not replace) session metadata into a chat scope. Used when agents
// open a host mid-turn so terminal tools can target the new sessionId
// without waiting for the next full scope push.
+2 -1
View File
@@ -113,6 +113,7 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
const openingTimeoutMs = Math.max(1, Number(options.openingTimeoutMs) || DEFAULT_SSH_EXEC_OPEN_TIMEOUT_MS);
const runTimeoutMs = Math.max(1, Number(options.runTimeoutMs) || DEFAULT_SSH_EXEC_RUN_TIMEOUT_MS);
const maxOutputBytes = Math.max(1, Number(options.maxOutputBytes) || DEFAULT_SSH_EXEC_MAX_OUTPUT_BYTES);
const invalidateOnOpenTimeout = options.invalidateOnOpenTimeout !== false;
return new Promise((resolve, reject) => {
let settled = false;
@@ -184,7 +185,7 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
openingTimer = setTimeoutFn(() => {
const error = new Error(`SSH exec channel open timed out after ${openingTimeoutMs} ms`);
error.code = "SSH_EXEC_OPEN_TIMEOUT";
finish(error, null, { terminate: true, invalidateTransport: true });
finish(error, null, { terminate: true, invalidateTransport: invalidateOnOpenTimeout });
}, openingTimeoutMs);
try {
+24
View File
@@ -64,6 +64,30 @@ test("bounded SSH exec times out while opening and terminates a late stream", as
assert.equal(timers.active.size, 0);
});
test("best-effort SSH exec open timeout can preserve a shared transport", async () => {
let callback;
let endCalls = 0;
let destroyCalls = 0;
const sshClient = {
exec(_command, next) { callback = next; },
end() { endCalls += 1; },
destroy() { destroyCalls += 1; },
};
await assert.rejects(
executeBoundedSshCommand(sshClient, "true", {
openingTimeoutMs: 2,
invalidateOnOpenTimeout: false,
}),
(error) => error.code === "SSH_EXEC_OPEN_TIMEOUT",
);
assert.equal(endCalls, 0);
assert.equal(destroyCalls, 0);
const stream = createStream();
callback(null, stream);
assert.ok(stream.closed > 0 || stream.destroyed > 0);
});
test("bounded raw exec stream preserves options and invalidates a hung open", async () => {
let callback;
let receivedOptions;
+223 -21
View File
@@ -94,8 +94,14 @@ function disconnectExternalMcpClients() {
// Per-scope metadata: chatSessionId → { sessionIds: string[], metadata: Map<sessionId, meta> }
// Each chat session only sees the hosts registered for its scope.
const scopedMetadata = new Map();
let liveSessionMetadata = new Map();
let sessionMetadataRevision = 0;
let terminalSessionCloseRevision = 0;
const closedTerminalSessionRevisions = new Map();
const MAX_CLOSED_TERMINAL_SESSION_REVISIONS = 2048;
const scopedAttachments = new Map(); // chatSessionId -> Map<filePath, attachment>
const { createSessionOwnershipRegistry } = require("./mcpServerBridge/sessionOwnership.cjs");
const { retainOwnedSessions, mergeRetentionMeta } = require("./mcpServerBridge/retainOwnedSessions.cjs");
const {
createSessionIdleManager,
normalizeSessionIdleTimeoutMinutes,
@@ -368,6 +374,8 @@ const {
releaseSessionExecution,
} = backgroundJobApi;
let disposeWorkerSessionClosed = null;
function init(deps) {
sessions = deps.sessions;
terminalWorkerManager = deps.terminalWorkerManager || null;
@@ -378,6 +386,23 @@ function init(deps) {
if (deps.commandBlocklist) {
commandBlocklist = deps.commandBlocklist;
}
try { disposeWorkerSessionClosed?.dispose?.(); } catch { /* ignore */ }
disposeWorkerSessionClosed = null;
// Only explicit UI/tab closes drop host_open ownership. Shell "exited"
// events (including missing/nonzero exitCode) and recoverable exits
// (error / worker-exit / superseded / quiet transport "closed") keep
// ownership so a disconnected tab can reconnect with the same session id.
// Auto-close clean exits still forget ownership when the renderer closes
// the tab and the worker emits explicit:true.
if (typeof terminalWorkerManager?.onSessionClosed === "function") {
disposeWorkerSessionClosed = terminalWorkerManager.onSessionClosed((event) => {
const sessionId = event?.sessionId;
if (!sessionId) return;
if (event?.explicit === true) {
forgetClosedTerminalSession(sessionId);
}
});
}
}
async function listActivePortForwards() {
@@ -441,6 +466,7 @@ function shutdownHost({ preserveScopedMetadata = false } = {}) {
if (!preserveScopedMetadata) {
scopedMetadata.clear();
scopedAttachments.clear();
liveSessionMetadata.clear();
}
for (const [, job] of backgroundJobs) {
try {
@@ -598,12 +624,50 @@ function syncLiveSessionsToExternalScope(chatSessionId = EXTERNAL_MCP_CHAT_SESSI
return { ok: true, count: sessionList.length, chatSessionId };
}
function findSessionMetaAcrossScopes(sessionId) {
for (const scoped of scopedMetadata.values()) {
function findSessionMetaAcrossScopes(sessionId, excludeChatSessionId = null) {
let latest = liveSessionMetadata.get(sessionId) || null;
let latestRevision = Number.isSafeInteger(latest?._revision) ? latest._revision : -1;
for (const [scopeId, scoped] of scopedMetadata.entries()) {
if (excludeChatSessionId && scopeId === excludeChatSessionId) continue;
const meta = scoped?.metadata?.get?.(sessionId);
if (meta) return meta;
if (!meta) continue;
const revision = Number.isSafeInteger(meta._revision) ? meta._revision : 0;
if (revision > latestRevision) {
latest = meta;
latestRevision = revision;
}
}
return null;
return latest;
}
/**
* App-owned live session snapshot. Unlike the External MCP scope, this is
* always refreshed by the main renderer and is never exposed as a scope of
* its own. It only supplies current state for session ids a chat already owns.
*/
function updateLiveSessionMetadata(sessionList) {
const incoming = Array.isArray(sessionList) ? sessionList : [];
const updateRevision = ++sessionMetadataRevision;
const next = new Map();
for (const entry of incoming) {
if (!entry || typeof entry !== "object" || !entry.sessionId) continue;
next.set(entry.sessionId, {
hostname: entry.hostname || "",
label: entry.label || "",
os: entry.os || "",
username: entry.username || "",
protocol: entry.protocol || "",
shellType: entry.shellType || "",
deviceType: entry.deviceType || "",
connected: entry.connected !== false,
hostId: entry.hostId || "",
hostChain: Array.isArray(entry.hostChain) ? entry.hostChain : [],
activePortForwards: Array.isArray(entry.activePortForwards) ? entry.activePortForwards : [],
_revision: updateRevision,
});
}
liveSessionMetadata = next;
return { ok: true, count: next.size };
}
function seedExternalScopeFromOtherScopes(chatSessionId) {
@@ -612,7 +676,10 @@ function seedExternalScopeFromOtherScopes(chatSessionId) {
if (scopeId === chatSessionId || !scoped?.metadata) continue;
for (const [sessionId, meta] of scoped.metadata.entries()) {
if (!sessionId || !meta) continue;
if (!byId.has(sessionId)) {
const existing = byId.get(sessionId);
const existingRevision = Number.isSafeInteger(existing?._revision) ? existing._revision : 0;
const revision = Number.isSafeInteger(meta._revision) ? meta._revision : 0;
if (!existing || revision > existingRevision) {
byId.set(sessionId, { sessionId, ...meta });
}
}
@@ -680,14 +747,34 @@ function beginChatExecution(chatSessionId, sessionId, command) {
* @param {string} [chatSessionId] - AI chat session ID for per-scope isolation
*/
function updateSessionMetadata(sessionList, chatSessionId) {
const incoming = Array.isArray(sessionList) ? sessionList : [];
const updateRevision = ++sessionMetadataRevision;
// Authoritative empty replace: clear retention ownership for this scope so a
// later non-empty sync cannot resurrect sessions via cross-scope fallback.
if (chatSessionId && incoming.length === 0) {
openedSessionOwnership.releaseScopeOwnership(chatSessionId);
}
// host_open merges the new session into this chat scope, but the AI side
// panel later pushes a full replace of only the currently focused tab —
// which would drop mid-turn opened sessions. Retain ownership-tracked ids.
const effectiveList = chatSessionId
? retainOwnedSessions({
incomingSessions: incoming,
ownedSessionIds: openedSessionOwnership.listOwned(chatSessionId),
previousById: scopedMetadata.get(chatSessionId)?.metadata || null,
// Skip the current scope so a stale connected:false snapshot cannot
// shadow a fresher copy from External MCP / another chat tab.
findFallbackMeta: (sessionId) => findSessionMetaAcrossScopes(sessionId, chatSessionId),
})
: incoming;
debugLog("updateSessionMetadata", {
chatSessionId,
count: Array.isArray(sessionList) ? sessionList.length : 0,
sessionIds: Array.isArray(sessionList) ? sessionList.map(s => s.sessionId) : [],
count: effectiveList.length,
sessionIds: effectiveList.map(s => s.sessionId),
});
const ids = sessionList.map(s => s.sessionId);
const ids = effectiveList.map(s => s.sessionId);
const metaMap = new Map();
for (const s of sessionList) {
for (const s of effectiveList) {
metaMap.set(s.sessionId, {
hostname: s.hostname || "",
label: s.label || "",
@@ -700,6 +787,7 @@ function updateSessionMetadata(sessionList, chatSessionId) {
hostId: s.hostId || "",
hostChain: Array.isArray(s.hostChain) ? s.hostChain : [],
activePortForwards: Array.isArray(s.activePortForwards) ? s.activePortForwards : [],
_revision: Number.isSafeInteger(s._revision) ? s._revision : updateRevision,
});
}
@@ -919,11 +1007,92 @@ function resolveScopedSessionIds(chatSessionId, explicitScopedIds = null) {
/**
* Look up metadata for a sessionId, scoped to a specific chat session.
* Falls back to session object properties if no scoped metadata is found.
* When this scope still has a stale connected:false snapshot (common after
* host_open + tab switch), refresh connected from a fresher cross-scope copy.
* Owned host_open sessions also refresh while still connected so another
* scope's empty activePortForwards reaches getContext without a later sidebar
* replace.
*/
function getSessionMeta(sessionId, chatSessionId) {
if (!chatSessionId) return null;
const scoped = scopedMetadata.get(chatSessionId);
return scoped?.metadata?.get(sessionId) || null;
const meta = scoped?.metadata?.get(sessionId) || null;
if (!meta) return null;
const owned = openedSessionOwnership.validate(chatSessionId, sessionId).ok;
// Live non-owned snapshots stay local. Owned sessions (and stale
// connected:false copies) consult other scopes.
if (!owned && meta.connected !== false) return toPublicSessionMeta(meta);
const fresher = findSessionMetaAcrossScopes(sessionId, chatSessionId);
if (!fresher) return toPublicSessionMeta(meta);
if (!owned && fresher.connected === false) return toPublicSessionMeta(meta);
const refreshed = mergeRetentionMeta(meta, fresher);
if (!refreshed) return toPublicSessionMeta(meta);
scoped.metadata.set(sessionId, refreshed);
return toPublicSessionMeta(refreshed);
}
function toPublicSessionMeta(meta) {
if (!meta || typeof meta !== "object") return meta;
const { _revision, ...publicMeta } = meta;
return publicMeta;
}
function buildOpenedSessionMeta(result, sessionId) {
const host = result?.host && typeof result.host === "object" ? result.host : {};
return {
sessionId,
hostname: host.hostname || "",
label: host.label || host.hostname || sessionId,
os: host.os || "",
username: host.username || "",
protocol: result?.protocol || host.protocol || "",
shellType: "",
deviceType: host.deviceType || "",
connected: result?.status === "connected",
hostId: result?.hostId || host.id || "",
hostChain: [],
activePortForwards: [],
};
}
function ensureOpenedSessionMetadata(chatSessionId, sessionId, result) {
if (!chatSessionId || !sessionId) return;
if (scopedMetadata.get(chatSessionId)?.metadata?.has(sessionId)) return;
const latest = findSessionMetaAcrossScopes(sessionId, chatSessionId);
mergeSessionMetadata([
latest ? { sessionId, ...latest } : buildOpenedSessionMeta(result, sessionId),
], chatSessionId);
}
/**
* Drop host_open ownership and scoped metadata after a terminal session is
* actually closed (explicit UI tab close or agent session_close).
*/
function forgetClosedTerminalSession(sessionId) {
if (!sessionId) return;
terminalSessionCloseRevision += 1;
closedTerminalSessionRevisions.delete(sessionId);
closedTerminalSessionRevisions.set(sessionId, terminalSessionCloseRevision);
while (closedTerminalSessionRevisions.size > MAX_CLOSED_TERMINAL_SESSION_REVISIONS) {
closedTerminalSessionRevisions.delete(closedTerminalSessionRevisions.keys().next().value);
}
sessionIdleManager.forgetSession(sessionId);
openedSessionOwnership.forgetSession(sessionId);
liveSessionMetadata.delete(sessionId);
for (const scoped of scopedMetadata.values()) {
scoped.sessionIds = scoped.sessionIds.filter((id) => id !== sessionId);
scoped.metadata.delete(sessionId);
}
}
function forgetUnownedTerminalSessionMetadata(sessionId) {
if (!sessionId) return;
openedSessionOwnership.forgetSession(sessionId);
liveSessionMetadata.delete(sessionId);
for (const scoped of scopedMetadata.values()) {
scoped.sessionIds = scoped.sessionIds.filter((id) => id !== sessionId);
scoped.metadata.delete(sessionId);
}
}
/**
@@ -1192,6 +1361,10 @@ const sessionIdleManager = createSessionIdleManager({
function reportOpenedSessionActivity(event = {}) {
const sessionId = event?.sessionId;
if (!sessionId) return false;
if (event.phase === "closed") {
forgetClosedTerminalSession(sessionId);
return true;
}
if (event.phase === "begin") {
return sessionIdleManager.beginActivity(null, sessionId);
}
@@ -1231,20 +1404,14 @@ sessionService = createSessionService({
endTerminalSessionClose(params.sessionId);
if (outcome.closed) return;
if (outcome.notFound) {
sessionIdleManager.forgetSession(params.sessionId);
openedSessionOwnership.forgetSession(params.sessionId);
forgetClosedTerminalSession(params.sessionId);
return;
}
sessionIdleManager.resume(params.sessionId);
},
onClosed: async (sessionId) => {
await settleBackgroundJobsForTerminalSession(sessionId);
sessionIdleManager.forgetSession(sessionId);
openedSessionOwnership.forgetSession(sessionId);
for (const scoped of scopedMetadata.values()) {
scoped.sessionIds = scoped.sessionIds.filter((id) => id !== sessionId);
scoped.metadata.delete(sessionId);
}
forgetClosedTerminalSession(sessionId);
},
});
@@ -1267,9 +1434,40 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
USER_DENIED_MESSAGE,
listPortForwards: () => listActivePortForwards(),
sessionService,
captureHostOpenScope: (chatSessionId) => openedSessionOwnership.captureGeneration(chatSessionId),
onHostOpened: (chatSessionId, sessionId, generation) => {
openedSessionOwnership.register(chatSessionId, sessionId, generation);
captureHostOpenScope: (chatSessionId) => ({
ownershipGeneration: openedSessionOwnership.captureGeneration(chatSessionId),
closeRevision: terminalSessionCloseRevision,
}),
onHostOpened: async (chatSessionId, sessionId, generation, result) => {
// The renderer creates the tab before replying to host_open. If the user
// explicitly closes it while that reply is still in flight, the eventual
// result must not revive ownership or scope metadata for the closed tab.
if ((closedTerminalSessionRevisions.get(sessionId) || 0) > generation?.closeRevision) return;
if (!openedSessionOwnership.register(
chatSessionId,
sessionId,
generation?.ownershipGeneration ?? generation,
)) {
// The chat may have been deleted while the renderer was creating the
// terminal. The generation revoke correctly rejects ownership, but the
// newly created backend session must also be reclaimed or it becomes an
// unowned session that no agent can close.
// host_open mints a fresh tab/session id. A reconnect with that id is the
// same logical tab and must also be reclaimed after its initiating chat
// was deleted; unrelated sessions do not reuse the minted id.
sessionIdleManager.track(chatSessionId, sessionId);
await sessionService.closeTracked({ chatSessionId, sessionId });
// A failed close stays idle-tracked so the normal bounded retry can try
// again. Remove only scope/live metadata here so a deleted chat cannot be
// recreated by the renderer's late merge.
forgetUnownedTerminalSessionMetadata(sessionId);
return;
}
closedTerminalSessionRevisions.delete(sessionId);
// A full empty scope replace can land after the renderer's best-effort
// host_open merge but before this async operation completes. Once the open
// succeeds, restore the returned session atomically with its ownership.
ensureOpenedSessionMetadata(chatSessionId, sessionId, result);
sessionIdleManager.track(chatSessionId, sessionId);
},
});
@@ -1992,6 +2190,8 @@ const configAndCleanupApi = createConfigAndCleanupApi({
const { resolveMcpServerRuntimeCommand, buildMcpServerConfig, cleanupScopedMetadata } = configAndCleanupApi;
function cleanup() {
try { disposeWorkerSessionClosed?.dispose?.(); } catch { /* ignore */ }
disposeWorkerSessionClosed = null;
shutdownHost();
}
@@ -2013,6 +2213,7 @@ module.exports = {
applyChatSessionCancelled,
checkCommandSafety,
updateSessionMetadata,
updateLiveSessionMetadata,
mergeSessionMetadata,
updateAttachmentMetadata,
handleListAttachments,
@@ -2029,6 +2230,7 @@ module.exports = {
hasActiveWorkerJobForTerminalSession,
cancelSftpOpsForSession,
getSessionMeta,
forgetClosedTerminalSession,
cleanupScopedMetadata,
cleanup,
shutdownHost,
@@ -0,0 +1,816 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
function loadFreshBridge() {
const bridgePath = require.resolve("./mcpServerBridge.cjs");
delete require.cache[bridgePath];
// Also clear retain/ownership deps so a fresh bridge gets fresh module state.
for (const key of Object.keys(require.cache)) {
if (key.includes(`${require("node:path").sep}mcpServerBridge${require("node:path").sep}`)) {
delete require.cache[key];
}
}
return require("./mcpServerBridge.cjs");
}
test("sidebar scope replace keeps host_open-owned sessions in the chat scope", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({
sessions: new Map(),
electronModule: null,
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async (op) => {
if (op === "host.open") {
return { ok: true, sessionId: "sess-opened", hostId: "host-b", status: "connecting" };
}
return { ok: false, error: `unexpected op ${op}` };
});
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
protocol: "ssh",
},
], "chat-1");
const opened = await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
assert.equal(opened.ok, true);
assert.equal(opened.sessionId, "sess-opened");
// AIChatSidePanel-style full replace of only the focused tab.
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.deepEqual(
bridge.getScopedSessionIds("chat-1").sort(),
["sess-opened", "sess-original"],
);
});
test("authoritative empty scope replace still clears host_open-owned sessions", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({
sessions: new Map(),
electronModule: null,
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
bridge.updateSessionMetadata([
{ sessionId: "sess-opened", hostname: "10.0.0.2", label: "server-b", connected: false },
], "chat-1");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
bridge.updateSessionMetadata([], "chat-1");
assert.deepEqual(bridge.getScopedSessionIds("chat-1"), []);
// A later non-empty sync must not resurrect the cleared owned session via
// cross-scope fallback / stale ownership.
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
},
], "__external_mcp__");
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.deepEqual(bridge.getScopedSessionIds("chat-1"), ["sess-original"]);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1"), null);
});
test("retained host_open metadata refreshes connected from another scope", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({
sessions: new Map(),
electronModule: null,
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
protocol: "ssh",
},
], "chat-1");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
// Another surface (External MCP / opened tab) learns the session connected.
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
protocol: "ssh",
username: "root",
},
], "__external_mcp__");
// Original chat sidebar still pushes only the focused tab.
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1")?.connected, true);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1")?.username, "root");
});
test("retained host_open metadata accepts a later disconnect from another scope", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({
sessions: new Map(),
electronModule: null,
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
protocol: "ssh",
},
], "chat-1");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
// The focused tab moved away; External MCP later learns the host dropped.
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
protocol: "ssh",
},
], "__external_mcp__");
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1")?.connected, false);
});
test("getSessionMeta clears stopped port forwards from another scope while owned session stays connected", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({
sessions: new Map(),
electronModule: null,
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
const forwards = [{ ruleId: "fwd-1", localPort: 8080, status: "active" }];
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
protocol: "ssh",
activePortForwards: forwards,
},
], "chat-1");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
// Another surface reports the session still up, but forwards already stopped.
// chat-1 does not get another metadata replace (focused tab stayed elsewhere).
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
protocol: "ssh",
activePortForwards: [],
},
], "__external_mcp__");
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1")?.connected, true);
assert.deepEqual(bridge.getSessionMeta("sess-opened", "chat-1")?.activePortForwards, []);
});
test("getSessionMeta does not let another scope clear forwards on a live non-owned session", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({
sessions: new Map(),
electronModule: null,
});
const forwards = [{ ruleId: "fwd-1", localPort: 8080, status: "active" }];
bridge.updateSessionMetadata([
{
sessionId: "sess-live",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
activePortForwards: forwards,
},
], "chat-1");
bridge.updateSessionMetadata([
{
sessionId: "sess-live",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
activePortForwards: [],
},
], "__external_mcp__");
assert.deepEqual(
bridge.getSessionMeta("sess-live", "chat-1")?.activePortForwards,
forwards,
);
});
test("ordinary tab close forgets host_open ownership so sidebar sync cannot revive ghosts", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
const closedListeners = new Set();
bridge.init({
sessions: new Map(),
electronModule: null,
terminalWorkerManager: {
onSessionClosed(listener) {
closedListeners.add(listener);
return {
dispose: () => closedListeners.delete(listener),
};
},
},
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
},
], "chat-1");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
// User closes the host_open tab through the normal UI / worker path.
for (const listener of closedListeners) {
listener({ sessionId: "sess-opened", reason: "closed", explicit: true });
}
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.deepEqual(bridge.getScopedSessionIds("chat-1"), ["sess-original"]);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1"), null);
});
test("recoverable worker exits keep host_open ownership for reconnect", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
const closedListeners = new Set();
bridge.init({
sessions: new Map(),
electronModule: null,
terminalWorkerManager: {
onSessionClosed(listener) {
closedListeners.add(listener);
return {
dispose: () => closedListeners.delete(listener),
};
},
},
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
},
], "chat-1");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
for (const event of [
{ reason: "error" },
{ reason: "worker-exit" },
{ reason: "superseded" },
{ reason: "closed" },
// Shell exits that leave the tab for reconnect (missing/nonzero exitCode).
{ reason: "exited" },
{ reason: "exited", exitCode: 1 },
]) {
for (const listener of closedListeners) {
listener({ sessionId: "sess-opened", ...event });
}
}
// After reconnect, sidebar may push only the focused tab; ownership must
// still retain the host_open session.
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.deepEqual(
bridge.getScopedSessionIds("chat-1").sort(),
["sess-opened", "sess-original"],
);
});
test("clean shell exit keeps ownership until the tab is explicitly closed", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
const closedListeners = new Set();
bridge.init({
sessions: new Map(),
electronModule: null,
terminalWorkerManager: {
onSessionClosed(listener) {
closedListeners.add(listener);
return {
dispose: () => closedListeners.delete(listener),
};
},
},
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
},
], "chat-1");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
// Worker reports a clean exit; auto-close may still leave the tab briefly,
// and disabled auto-close keeps it for reconnect — ownership stays.
for (const listener of closedListeners) {
listener({ sessionId: "sess-opened", reason: "exited", exitCode: 0 });
}
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.deepEqual(
bridge.getScopedSessionIds("chat-1").sort(),
["sess-opened", "sess-original"],
);
// Renderer tab close is the authoritative ownership drop.
for (const listener of closedListeners) {
listener({ sessionId: "sess-opened", reason: "closed", explicit: true });
}
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.deepEqual(bridge.getScopedSessionIds("chat-1"), ["sess-original"]);
});
test("latest metadata revision wins across multiple chat and external scopes", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: new Map(), electronModule: null });
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
bridge.updateSessionMetadata([{
sessionId: "sess-opened",
connected: true,
activePortForwards: [{ ruleId: "old-forward" }],
}], "chat-stale");
bridge.updateSessionMetadata([{
sessionId: "sess-opened",
connected: true,
activePortForwards: [],
}], "chat-owner");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-owner",
hostId: "host-b",
});
bridge.updateSessionMetadata([{
sessionId: "sess-opened",
connected: false,
activePortForwards: [],
}], "__external_mcp__");
assert.equal(bridge.getSessionMeta("sess-opened", "chat-owner")?.connected, false);
assert.deepEqual(bridge.getSessionMeta("sess-opened", "chat-owner")?.activePortForwards, []);
bridge.updateSessionMetadata([{
sessionId: "sess-opened",
connected: true,
username: "reconnected-user",
activePortForwards: [{ ruleId: "new-forward" }],
}], "chat-live");
assert.equal(bridge.getSessionMeta("sess-opened", "chat-owner")?.connected, true);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-owner")?.username, "reconnected-user");
assert.deepEqual(
bridge.getSessionMeta("sess-opened", "chat-owner")?.activePortForwards,
[{ ruleId: "new-forward" }],
);
});
test("host_open finishing after an empty scope replace restores its returned session", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: new Map(), electronModule: null });
bridge.setPermissionMode("auto");
let finishOpen;
bridge.setVaultAgentInvoker(() => new Promise((resolve) => {
finishOpen = resolve;
}));
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-race");
const opening = bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-race",
hostId: "host-b",
});
bridge.updateSessionMetadata([], "chat-race");
finishOpen({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
protocol: "ssh",
host: { id: "host-b", hostname: "10.0.0.2", label: "server-b", username: "root" },
});
const opened = await opening;
assert.equal(opened.ok, true);
assert.deepEqual(bridge.getScopedSessionIds("chat-race"), ["sess-opened"]);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-race")?.label, "server-b");
assert.equal(bridge.getSessionMeta("sess-opened", "chat-race")?.connected, false);
});
test("initial external scope seed selects the latest session snapshot", (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: new Map(), electronModule: null });
bridge.updateSessionMetadata([{
sessionId: "sess-shared",
connected: true,
username: "old-user",
}], "chat-old");
bridge.updateSessionMetadata([{
sessionId: "sess-shared",
connected: false,
username: "new-user",
}], "chat-new");
const seeded = bridge.syncLiveSessionsToExternalScope();
assert.equal(seeded.seeded, true);
assert.equal(bridge.getSessionMeta("sess-shared", "__external_mcp__")?.connected, false);
assert.equal(bridge.getSessionMeta("sess-shared", "__external_mcp__")?.username, "new-user");
});
test("explicit close before host_open returns cannot revive the closed session", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: new Map(), electronModule: null });
bridge.setPermissionMode("auto");
let finishOpen;
bridge.setVaultAgentInvoker(() => new Promise((resolve) => {
finishOpen = resolve;
}));
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-close-race");
const opening = bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-close-race",
hostId: "host-b",
});
// Renderer-side host.open creates and merges the tab before returning its
// result to the main process. Simulate the user closing that tab immediately.
bridge.mergeSessionMetadata([{
sessionId: "sess-opened",
hostId: "host-b",
connected: false,
}], "chat-close-race");
bridge.forgetClosedTerminalSession("sess-opened");
finishOpen({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
protocol: "ssh",
});
assert.equal((await opening).ok, true);
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-close-race");
assert.deepEqual(bridge.getScopedSessionIds("chat-close-race"), ["sess-original"]);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-close-race"), null);
});
test("app live sync makes host_open session usable without External MCP or a second AI panel", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
const requests = [];
bridge.init({
sessions: new Map(),
electronModule: null,
terminalWorkerManager: {
request(channel, payload, options) {
requests.push({ channel, payload, options });
if (channel === "netcatty:portforward:list") return Promise.resolve([]);
if (channel === "netcatty:ai:exec") {
return Promise.resolve({ ok: true, stdout: "ready\n", stderr: "", exitCode: 0 });
}
throw new Error(`unexpected worker request: ${channel}`);
},
},
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
protocol: "ssh",
host: { id: "host-b", hostname: "10.0.0.2", label: "server-b", username: "root" },
}));
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-owner");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-owner",
hostId: "host-b",
});
// The app-level session state observes the connection. No External MCP scope
// and no AI panel for the opened tab are involved.
bridge.updateLiveSessionMetadata([{
sessionId: "sess-opened",
hostId: "host-b",
hostname: "10.0.0.2",
label: "server-b",
username: "root",
protocol: "ssh",
connected: true,
}]);
// Returning to the original tab replaces its sidebar payload with only A.
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-owner");
assert.deepEqual(
bridge.getScopedSessionIds("chat-owner").sort(),
["sess-opened", "sess-original"],
);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-owner")?.connected, true);
const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", {
chatSessionId: "chat-owner",
});
const openedHost = environment.hosts.find((host) => host.sessionId === "sess-opened");
assert.equal(openedHost?.connected, true);
const executed = await bridge.dispatchBuiltinRpc("netcatty/exec", {
chatSessionId: "chat-owner",
sessionId: "sess-opened",
command: "pwd",
});
assert.deepEqual(executed, { ok: true, stdout: "ready\n", stderr: "", exitCode: 0 });
assert.equal(requests.some((entry) => entry.channel === "netcatty:ai:exec"), true);
});
test("chat cleanup during host_open closes the late-created unowned session", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: new Map(), electronModule: null });
bridge.setPermissionMode("auto");
let finishOpen;
const calls = [];
bridge.setVaultAgentInvoker((op, params) => {
calls.push({ op, params });
if (op === "host.open") {
return new Promise((resolve) => {
finishOpen = resolve;
});
}
if (op === "session.close") return Promise.resolve({ ok: true, sessionId: params.sessionId });
throw new Error(`unexpected op ${op}`);
});
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-deleted");
const opening = bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-deleted",
hostId: "host-b",
});
await bridge.cleanupScopedMetadata("chat-deleted");
// Renderer host.open can still finish its own merge after chat cleanup.
bridge.mergeSessionMetadata([{
sessionId: "sess-opened",
hostId: "host-b",
connected: false,
}], "chat-deleted");
finishOpen({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
protocol: "ssh",
});
assert.equal((await opening).ok, true);
assert.deepEqual(calls.map(({ op }) => op), ["host.open", "session.close"]);
assert.deepEqual(bridge.getScopedSessionIds("chat-deleted"), []);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-deleted"), null);
});
test("failed compensating close keeps the late-created session tracked for retry", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: new Map(), electronModule: null });
bridge.setPermissionMode("auto");
let finishOpen;
bridge.setVaultAgentInvoker((op) => {
if (op === "host.open") {
return new Promise((resolve) => {
finishOpen = resolve;
});
}
if (op === "session.close") {
return Promise.resolve({ ok: false, error: "renderer unavailable" });
}
throw new Error(`unexpected op ${op}`);
});
const opening = bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-deleted",
hostId: "host-b",
});
await bridge.cleanupScopedMetadata("chat-deleted");
bridge.mergeSessionMetadata([{
sessionId: "sess-opened",
hostId: "host-b",
connected: false,
}], "chat-deleted");
finishOpen({ ok: true, sessionId: "sess-opened", hostId: "host-b", status: "connecting" });
assert.equal((await opening).ok, true);
assert.deepEqual(bridge.getScopedSessionIds("chat-deleted"), []);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-deleted"), null);
assert.equal(
bridge.reportOpenedSessionActivity({ sessionId: "sess-opened", phase: "touch" }),
true,
);
});
@@ -166,7 +166,7 @@ function createCapabilityRpcDispatcher(deps) {
: null;
const result = await handler(params);
if (capability.id === "vault.host.open" && result?.ok !== false && result?.sessionId) {
deps.onHostOpened?.(params?.chatSessionId, result.sessionId, hostOpenGeneration);
await deps.onHostOpened?.(params?.chatSessionId, result.sessionId, hostOpenGeneration, result);
}
return result;
};
@@ -169,15 +169,20 @@ test("dispatchCapabilityRpc preserves the host-open scope generation across the
assert.equal(chatSessionId, "chat-1");
return 7;
},
onHostOpened: (chatSessionId, sessionId, generation) => {
registrations.push({ chatSessionId, sessionId, generation });
onHostOpened: (chatSessionId, sessionId, generation, result) => {
registrations.push({ chatSessionId, sessionId, generation, result });
},
invokeVaultAgent: async () => ({ ok: true, sessionId: "session-1" }),
});
await dispatch("public/vault/hosts/open", { chatSessionId: "chat-1", hostId: "host-1" });
assert.deepEqual(registrations, [{ chatSessionId: "chat-1", sessionId: "session-1", generation: 7 }]);
assert.deepEqual(registrations, [{
chatSessionId: "chat-1",
sessionId: "session-1",
generation: 7,
result: { ok: true, sessionId: "session-1" },
}]);
});
test("dispatchCapabilityRpc routes vault hosts create to vault service", async () => {
@@ -0,0 +1,117 @@
"use strict";
/**
* Merge two scoped snapshots without letting an older cross-scope copy
* overwrite newer connection state. Metadata revisions are assigned by the
* main-process bridge when a renderer update arrives.
*
* @param {Record<string, unknown> | null | undefined} previous
* @param {Record<string, unknown> | null | undefined} fallback
* @returns {Record<string, unknown> | null}
*/
function mergeRetentionMeta(previous, fallback) {
if (!previous && !fallback) return null;
if (!previous) return fallback && typeof fallback === "object" ? fallback : null;
if (!fallback || typeof fallback !== "object") return previous;
const previousRevision = Number.isSafeInteger(previous._revision) ? previous._revision : 0;
const fallbackRevision = Number.isSafeInteger(fallback._revision) ? fallback._revision : 0;
// Unversioned direct helper inputs retain the historical fallback-wins
// behavior. Bridge-owned metadata is always versioned.
const fallbackIsNewer = fallbackRevision >= previousRevision;
const newer = fallbackIsNewer ? fallback : previous;
const older = fallbackIsNewer ? previous : fallback;
const connected = Object.prototype.hasOwnProperty.call(newer, "connected")
? newer.connected !== false
: older.connected !== false;
return {
...older,
...newer,
hostname: newer.hostname || older.hostname,
label: newer.label || older.label,
os: newer.os || older.os,
username: newer.username || older.username,
protocol: newer.protocol || older.protocol,
shellType: newer.shellType || older.shellType,
deviceType: newer.deviceType || older.deviceType,
hostId: newer.hostId || older.hostId,
hostChain: Array.isArray(newer.hostChain) ? newer.hostChain : older.hostChain,
// Explicit empty arrays in the newer snapshot clear stopped forwards.
activePortForwards: Array.isArray(newer.activePortForwards)
? newer.activePortForwards
: older.activePortForwards,
connected,
...(Math.max(previousRevision, fallbackRevision) > 0
? { _revision: Math.max(previousRevision, fallbackRevision) }
: {}),
};
}
/**
* Keep host_open-owned sessions in a chat scope when a full metadata replace
* would otherwise drop them (e.g. AIChatSidePanel pushing only the current
* terminal tab after a mid-turn host_open).
*
* Empty incoming lists are treated as authoritative clears and are not retained.
*
* @param {{
* incomingSessions: Array<Record<string, unknown>>,
* ownedSessionIds: string[],
* previousById?: Map<string, Record<string, unknown>> | null,
* findFallbackMeta?: ((sessionId: string) => Record<string, unknown> | null | undefined) | null,
* }} args
* @returns {Array<Record<string, unknown>>}
*/
function retainOwnedSessions({
incomingSessions,
ownedSessionIds,
previousById = null,
findFallbackMeta = null,
}) {
if (!Array.isArray(incomingSessions) || incomingSessions.length === 0) {
return incomingSessions;
}
if (!Array.isArray(ownedSessionIds) || ownedSessionIds.length === 0) {
return incomingSessions;
}
const byId = new Map();
for (const entry of incomingSessions) {
if (!entry || typeof entry !== "object" || !entry.sessionId) continue;
byId.set(String(entry.sessionId), entry);
}
for (const ownedIdRaw of ownedSessionIds) {
const ownedId = typeof ownedIdRaw === "string" ? ownedIdRaw.trim() : "";
if (!ownedId || byId.has(ownedId)) continue;
const previous = previousById?.get?.(ownedId) || null;
// Always consult fallback — a stale previous connected:false must not
// block a fresher cross-scope snapshot (e.g. External MCP / other tab).
const fallback = typeof findFallbackMeta === "function"
? findFallbackMeta(ownedId)
: null;
const meta = mergeRetentionMeta(previous, fallback);
if (!meta || typeof meta !== "object") continue;
byId.set(ownedId, {
sessionId: ownedId,
hostname: meta.hostname || "",
label: meta.label || "",
os: meta.os || "",
username: meta.username || "",
protocol: meta.protocol || "",
shellType: meta.shellType || "",
deviceType: meta.deviceType || "",
connected: meta.connected !== false,
hostId: meta.hostId || "",
hostChain: Array.isArray(meta.hostChain) ? meta.hostChain : [],
activePortForwards: Array.isArray(meta.activePortForwards) ? meta.activePortForwards : [],
...(Number.isSafeInteger(meta._revision) ? { _revision: meta._revision } : {}),
});
}
return Array.from(byId.values());
}
module.exports = { retainOwnedSessions, mergeRetentionMeta };
@@ -0,0 +1,267 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { retainOwnedSessions, mergeRetentionMeta } = require("./retainOwnedSessions.cjs");
test("retainOwnedSessions keeps host_open-owned sessions dropped by a full scope replace", () => {
const previousById = new Map([
["sess-original", {
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
}],
["sess-opened", {
hostname: "10.0.0.2",
label: "server-b",
protocol: "ssh",
connected: false,
hostId: "host-b",
}],
]);
const retained = retainOwnedSessions({
incomingSessions: [{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
}],
ownedSessionIds: ["sess-opened"],
previousById,
});
const ids = retained.map((entry) => entry.sessionId).sort();
assert.deepEqual(ids, ["sess-opened", "sess-original"]);
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
assert.equal(opened.label, "server-b");
assert.equal(opened.hostId, "host-b");
assert.equal(opened.connected, false);
});
test("retainOwnedSessions does not alter authoritative empty replaces", () => {
const retained = retainOwnedSessions({
incomingSessions: [],
ownedSessionIds: ["sess-opened"],
previousById: new Map([
["sess-opened", { hostname: "10.0.0.2", label: "server-b" }],
]),
});
assert.deepEqual(retained, []);
});
test("retainOwnedSessions falls back to cross-scope metadata when needed", () => {
const retained = retainOwnedSessions({
incomingSessions: [{ sessionId: "sess-original", label: "server-a" }],
ownedSessionIds: ["sess-opened"],
previousById: new Map(),
findFallbackMeta: (sessionId) => (
sessionId === "sess-opened"
? { hostname: "10.0.0.2", label: "server-b", hostId: "host-b" }
: null
),
});
assert.equal(retained.length, 2);
assert.equal(
retained.find((entry) => entry.sessionId === "sess-opened")?.label,
"server-b",
);
});
test("retainOwnedSessions ignores owned ids with no recoverable metadata", () => {
const retained = retainOwnedSessions({
incomingSessions: [{ sessionId: "sess-original", label: "server-a" }],
ownedSessionIds: ["sess-ghost"],
previousById: new Map(),
});
assert.deepEqual(retained.map((entry) => entry.sessionId), ["sess-original"]);
});
test("retainOwnedSessions refreshes connected from fallback even when previous exists", () => {
const retained = retainOwnedSessions({
incomingSessions: [{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
}],
ownedSessionIds: ["sess-opened"],
previousById: new Map([
["sess-opened", {
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
}],
]),
findFallbackMeta: (sessionId) => (
sessionId === "sess-opened"
? {
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
username: "root",
}
: null
),
});
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
assert.ok(opened);
assert.equal(opened.connected, true);
assert.equal(opened.username, "root");
assert.equal(opened.hostId, "host-b");
});
test("mergeRetentionMeta lets a later disconnected fallback replace connected:true", () => {
const merged = mergeRetentionMeta(
{
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
},
{
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
},
);
assert.equal(merged.connected, false);
});
test("retainOwnedSessions applies a later disconnect from fallback metadata", () => {
const retained = retainOwnedSessions({
incomingSessions: [{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
}],
ownedSessionIds: ["sess-opened"],
previousById: new Map([
["sess-opened", {
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
}],
]),
findFallbackMeta: (sessionId) => (
sessionId === "sess-opened"
? {
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
}
: null
),
});
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
assert.ok(opened);
assert.equal(opened.connected, false);
});
test("mergeRetentionMeta clears activePortForwards when fallback reports an empty array", () => {
const merged = mergeRetentionMeta(
{
hostname: "10.0.0.2",
label: "server-b",
connected: true,
activePortForwards: [{ ruleId: "fwd-1", localPort: 8080, status: "active" }],
},
{
hostname: "10.0.0.2",
label: "server-b",
connected: true,
activePortForwards: [],
},
);
assert.deepEqual(merged.activePortForwards, []);
});
test("mergeRetentionMeta keeps prior activePortForwards when fallback omits them", () => {
const prior = [{ ruleId: "fwd-1", localPort: 8080, status: "active" }];
const merged = mergeRetentionMeta(
{
hostname: "10.0.0.2",
label: "server-b",
connected: true,
activePortForwards: prior,
},
{
hostname: "10.0.0.2",
label: "server-b",
connected: true,
},
);
assert.deepEqual(merged.activePortForwards, prior);
});
test("retainOwnedSessions applies empty activePortForwards from fallback metadata", () => {
const retained = retainOwnedSessions({
incomingSessions: [{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
}],
ownedSessionIds: ["sess-opened"],
previousById: new Map([
["sess-opened", {
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
activePortForwards: [{ ruleId: "fwd-1", localPort: 8080, status: "active" }],
}],
]),
findFallbackMeta: (sessionId) => (
sessionId === "sess-opened"
? {
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
activePortForwards: [],
}
: null
),
});
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
assert.ok(opened);
assert.deepEqual(opened.activePortForwards, []);
assert.equal(opened.connected, true);
});
test("mergeRetentionMeta keeps the higher metadata revision in either argument", () => {
const oldConnected = {
connected: true,
username: "old-user",
activePortForwards: [{ ruleId: "old-forward" }],
_revision: 4,
};
const newDisconnected = {
connected: false,
username: "new-user",
activePortForwards: [],
_revision: 7,
};
for (const merged of [
mergeRetentionMeta(oldConnected, newDisconnected),
mergeRetentionMeta(newDisconnected, oldConnected),
]) {
assert.equal(merged.connected, false);
assert.equal(merged.username, "new-user");
assert.deepEqual(merged.activePortForwards, []);
assert.equal(merged._revision, 7);
}
});
@@ -43,6 +43,12 @@ function createSessionOwnershipRegistry() {
return { ok: true };
}
function listOwned(chatSessionId) {
if (!chatSessionId) return [];
const owned = ownedByScope.get(chatSessionId);
return owned ? Array.from(owned) : [];
}
function forgetSession(sessionId) {
for (const [scopeId, owned] of ownedByScope) {
owned.delete(sessionId);
@@ -50,6 +56,16 @@ function createSessionOwnershipRegistry() {
}
}
/**
* Drop retained ownership for a chat scope without revoking its host_open
* generation. Used when the renderer pushes an authoritative empty scope
* replace so a later non-empty sync cannot resurrect cleared sessions.
*/
function releaseScopeOwnership(chatSessionId) {
if (!chatSessionId) return;
ownedByScope.delete(chatSessionId);
}
function clearScope(chatSessionId) {
ownedByScope.delete(chatSessionId);
const generation = scopeGenerations.get(chatSessionId);
@@ -65,7 +81,9 @@ function createSessionOwnershipRegistry() {
captureGeneration,
register,
validate,
listOwned,
forgetSession,
releaseScopeOwnership,
clearScope,
getTrackedGenerationCountForTests,
};
@@ -13,6 +13,18 @@ test("session ownership is isolated by AI scope", () => {
assert.match(ownership.validate("chat-b", "session-1").error, /not opened/i);
});
test("listOwned returns sessions registered for a chat scope", () => {
const ownership = createSessionOwnershipRegistry();
ownership.register("chat-a", "session-1");
ownership.register("chat-a", "session-2");
ownership.register("chat-b", "session-3");
assert.deepEqual(ownership.listOwned("chat-a").sort(), ["session-1", "session-2"]);
assert.deepEqual(ownership.listOwned("chat-b"), ["session-3"]);
assert.deepEqual(ownership.listOwned("missing"), []);
assert.deepEqual(ownership.listOwned(""), []);
});
test("forgetSession revokes ownership from every scope", () => {
const ownership = createSessionOwnershipRegistry();
ownership.register("chat-a", "session-1");
@@ -23,6 +35,22 @@ test("forgetSession revokes ownership from every scope", () => {
assert.equal(ownership.validate("chat-b", "session-1").ok, false);
});
test("releaseScopeOwnership clears retained ids without revoking generations", () => {
const ownership = createSessionOwnershipRegistry();
const generation = ownership.captureGeneration("chat-a");
ownership.register("chat-a", "session-1", generation);
ownership.register("chat-b", "session-2");
ownership.releaseScopeOwnership("chat-a");
assert.deepEqual(ownership.listOwned("chat-a"), []);
assert.equal(ownership.validate("chat-a", "session-1").ok, false);
assert.equal(ownership.validate("chat-b", "session-2").ok, true);
// Late host_open for the same generation must still be allowed.
assert.equal(ownership.register("chat-a", "session-3", generation), true);
assert.deepEqual(ownership.listOwned("chat-a"), ["session-3"]);
});
test("clearScope only revokes the deleted chat scope", () => {
const ownership = createSessionOwnershipRegistry();
ownership.register("chat-a", "session-1");
+55 -11
View File
@@ -39,10 +39,15 @@ const pendingScreenSnapshots = new Map();
const scriptLogTokens = new Map();
/** @type {Map<string, Promise<void>>} */
const sessionRunChains = new Map();
/** @type {Map<string, number>} */
const queuedRunGenerations = new Map();
/** @type {Map<string, number>} */
const cancelledQueuedRunGenerations = new Map();
/** @type {Map<string, { abort: (reason?: Error) => void }>} */
const runAbortControls = new Map();
/** @type {Map<string, { connected?: boolean, name?: string, hostname?: string, username?: string }>} */
const rendererSessionMetaById = new Map();
let scriptRuntimeGeneration = 0;
let disposeTerminalDataTap = null;
let disposeWorkerOutputTap = null;
let disposeWorkerSessionClosed = null;
@@ -78,6 +83,9 @@ function enqueueSessionRun(sessionId, task) {
}
function init(deps) {
scriptRuntimeGeneration += 1;
queuedRunGenerations.clear();
cancelledQueuedRunGenerations.clear();
for (const [runId, control] of runAbortControls.entries()) {
const run = runs.get(runId);
if (run && !run.endedAt) {
@@ -593,6 +601,8 @@ async function runScriptOnSession({
async function handleScriptRun(_event, payload = {}) {
const {
runId: requestedRunId,
returnWhenQueued = false,
scriptId,
scriptLabel,
content,
@@ -612,22 +622,51 @@ async function handleScriptRun(_event, payload = {}) {
if (!content || !String(content).trim()) {
throw new Error("Script content is empty");
}
if (requestedRunId !== undefined && targets.length !== 1) {
throw new Error("A caller-provided runId requires exactly one target session");
}
if (returnWhenQueued && targets.length !== 1) {
throw new Error("returnWhenQueued requires exactly one target session");
}
const runIds = [];
const queueRun = (sid) => {
const runId = randomUUID();
const generation = scriptRuntimeGeneration;
const runId = requestedRunId === undefined ? randomUUID() : String(requestedRunId).trim();
if (!runId || runId.length > 128) {
throw new Error("Invalid script run id");
}
if (runs.has(runId) || queuedRunGenerations.has(runId)) {
throw new Error(`Script run id already exists: ${runId}`);
}
runIds.push(runId);
return enqueueSessionRun(sid, () => runScriptOnSession({
runId,
scriptId,
scriptLabel,
sessionId: sid,
content,
permissionMode,
sessionMeta: payload.sessionMeta,
}));
queuedRunGenerations.set(runId, generation);
return enqueueSessionRun(sid, () => {
if (queuedRunGenerations.get(runId) === generation) {
queuedRunGenerations.delete(runId);
}
if (generation !== scriptRuntimeGeneration) return undefined;
if (cancelledQueuedRunGenerations.get(runId) === generation) {
cancelledQueuedRunGenerations.delete(runId);
return undefined;
}
return runScriptOnSession({
runId,
scriptId,
scriptLabel,
sessionId: sid,
content,
permissionMode,
sessionMeta: payload.sessionMeta,
});
});
};
if (returnWhenQueued) {
void queueRun(targets[0]).catch(() => {});
return { runIds, runId: runIds[0] };
}
if (mode === "sequential") {
for (const sid of targets) {
await queueRun(sid);
@@ -641,7 +680,12 @@ async function handleScriptRun(_event, payload = {}) {
function handleScriptStop(_event, payload = {}) {
const run = runs.get(payload.runId);
if (!run) return { ok: false };
if (!run) {
const generation = queuedRunGenerations.get(payload.runId);
if (generation === undefined) return { ok: false };
cancelledQueuedRunGenerations.set(payload.runId, generation);
return { ok: true };
}
run.aborted = true;
run.paused = false;
run.status = "failed";
+250
View File
@@ -202,6 +202,90 @@ test("same session script runs are serialized through the session mutex", async
assert.deepEqual(writeOrder, ["slow-run", "fast-run"]);
});
test("a caller can cancel its queued script by run id without stopping the active script", async () => {
const handlers = new Map();
const writes = [];
let activeDialogRequestId;
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: { getVersion: () => "test", getPath: () => process.cwd() },
},
terminalBridge: {
writeToSession(_event, payload) {
writes.push(String(payload.data || ""));
},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:dialog-request") {
activeDialogRequestId = payload.requestId;
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: { rows: 24, cols: 80, currentRow: 0, lines: [] },
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runHandler = handlers.get("netcatty:script:run");
const activeRun = runHandler({}, {
runId: "unrelated-active-run",
scriptId: "manual",
sessionId: "session-queued-cancel",
content: `
await nct.dialog.confirm('hold queue');
await nct.screen.sendLine('active-finished');
`,
permissionMode: "auto",
});
await waitUntil(() => activeDialogRequestId);
const queued = await runHandler({}, {
runId: "queued-connect-run",
returnWhenQueued: true,
scriptId: "connect",
sessionId: "session-queued-cancel",
content: "await nct.screen.sendLine('queued-ran');",
permissionMode: "auto",
});
assert.deepEqual(queued, {
runId: "queued-connect-run",
runIds: ["queued-connect-run"],
});
assert.deepEqual(
await handlers.get("netcatty:script:stop")({}, { runId: "queued-connect-run" }),
{ ok: true },
);
assert.deepEqual(
await handlers.get("netcatty:script:dialog-response")({}, {
requestId: activeDialogRequestId,
value: true,
}),
{ ok: true },
);
await activeRun;
await delay(30);
assert.ok(writes.some((data) => data.includes("active-finished")));
assert.ok(!writes.some((data) => data.includes("queued-ran")));
});
test("stopping a script releases the session queue so it can run again", async () => {
const handlers = new Map();
const sentRunUpdates = [];
@@ -358,6 +442,172 @@ test("reinitializing the script bridge drops an accepted old-run sendLine tail",
assert.deepEqual(replacementWrites, []);
});
test("reinitializing the script bridge invalidates accepted queued runs", async () => {
const handlers = new Map();
const oldWrites = [];
const replacementWrites = [];
let activeDialogRequestId;
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: { getVersion: () => "test", getPath: () => process.cwd() },
},
terminalBridge: {
writeToSession(_event, payload) { oldWrites.push(String(payload.data || "")); },
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:dialog-request") {
activeDialogRequestId = payload.requestId;
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: { rows: 24, cols: 80, currentRow: 0, lines: [] },
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) { handlers.set(channel, handler); },
});
const runHandler = handlers.get("netcatty:script:run");
const activeRun = runHandler({}, {
runId: "active-before-reinit",
scriptId: "active-before-reinit",
sessionId: "session-reinit-queue",
content: "await nct.dialog.confirm('hold queue');",
permissionMode: "auto",
});
await waitUntil(() => activeDialogRequestId);
await runHandler({}, {
runId: "queued-before-reinit",
returnWhenQueued: true,
scriptId: "queued-before-reinit",
sessionId: "session-reinit-queue",
content: "await nct.screen.sendLine('stale-queued-write');",
permissionMode: "auto",
});
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: { getVersion: () => "test", getPath: () => process.cwd() },
},
terminalBridge: {
writeToSession(_event, payload) { replacementWrites.push(String(payload.data || "")); },
},
terminalWorkerManager: null,
getMainWindow: () => null,
});
await activeRun;
await delay(60);
assert.deepEqual(oldWrites, []);
assert.deepEqual(replacementWrites, []);
});
test("an old queued run cannot unregister a reused run id after reinitialization", async () => {
const handlers = new Map();
const writes = [];
let oldDialogRequestId;
let replacementDialogRequestId;
const makeMainWindow = (setDialogRequestId) => () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:dialog-request") {
setDialogRequestId(payload.requestId);
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: { rows: 24, cols: 80, currentRow: 0, lines: [] },
});
});
}
},
},
});
const baseDeps = {
sessions: new Map(),
electronModule: {
app: { getVersion: () => "test", getPath: () => process.cwd() },
},
terminalBridge: {
writeToSession(_event, payload) { writes.push(String(payload.data || "")); },
},
terminalWorkerManager: null,
};
scriptBridge.init({
...baseDeps,
getMainWindow: makeMainWindow((id) => { oldDialogRequestId = id; }),
});
scriptBridge.registerHandlers({
handle(channel, handler) { handlers.set(channel, handler); },
});
const runHandler = handlers.get("netcatty:script:run");
const oldActive = runHandler({}, {
runId: "old-active-for-reuse",
sessionId: "session-reinit-reuse",
content: "await nct.dialog.confirm('hold old queue');",
permissionMode: "auto",
});
await waitUntil(() => oldDialogRequestId);
await runHandler({}, {
runId: "reused-queued-id",
returnWhenQueued: true,
sessionId: "session-reinit-reuse",
content: "await nct.screen.sendLine('stale-reused-write');",
permissionMode: "auto",
});
scriptBridge.init({
...baseDeps,
getMainWindow: makeMainWindow((id) => { replacementDialogRequestId = id; }),
});
const replacementActive = runHandler({}, {
runId: "replacement-active-for-reuse",
sessionId: "session-reinit-reuse",
content: "await nct.dialog.confirm('hold replacement queue');",
permissionMode: "auto",
});
await runHandler({}, {
runId: "reused-queued-id",
returnWhenQueued: true,
sessionId: "session-reinit-reuse",
content: "await nct.screen.sendLine('replacement-reused-write');",
permissionMode: "auto",
});
await oldActive;
await waitUntil(() => replacementDialogRequestId);
assert.deepEqual(
await handlers.get("netcatty:script:stop")({}, { runId: "reused-queued-id" }),
{ ok: true },
);
await handlers.get("netcatty:script:dialog-response")({}, {
requestId: replacementDialogRequestId,
value: true,
});
await replacementActive;
await delay(60);
assert.ok(!writes.some((data) => data.includes("stale-reused-write")));
assert.ok(!writes.some((data) => data.includes("replacement-reused-write")));
});
test("late startup snapshots from a stopped script do not seed the next run", async () => {
const handlers = new Map();
const writes = [];
+38 -1
View File
@@ -4,6 +4,7 @@
*/
const pendingBootAborts = new Map();
const latestBootEpochs = new Map();
function normalizeBootEpoch(bootEpoch) {
if (!Number.isFinite(bootEpoch)) return undefined;
@@ -29,9 +30,13 @@ function registerPendingBootAbort(sessionId, bootEpoch) {
try { existing.controller.abort(); } catch { /* ignore */ }
}
const controller = new AbortController();
const normalized = normalizeBootEpoch(bootEpoch);
if (normalized !== undefined) {
latestBootEpochs.set(sessionId, Math.max(latestBootEpochs.get(sessionId) ?? normalized, normalized));
}
pendingBootAborts.set(sessionId, {
controller,
bootEpoch: normalizeBootEpoch(bootEpoch),
bootEpoch: normalized,
});
return controller;
}
@@ -59,6 +64,34 @@ function abortPendingBoot(sessionId, bootEpoch) {
return true;
}
function hasPendingBootAfter(sessionId, bootEpoch) {
if (!sessionId) return false;
const pending = pendingBootAborts.get(sessionId);
const requested = normalizeBootEpoch(bootEpoch);
return Boolean(
pending
&& requested !== undefined
&& pending.bootEpoch !== undefined
&& pending.bootEpoch > requested
);
}
function hasNewerBootEpoch(sessionId, bootEpoch) {
if (!sessionId) return false;
const requested = normalizeBootEpoch(bootEpoch);
const latest = latestBootEpochs.get(sessionId);
return requested !== undefined && latest !== undefined && latest > requested;
}
function forgetBootEpoch(sessionId, bootEpoch) {
if (!sessionId) return;
const requested = normalizeBootEpoch(bootEpoch);
const latest = latestBootEpochs.get(sessionId);
if (requested === undefined || latest === undefined || latest <= requested) {
latestBootEpochs.delete(sessionId);
}
}
function clearPendingBootAbort(sessionId, controller) {
if (!sessionId || !controller) return;
const pending = pendingBootAborts.get(sessionId);
@@ -161,6 +194,7 @@ function claimSessionSlot(sessions, sessionId, session, bootEpoch) {
}
if (normalized !== undefined) {
session.bootEpoch = normalized;
latestBootEpochs.set(sessionId, Math.max(latestBootEpochs.get(sessionId) ?? normalized, normalized));
}
let displaced;
// When a newer boot replaces an older registry entry, mark and return the
@@ -197,6 +231,9 @@ module.exports = {
claimSessionSlot,
clearPendingBootAbort,
disposeDisplacedSessionResources,
forgetBootEpoch,
hasPendingBootAfter,
hasNewerBootEpoch,
normalizeBootEpoch,
registerPendingBootAbort,
sessionMatchesBootEpoch,
@@ -21,17 +21,26 @@ test("pending boot abort cancels older generations but not newer ones", () => {
const {
abortPendingBoot,
clearPendingBootAbort,
forgetBootEpoch,
hasNewerBootEpoch,
registerPendingBootAbort,
hasPendingBootAfter,
} = require("./sessionBootEpoch.cjs");
const older = registerPendingBootAbort("s-pending", 2);
const newer = registerPendingBootAbort("s-pending", 4);
assert.equal(older.signal.aborted, true);
assert.equal(newer.signal.aborted, false);
assert.equal(abortPendingBoot("s-pending", 3), false);
assert.equal(hasPendingBootAfter("s-pending", 3), true);
assert.equal(hasNewerBootEpoch("s-pending", 3), true);
assert.equal(newer.signal.aborted, false);
assert.equal(abortPendingBoot("s-pending", 4), true);
assert.equal(hasPendingBootAfter("s-pending", 3), false);
assert.equal(hasNewerBootEpoch("s-pending", 3), true);
assert.equal(newer.signal.aborted, true);
clearPendingBootAbort("s-pending", newer);
forgetBootEpoch("s-pending", 4);
assert.equal(hasNewerBootEpoch("s-pending", 3), false);
});
test("claimSessionSlot replaces an older boot epoch and marks it superseded", () => {
+44
View File
@@ -1945,6 +1945,7 @@ async function openSftpForSession(_event, payload) {
const probeTimeoutMs = Number.isFinite(payload?.timeoutMs) && payload.timeoutMs > 0
? payload.timeoutMs
: SCP_PROBE_TIMEOUT_MS;
const sudoRequested = Boolean(payload?.sudo);
async function probeScpCapability() {
const bounded = createBoundedProbeSignal(payload?.abortSignal || null, probeTimeoutMs);
@@ -1970,6 +1971,49 @@ async function openSftpForSession(_event, payload) {
}
try {
// Forced SCP cannot provide sudo elevation; reject before touching the channel
// so session reuse matches the fresh openSftp contract.
if (sudoRequested && fileProtocol === "scp") {
throw new Error(
"Sudo Mode is not supported with File Protocol set to SCP. Disable Sudo Mode or use Auto/SFTP.",
);
}
if (sudoRequested) {
let sftpWrapper;
let sudoActive = true;
try {
sftpWrapper = await connectSudoSftp(sshClient, payload?.password || "");
} catch (e) {
// Fallback: if sftp-server binary is missing (exit code 127), try the
// standard SFTP subsystem instead of failing completely. Mirrors openSftp
// (ESXi / hosts without a standalone sftp-server). Keeps the reused SSH
// transport so MFA is not repeated.
if (e?.message && e.message.includes("exit code 127")) {
console.warn(
"[SFTP] openSftpForSession sftp-server not found, falling back to standard SFTP subsystem",
);
sudoActive = false;
sftpWrapper = await requireSftpChannel(client, {
signal: payload?.abortSignal,
timeoutMs: payload?.timeoutMs,
});
} else {
throw e;
}
}
client.sftp = sftpWrapper;
client.__netcattyFileProtocol = "sftp";
client.__netcattySudoMode = sudoActive;
if (sudoActive) {
sftpWrapper.on?.("close", () => client.end());
}
throwIfAborted(payload?.abortSignal);
copySftpEncodingState(payload?.encodingStateKey, sftpId);
sftpClients.set(sftpId, client);
return { ok: true, sftpId, fileProtocol: "sftp", sourceSessionId };
}
if (fileProtocol === "scp") {
client.__netcattyFileProtocol = "scp";
client.sftp = null;
@@ -14,7 +14,13 @@ const {
function loadSftpBridgeWithProxySocket(proxySocket, overrides = {}) {
const bridgePath = require.resolve("./sftpBridge.cjs");
const openConnectionPath = require.resolve("./sftpBridge/openConnection.cjs");
delete require.cache[bridgePath];
// Reload openConnection when we need to wrap createOpenConnectionApi so the
// bridge picks up a patched connectSudoSftp (session-backed sudo tests).
if (overrides.connectSudoSftp) {
delete require.cache[openConnectionPath];
}
const originalLoad = Module._load;
Module._load = function patchedLoad(request, parent, isMain) {
@@ -30,6 +36,25 @@ function loadSftpBridgeWithProxySocket(proxySocket, overrides = {}) {
Client: overrides.SSHClient,
};
}
if (
overrides.connectSudoSftp
&& (request === "./sftpBridge/openConnection.cjs"
|| request === openConnectionPath
|| (typeof request === "string" && request.endsWith("/sftpBridge/openConnection.cjs")))
) {
const mod = originalLoad.call(this, request, parent, isMain);
const originalCreate = mod.createOpenConnectionApi;
return {
...mod,
createOpenConnectionApi(ctx) {
const api = originalCreate(ctx);
return {
...api,
connectSudoSftp: overrides.connectSudoSftp,
};
},
};
}
return originalLoad.call(this, request, parent, isMain);
};
@@ -40,6 +65,20 @@ function loadSftpBridgeWithProxySocket(proxySocket, overrides = {}) {
}
}
function createFakeSftpChannel() {
return {
ended: false,
readdir: () => {},
stat: () => {},
mkdir: () => {},
unlink: () => {},
end() {
this.ended = true;
},
on() {},
};
}
class FailingSshClient extends EventEmitter {
constructor() {
super();
@@ -270,3 +309,163 @@ test("openSftpForSession honors session.sftpFileProtocol when payload omits file
assert.equal(sftpClients.get(opened.sftpId)?.__netcattyFileProtocol, "sftp");
await bridge.closeSftp(null, { sftpId: opened.sftpId });
});
test("openSftpForSession rejects sudo with forced SCP before opening a channel", async () => {
let sudoCalls = 0;
let sftpCalls = 0;
const bridge = loadSftpBridgeWithProxySocket(null, {
connectSudoSftp: async () => {
sudoCalls += 1;
throw new Error("connectSudoSftp should not run for scp+sudo");
},
});
const sftpClients = new Map();
const conn = {
sftp(cb) {
sftpCalls += 1;
cb(null, createFakeSftpChannel());
},
end() {},
};
const session = {
conn,
stream: {},
sftpFileProtocol: "scp",
};
bridge.init({
sftpClients,
sessions: new Map([["session-sudo-scp", session]]),
electronModule: {},
});
await assert.rejects(
() => bridge.openSftpForSession(null, {
sessionId: "session-sudo-scp",
sudo: true,
fileProtocol: "scp",
password: "secret",
}),
/Sudo Mode is not supported with File Protocol set to SCP/i,
);
assert.equal(sudoCalls, 0);
assert.equal(sftpCalls, 0);
assert.equal(sftpClients.size, 0);
});
test("openSftpForSession falls back to standard SFTP when sudo sftp-server exits 127", async () => {
let sudoCalls = 0;
let sftpCalls = 0;
const bridge = loadSftpBridgeWithProxySocket(null, {
connectSudoSftp: async () => {
sudoCalls += 1;
throw new Error("SFTP sudo failed with exit code 127. sftp-server not found");
},
});
const sftpClients = new Map();
const fakeSftp = createFakeSftpChannel();
const conn = {
sftp(cb) {
sftpCalls += 1;
cb(null, fakeSftp);
},
end() {},
};
const session = { conn, stream: {} };
bridge.init({
sftpClients,
sessions: new Map([["session-sudo-127", session]]),
electronModule: {},
});
const opened = await bridge.openSftpForSession(null, {
sessionId: "session-sudo-127",
sudo: true,
password: "secret",
});
assert.equal(opened.ok, true);
assert.equal(opened.fileProtocol, "sftp");
assert.equal(opened.sourceSessionId, "session-sudo-127");
assert.equal(sudoCalls, 1);
assert.equal(sftpCalls, 1);
const client = sftpClients.get(opened.sftpId);
assert.equal(client?.__netcattyFileProtocol, "sftp");
assert.equal(client?.__netcattySudoMode, false);
assert.equal(client?.sftp, fakeSftp);
await bridge.closeSftp(null, { sftpId: opened.sftpId });
});
test("openSftpForSession does not fall back to standard SFTP on non-127 sudo errors", async () => {
let sftpCalls = 0;
const bridge = loadSftpBridgeWithProxySocket(null, {
connectSudoSftp: async () => {
throw new Error("SFTP sudo failed with exit code 1. The password may be incorrect");
},
});
const sftpClients = new Map();
const conn = {
sftp(cb) {
sftpCalls += 1;
cb(null, createFakeSftpChannel());
},
end() {},
};
const session = { conn, stream: {} };
bridge.init({
sftpClients,
sessions: new Map([["session-sudo-auth", session]]),
electronModule: {},
});
await assert.rejects(
() => bridge.openSftpForSession(null, {
sessionId: "session-sudo-auth",
sudo: true,
password: "wrong",
}),
/exit code 1/i,
);
assert.equal(sftpCalls, 0);
assert.equal(sftpClients.size, 0);
});
test("openSftpForSession keeps sudo mode when connectSudoSftp succeeds", async () => {
const sudoWrapper = createFakeSftpChannel();
let closeBound = false;
sudoWrapper.on = (event, cb) => {
if (event === "close") closeBound = true;
return sudoWrapper;
};
const bridge = loadSftpBridgeWithProxySocket(null, {
connectSudoSftp: async () => sudoWrapper,
});
const sftpClients = new Map();
let sftpCalls = 0;
const conn = {
sftp(cb) {
sftpCalls += 1;
cb(null, createFakeSftpChannel());
},
end() {},
};
const session = { conn, stream: {} };
bridge.init({
sftpClients,
sessions: new Map([["session-sudo-ok", session]]),
electronModule: {},
});
const opened = await bridge.openSftpForSession(null, {
sessionId: "session-sudo-ok",
sudo: true,
password: "secret",
});
assert.equal(opened.ok, true);
assert.equal(sftpCalls, 0);
const client = sftpClients.get(opened.sftpId);
assert.equal(client?.__netcattySudoMode, true);
assert.equal(client?.sftp, sudoWrapper);
assert.equal(closeBound, true);
await bridge.closeSftp(null, { sftpId: opened.sftpId });
});
+33 -3
View File
@@ -932,8 +932,14 @@ function createFileOpsApi(ctx) {
}
}
// Method 2: SFTP realpath('.') — skip if result is '/' for non-root users
// because some SFTP servers start in '/' rather than the user's home
// Method 2: SFTP realpath('.'). A virtual/chroot SFTP server (including
// bastion products such as JumpServer) can legitimately expose '/' as
// the authenticated user's root even when SSH exec channels are denied.
//
// Accept non-root absolute paths immediately. Accept '/' only when it is
// actually listable so we do not suppress renderer candidate probing
// (/home/<user>, /root) on servers that merely start cwd at '/' without
// granting readdir on the real filesystem root.
try {
const sftp = await requireSftpChannel(client, {
signal,
@@ -942,9 +948,33 @@ function createFileOpsApi(ctx) {
throwIfAborted(signal);
const absPath = await realpathAsync(sftp, ".");
throwIfAborted(signal);
if (absPath && absPath !== "/") {
if (absPath && absPath.startsWith("/") && absPath !== "/") {
return { success: true, homeDir: absPath };
}
if (absPath === "/") {
try {
if (typeof readdirAsync === "function") {
await readdirAsync(sftp, "/");
} else if (sftp && typeof sftp.readdir === "function") {
await new Promise((resolve, reject) => {
sftp.readdir("/", (err, items) => {
if (err) reject(err);
else resolve(items || []);
});
});
} else {
// No list probe available — keep virtual-root acceptance from #2934.
return { success: true, homeDir: "/" };
}
throwIfAborted(signal);
return { success: true, homeDir: "/" };
} catch (listErr) {
if (signal?.aborted) {
throw listErr;
}
// Non-listable root: fall through so candidate probing can run.
}
}
} catch (err) {
if (signal?.aborted) {
throw err;
@@ -0,0 +1,68 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const { createFileOpsApi } = require("./fileOps.cjs");
test("home discovery accepts a virtual SFTP root when SSH exec is unavailable", async () => {
const channel = {};
const listed = [];
const api = createFileOpsApi({
sftpClients: new Map([["jumpserver", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
realpathAsync: async (resolvedChannel, remotePath) => {
assert.equal(resolvedChannel, channel);
assert.equal(remotePath, ".");
return "/";
},
readdirAsync: async (resolvedChannel, remotePath) => {
listed.push([resolvedChannel, remotePath]);
return [{ filename: "data" }];
},
});
const result = await api.getSftpHomeDir(null, { sftpId: "jumpserver" });
assert.deepEqual(result, { success: true, homeDir: "/" });
assert.deepEqual(listed, [[channel, "/"]]);
});
test("home discovery rejects non-listable root so candidate probing can run", async () => {
const channel = {};
const api = createFileOpsApi({
sftpClients: new Map([["restricted", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
realpathAsync: async () => "/",
readdirAsync: async () => {
const error = new Error("Permission denied");
error.code = "EACCES";
throw error;
},
});
const result = await api.getSftpHomeDir(null, { sftpId: "restricted" });
assert.equal(result.success, false);
assert.match(result.error || "", /Could not determine home directory/);
});
test("home discovery still accepts non-root realpath without listing", async () => {
const channel = {};
let readdirCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["normal", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
realpathAsync: async () => "/home/deploy",
readdirAsync: async () => {
readdirCalls += 1;
return [];
},
});
const result = await api.getSftpHomeDir(null, { sftpId: "normal" });
assert.deepEqual(result, { success: true, homeDir: "/home/deploy" });
assert.equal(readdirCalls, 0);
});
+2
View File
@@ -470,6 +470,7 @@ const {
acquireConnectionRef,
releaseConnectionRef,
transferConnectionRef,
consumePendingShellReconnectRisk,
findReusableSession,
findTransportByEndpoint,
resolveTransportForReuse,
@@ -979,6 +980,7 @@ const startSessionApi = createStartSessionApi({
get selectZmodemDownloadDirectory() { return selectZmodemDownloadDirectory; },
preparePrivateKeyForAuth, loadFirstIdentityFileForAuth, prepareSystemSshAgentForAuth, hasUserConfiguredKey, isPasswordProvided, createKeyboardInteractiveHandler, createOrderedStringAuthHandler, createAuthPhase, markAuthPhasePartialSuccess, canRepeatKeyboardInteractive, shouldSkipKiPasswordAutoFill,
createConnectionRef, acquireConnectionRef, releaseConnectionRef, transferConnectionRef,
consumePendingShellReconnectRisk,
findReusableSession, findTransportByEndpoint, resolveTransportForReuse, discardAllTransports,
beginTransportDial, waitForTransportDial, completeTransportDial, failTransportDial,
buildConnectionReuseEndpoint,
@@ -3,12 +3,18 @@ const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const Module = require("node:module");
const sshConnectionPool = require("./sshConnectionPool.cjs");
const {
createTransport,
borrowTransport,
createConnectionRef,
acquireConnectionRef,
releaseConnectionRef,
beginTransportDial,
completeTransportDial,
buildConnectionReuseEndpoint,
resetSshTransportRegistryForTests,
} = require("./sshConnectionPool.cjs");
} = sshConnectionPool;
// Load sshBridge with a mocked ssh2 module so we can observe whether a *new*
// SSH client is constructed (a fresh connection) versus an existing connection
@@ -190,6 +196,163 @@ test("an ordinary open with reuse disabled bypasses an idle same-host transport"
assert.notEqual(firstTransport.conn, sessions.get("second").conn);
});
test("idle-park reconnect after last shell closes skips post-open PID discovery", async (t) => {
// After the sole interactive shell returns its lease, the next open reuses the
// parked transport. Post-open discovery exec has no sibling PIDs to
// disambiguate and can tear down bastion sessions (issue #2923).
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "bastion.qzsec.example",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
sshChannelOpenRateLimitBackoffMs: 1,
};
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
const first = sessions.get("first");
const parkedConn = first.conn;
const firstTransport = first.connRef;
first.stream.emit("close");
assert.equal(firstTransport.state, "idle");
let execCalls = 0;
parkedConn.exec = (_command, callback) => {
execCalls += 1;
callback(new Error("idle reconnect must not open discovery exec"));
};
const shellsBefore = parkedConn.openedShells.length;
await start({ sender: makeSender() }, { ...options, sessionId: "second" });
assert.equal(getClientConstructCount(), 1, "second open reuses the parked transport");
assert.equal(parkedConn.openedShells.length, shellsBefore + 1);
assert.equal(execCalls, 0, "must not open discovery exec after sole-shell reconnect");
const second = sessions.get("second");
assert.equal(second.blockUntargetedCwdProbe, true);
assert.notEqual(second.allowCwdRecovery, true);
second.pendingCwdRecoveryAfterUserCommand = true;
second.stream.emit("data", Buffer.from("command output\r\n"));
assert.equal(second.allowCwdRecovery, true);
// A second ordinary reconnect can arrive while the first risk-marked shell
// is still live but unassigned. It must not reopen PID discovery during the
// connection path either; both sessions stay fail-closed instead.
await start({ sender: makeSender() }, { ...options, sessionId: "third" });
const third = sessions.get("third");
assert.equal(execCalls, 0);
assert.equal(third.blockUntargetedCwdProbe, true);
assert.equal(third.parkedReconnectRisk.hasUnknownOldShell, true);
});
for (const leaseKind of ["sftp", "forward"]) {
test(`first shell on a ${leaseKind}-only transport keeps cwd discovery enabled`, async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: `${leaseKind}-only.example`,
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
const conn = makeReusableConn();
const transport = createTransport({
conn,
endpoint: buildConnectionReuseEndpoint(options),
});
borrowTransport(transport, {
kind: leaseKind,
holder: { id: `${leaseKind}-holder` },
});
await start({ sender: makeSender() }, { ...options, sessionId: "first-shell" });
assert.equal(getClientConstructCount(), 0);
assert.equal(sessions.get("first-shell").connRef, transport);
assert.notEqual(sessions.get("first-shell").blockUntargetedCwdProbe, true);
});
}
test("shell joining an SFTP-led initial dial keeps cwd discovery enabled", async (t) => {
const originalWaitForTransportDial = sshConnectionPool.waitForTransportDial;
let observeJoin;
const joinedDial = new Promise((resolve) => { observeJoin = resolve; });
sshConnectionPool.waitForTransportDial = (coordination, ...args) => {
if (coordination?.role === "join") observeJoin();
return originalWaitForTransportDial(coordination, ...args);
};
t.after(() => { sshConnectionPool.waitForTransportDial = originalWaitForTransportDial; });
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "sftp-led.example",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
const endpoint = buildConnectionReuseEndpoint(options);
const coordination = beginTransportDial(endpoint, { kind: "channel" });
const pendingStart = start({ sender: makeSender() }, { ...options, sessionId: "first-shell" });
await joinedDial;
const conn = makeReusableConn();
const transport = createTransport({ conn, endpoint });
borrowTransport(transport, { kind: "sftp", holder: { id: "sftp-holder" } });
completeTransportDial(coordination, transport);
await pendingStart;
assert.equal(getClientConstructCount(), 0);
assert.equal(sessions.get("first-shell").connRef, transport);
assert.notEqual(sessions.get("first-shell").blockUntargetedCwdProbe, true);
});
test("last shell close stays protected when an SFTP lease keeps the transport live", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "shell-plus-sftp.example",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender: makeSender() }, { ...options, sessionId: "old-shell" });
const oldSession = sessions.get("old-shell");
const transport = oldSession.connRef;
oldSession.shellPid = "111";
acquireConnectionRef({ id: "sftp-holder", __sshLeaseKind: "sftp" }, transport);
oldSession.stream.emit("close");
assert.equal(transport.state, "live");
await start({ sender: makeSender() }, { ...options, sessionId: "new-shell" });
assert.equal(getClientConstructCount(), 1);
assert.equal(sessions.get("new-shell").connRef, transport);
assert.equal(sessions.get("new-shell").blockUntargetedCwdProbe, true);
assert.deepEqual(sessions.get("new-shell").parkedReconnectRisk, {
oldShellPids: ["111"],
hasUnknownOldShell: false,
});
});
function makeSender() {
return {
id: 1,
@@ -3,6 +3,7 @@ const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { createSessionOpsApi } = require("./sshBridge/sessionOps.cjs");
const { selectServerStatsFixtureOutput } = require("./sshBridge/serverStatsTestHelpers.cjs");
function fakeStream(stdout, waitForWrite = false) {
const stream = new EventEmitter();
@@ -25,10 +26,7 @@ function fakeStream(stdout, waitForWrite = false) {
function fakeConn(stdout) {
return {
exec(command, cb) {
const output = command.includes("NC_LATENCY_MARK") && !stdout.includes("NC_LATENCY_MARK")
? `NC_LATENCY_MARK|${stdout}`
: stdout;
cb(null, fakeStream(output));
cb(null, fakeStream(selectServerStatsFixtureOutput(command, stdout)));
},
};
}
@@ -120,7 +118,7 @@ test("getServerStats opens an ET stats companion connection for direct ET sessio
});
test("getServerStats falls back to execOnEtSession for jumped ET sessions", async () => {
let command = "";
const commands = [];
let ensureCalls = 0;
const api = makeApi(
{
@@ -131,8 +129,12 @@ test("getServerStats falls back to execOnEtSession for jumped ET sessions", asyn
etStatsAuth: { hostname: "example.test", hasJumpHost: true },
},
async (_session, cmd) => {
command = cmd;
return { success: true, stdout: LINUX_STATS, stderr: "" };
commands.push(cmd);
return {
success: true,
stdout: cmd.includes('echo "DISKS:$disks"') ? "DISKS:" : LINUX_STATS,
stderr: "",
};
},
{
ensureEtStatsConnection: async () => {
@@ -145,11 +147,14 @@ test("getServerStats falls back to execOnEtSession for jumped ET sessions", asyn
const result = await api.getServerStats(null, { sessionId: "et-1" });
assert.equal(ensureCalls, 0);
assert.match(command, /CPURAW|UNSUPPORTED_OS/);
assert.equal(commands.length, 2);
assert.match(commands[0], /CPURAW|UNSUPPORTED_OS/);
assert.match(commands[1], /DISKS:/);
assert.ok(commands.every((command) => Buffer.byteLength(command, "utf8") <= 9000));
assert.equal(result.success, true);
assert.equal(result.stats.memTotal, 8000);
assert.equal(result.stats.latencyMs, null);
assert.doesNotMatch(command, /read -r nc_latency_probe/);
assert.ok(commands.every((command) => !/read -r nc_latency_probe/.test(command)));
});
test("getServerStats falls back to execOnEtSession when the direct ET companion is unavailable", async () => {
@@ -163,9 +168,13 @@ test("getServerStats falls back to execOnEtSession when the direct ET companion
etStatsAuth: { hostname: "example.test" },
etStatsConnFailed: true,
},
async () => {
async (_session, command) => {
execFallbackCalls += 1;
return { success: true, stdout: LINUX_STATS, stderr: "" };
return {
success: true,
stdout: command.includes('echo "DISKS:$disks"') ? "DISKS:" : LINUX_STATS,
stderr: "",
};
},
{
ensureEtStatsConnection: async () => {
@@ -176,7 +185,7 @@ test("getServerStats falls back to execOnEtSession when the direct ET companion
const result = await api.getServerStats(null, { sessionId: "et-1" });
assert.equal(execFallbackCalls, 1);
assert.equal(execFallbackCalls, 2);
assert.equal(result.success, true);
assert.equal(result.stats.memTotal, 8000);
assert.equal(result.stats.latencyMs, 3);
@@ -0,0 +1,14 @@
"use strict";
function selectServerStatsFixtureOutput(command, stdout) {
const parts = String(stdout || "").split("|");
const diskPart = parts.find((part) => part.startsWith("DISKS:")) || "DISKS:";
const baseOutput = parts.filter((part) => !part.startsWith("DISKS:")).join("|");
if (command.includes('echo "DISKS:$disks"')) return diskPart;
if (command.includes("NC_LATENCY_MARK") && !baseOutput.includes("NC_LATENCY_MARK")) {
return `NC_LATENCY_MARK|${baseOutput}`;
}
return baseOutput;
}
module.exports = { selectServerStatsFixtureOutput };
+262 -38
View File
@@ -1,5 +1,6 @@
/* eslint-disable no-undef */
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
const { listInteractiveShellPids } = require("../sshInteractiveShells.cjs");
function decodeLsofFileName(value) {
if (typeof value !== 'string') return null;
// lsof's caret form is ambiguous: a BEL byte and the literal characters
@@ -54,6 +55,7 @@ function decodeLsofFileName(value) {
function createSessionOpsApi(ctx) {
with (ctx) {
const cwdRecoveryToken = Symbol('cwd-recovery');
function getTcpLatencyTarget(session) {
if (session.tcpLatencyDirect === false) return null;
@@ -237,6 +239,7 @@ function createSessionOpsApi(ctx) {
async function getSessionPwd(event, payload) {
const { sessionId } = payload;
const isTargetedRecovery = payload?._cwdRecoveryToken === cwdRecoveryToken;
const allowHomeFallback = payload?.allowHomeFallback !== false;
// Login-shell fallback defaults to the same gate as ~ guessing so callers
// that pass allowHomeFallback: false (e.g. captureInheritedCwd) keep the
@@ -252,9 +255,178 @@ function createSessionOpsApi(ctx) {
if (!session || !session.conn) {
return { success: false, error: 'Session not found or not connected' };
}
if (
session.blockUntargetedCwdProbe
&& session.cwdRecoveryPromise
&& !isTargetedRecovery
) {
return session.cwdRecoveryPromise;
}
if (session.blockUntargetedCwdProbe && !session.shellPid && !isTargetedRecovery) {
const transport = session.connRef || null;
if (session.cwdRecoveryPromise) return session.cwdRecoveryPromise;
if (transport?.cwdRecoveryPromise) {
await transport.cwdRecoveryPromise.catch(() => {});
return { success: false, error: 'Another cwd recovery was already in progress' };
}
if (session.allowCwdRecovery !== true || transport?.cwdRecoveryDisabled) {
return {
success: false,
error: 'Current directory is unavailable during an immediate reconnect',
};
}
const reconnectRisk = session.parkedReconnectRisk;
if (!reconnectRisk || reconnectRisk.hasUnknownOldShell) {
return {
success: false,
error: 'Current directory cannot be safely identified after reconnect',
};
}
// One real command/output pair permits one attempt. Ambiguous or failed
// recovery stays closed until another command produces output.
session.allowCwdRecovery = false;
const recoveryOwner = {
conn: session.conn,
connRef: session.connRef,
stream: session.stream,
shellCloseGeneration: transport?.shellCloseGeneration || 0,
};
const isCurrentOwner = () => (
sessions.get(sessionId) === session
&& session.conn === recoveryOwner.conn
&& session.connRef === recoveryOwner.connRef
&& session.stream === recoveryOwner.stream
);
const recoveryPromise = (async () => {
const sharedTerminalCountBeforeRecovery = transport
? [...sessions.values()].filter(
(candidate) => candidate?.connRef === transport && candidate?.stream,
).length
: 1;
if (sharedTerminalCountBeforeRecovery !== 1) {
return {
success: false,
error: 'Current directory is ambiguous across shared terminal channels',
};
}
if (transport?.closedShellPidUnknown) {
return {
success: false,
error: 'Current directory cannot be safely identified after reconnect',
};
}
const oldShellPids = new Set([
...(Array.isArray(reconnectRisk.oldShellPids) ? reconnectRisk.oldShellPids : []),
...(
transport?.closedShellPids instanceof Set
? [...transport.closedShellPids]
: []
),
].map(String));
const discovery = await listInteractiveShellPids(session.conn, {
quoteShellArg,
openingTimeoutMs: timeoutMs,
runTimeoutMs: timeoutMs,
setTimeoutFn: setTimeout,
clearTimeoutFn: clearTimeout,
// Best-effort cwd recovery must not kill the interactive shell or
// other SFTP/forward leases when a server never answers exec open.
invalidateOnOpenTimeout: false,
});
if (discovery.openTimedOut && transport) transport.cwdRecoveryDisabled = true;
if (!isCurrentOwner()) {
return { success: false, error: 'Session changed during cwd recovery' };
}
if (
transport
&& (transport.shellCloseGeneration || 0) !== recoveryOwner.shellCloseGeneration
) {
return { success: false, error: 'Terminal set changed during cwd recovery' };
}
if (transport?.closedShellPidUnknown) {
return {
success: false,
error: 'Current directory cannot be safely identified after reconnect',
};
}
const assignedSiblingPids = new Set(
[...sessions.values()]
.filter((candidate) => (
candidate?.connRef === session.connRef
&& candidate !== session
&& candidate.shellPid
))
.map((candidate) => String(candidate.shellPid)),
);
const unclaimedPids = discovery.pids.filter(
(pid) => (
!assignedSiblingPids.has(String(pid))
&& !oldShellPids.has(String(pid))
),
);
const sharedTerminalCountAfterRecovery = session.connRef
? [...sessions.values()].filter(
(candidate) => candidate?.connRef === session.connRef && candidate?.stream,
).length
: 1;
if (
sharedTerminalCountAfterRecovery !== 1
|| !discovery.available
|| unclaimedPids.length !== 1
) {
return {
success: false,
error: 'Current directory is still ambiguous after reconnect',
};
}
const recoveredPid = unclaimedPids[0];
const result = await getSessionPwd(event, {
...payload,
_cwdRecoveryToken: cwdRecoveryToken,
_cwdRecoveryTargetPid: recoveredPid,
_cwdRecoveryTransport: transport,
});
if (!isCurrentOwner()) {
return { success: false, error: 'Session changed during cwd recovery' };
}
const sharedTerminalCountAfterPwd = transport
? [...sessions.values()].filter(
(candidate) => candidate?.connRef === transport && candidate?.stream,
).length
: 1;
if (
sharedTerminalCountAfterPwd !== 1
|| (
transport
&& (transport.shellCloseGeneration || 0) !== recoveryOwner.shellCloseGeneration
)
|| transport?.closedShellPidUnknown
) {
return { success: false, error: 'Terminal set changed during cwd recovery' };
}
if (!result.success) {
return result;
}
session.shellPid = recoveredPid;
session.blockUntargetedCwdProbe = false;
session.parkedReconnectRisk = null;
return result;
})().finally(() => {
if (session.cwdRecoveryPromise === recoveryPromise) {
session.cwdRecoveryPromise = null;
}
if (transport?.cwdRecoveryPromise === recoveryPromise) {
transport.cwdRecoveryPromise = null;
}
});
session.cwdRecoveryPromise = recoveryPromise;
if (transport) transport.cwdRecoveryPromise = recoveryPromise;
return recoveryPromise;
}
const targetLoginPid = /^\d+$/.test(String(session.shellPid || ''))
? String(session.shellPid)
const requestedRecoveryPid = isTargetedRecovery ? payload?._cwdRecoveryTargetPid : '';
const targetLoginPid = /^\d+$/.test(String(requestedRecoveryPid || session.shellPid || ''))
? String(requestedRecoveryPid || session.shellPid)
: '';
const sharedTerminalCount = session.connRef
? [...sessions.values()].filter(
@@ -272,6 +444,17 @@ function createSessionOpsApi(ctx) {
// in the interactive terminal. The exec channel and the interactive
// shell are both children of the same per-connection sshd process,
// so we find the shell as a sibling via $PPID.
const pwdOwner = {
conn: session.conn,
connRef: session.connRef,
stream: session.stream,
};
const isCurrentPwdOwner = () => (
sessions.get(sessionId) === session
&& session.conn === pwdOwner.conn
&& session.connRef === pwdOwner.connRef
&& session.stream === pwdOwner.stream
);
return new Promise((resolve) => {
let settled = false;
const settle = (result) => {
@@ -440,14 +623,19 @@ function createSessionOpsApi(ctx) {
maxOutputBytes: 256 * 1024,
setTimeoutFn: setTimeout,
clearTimeoutFn: clearTimeout,
invalidateOnOpenTimeout: !isTargetedRecovery,
}).then(({ stdout, stderr, code }) => {
if (!isCurrentPwdOwner()) {
settle({ success: false, error: 'Session changed during cwd probe' });
return;
}
const rawPath = stdout.replace(/\r?\n$/, '');
const lsofPrefix = 'NETCATTY_LSOF_CWD=';
const path = rawPath.startsWith(lsofPrefix)
? decodeLsofFileName(rawPath.slice(lsofPrefix.length))
: rawPath;
const loginPidMatch = stderr.match(/(?:^|\n)NETCATTY_LOGIN_PID=(\d+)(?:\n|$)/);
if (loginPidMatch) {
if (loginPidMatch && !isTargetedRecovery) {
session.shellPid = loginPidMatch[1];
}
log('[getSessionPwd]', { stdout: rawPath, stderr: stderr.trim(), exitCode: code });
@@ -457,6 +645,14 @@ function createSessionOpsApi(ctx) {
settle({ success: false, error: 'Could not determine cwd' });
}
}, (err) => {
if (isTargetedRecovery && err?.code === "SSH_EXEC_OPEN_TIMEOUT") {
const recoveryTransport = payload?._cwdRecoveryTransport || session.connRef;
if (recoveryTransport) recoveryTransport.cwdRecoveryDisabled = true;
}
if (!isCurrentPwdOwner()) {
settle({ success: false, error: 'Session changed during cwd probe' });
return;
}
log('[getSessionPwd] exec error:', err?.message || String(err));
settle({
success: false,
@@ -864,17 +1060,19 @@ function createSessionOpsApi(ctx) {
filesystem_type = mount_filesystem_types[mount_point];
}
if (mount_point == "") next;
if (source ~ /^\/dev\/loop/) next;
if (source ~ /^\/dev\/loop/ && mount_point != "/") next;
if (mount_point != "/" && mount_point ~ /^\/(etc|proc|sys|dev|snap)(\/|$)/) next;
if (mount_point != "/" && source ~ /^(overlay|overlayfs)(\/|$|:)/) next;
fstype = tolower(filesystem_type);
source_lower = tolower(source);
is_network_or_fuse = 0;
if (fstype ~ /^fuse\.(rclone|sshfs|s3fs|gcsfuse|ufs)$/) is_network_or_fuse = 1;
if (fstype ~ /^fuse\.(rclone|sshfs|s3fs|gcsfuse|ufs|mergerfs|unionfs|unionfs-fuse|ceph|ceph-fuse|cephfs|glusterfs)$/) is_network_or_fuse = 1;
if (fstype ~ /clouddrive/) is_network_or_fuse = 1;
if (fstype ~ /^(rclone|sshfs|s3fs|gcsfuse)$/) is_network_or_fuse = 1;
if (fstype ~ /^(fuse|rclone|sshfs|s3fs|gcsfuse|mergerfs|unionfs|unionfs-fuse|nfs|nfs4|cifs|smb|smb3|smbfs|afs|ceph|cephfs|glusterfs)$/) is_network_or_fuse = 1;
if ((filesystem_type == "" || filesystem_type == "-") && source_lower ~ /clouddrive/) is_network_or_fuse = 1;
if ((filesystem_type == "" || filesystem_type == "-") && source_lower ~ /^(fuse|fuse\..*|rclone|rclone:.*|sshfs|s3fs|gcsfuse|mergerfs|unionfs|unionfs-fuse|ufs)$/) is_network_or_fuse = 1;
if ((filesystem_type == "" || filesystem_type == "-") && source_lower ~ /^(fuse|fuse\..*|rclone|rclone:.*|sshfs|s3fs|gcsfuse|mergerfs|unionfs|unionfs-fuse|ceph|ceph-fuse|cephfs|gluster|glusterfs|ufs)$/) is_network_or_fuse = 1;
if ((filesystem_type == "" || filesystem_type == "-") && source_lower ~ /^\/\//) is_network_or_fuse = 1;
if ((filesystem_type == "" || filesystem_type == "-") && source_lower !~ /^(apfs|overlay|overlayfs):/ && source_lower ~ /^([a-z0-9._-]+|\[[0-9a-f:]+(%[a-z0-9._-]+)?\]):\//) is_network_or_fuse = 1;
if (is_network_or_fuse) next;
pseudo_type = (fstype != "" && fstype != "-") ? fstype : source_lower;
if (mount_point != "/" && pseudo_type ~ /^(tmpfs|shm|devtmpfs|udev|none|proc|sysfs|cgroup|cgroup2|devpts|mqueue|hugetlbfs|debugfs|tracefs|securityfs|pstore|bpf|fusectl|configfs|ramfs|rpc_pipefs|binfmt_misc|efivarfs)$/) next;
@@ -891,7 +1089,7 @@ function createSessionOpsApi(ctx) {
const linuxDiskTable = `{ LC_ALL=C mount 2>/dev/null; printf "%s\\n" "__NETCATTY_DF__"; LC_ALL=C df -kPT 2>/dev/null || LC_ALL=C df -kP 2>/dev/null; }`;
const linuxDiskRoot = `{ LC_ALL=C mount 2>/dev/null; printf "%s\\n" "__NETCATTY_DF__"; LC_ALL=C df -kPT / 2>/dev/null || LC_ALL=C df -kP / 2>/dev/null; }`;
// Command to get CPU (overall + per-core), Memory, Disk, and Network stats
// Command to get CPU (overall + per-core), Memory, and Network stats
// This command is designed to work across most Linux distributions
// Note: Using semicolons and avoiding comments for single-line execution
// CPU: Output raw values (total and idle) instead of percentage - we calculate delta on backend
@@ -909,17 +1107,6 @@ function createSessionOpsApi(ctx) {
// GNU ps: ps -eo pid,%mem,comm --sort=-%mem
// BusyBox fallback: top exposes %VSZ/%CPU; minimal builds without top use plain ps VSZ.
`procs=$(if procraw=$(ps -eo pid,%mem,comm --sort=-%mem 2>/dev/null); then printf "%s\n" "$procraw" | awk 'NR>1 && NR<=11 {gsub(/;/, "_", $3); printf "%s;%.1f;%s,", $1, $2, $3}' | sed 's/,$//'; elif topraw=$(top -b -n 1 2>/dev/null); then printf "%s\n" "$topraw" | awk '$1 == "PID" {for(i=1;i<=NF;i++){if($i=="%VSZ") mem_col=i; else if($i=="COMMAND") cmd_col=i} next} $1 ~ /^[0-9]+$/ && mem_col && cmd_col {pct=$(mem_col); gsub(/%/, "", pct); cmd=$(cmd_col); gsub(/;/, "_", cmd); print pct ";" $1 ";" cmd}' | sort -t ';' -k1,1rn | head -10 | awk -F';' '{printf "%s;%.1f;%s,", $2, $1, $3}' | sed 's/,$//'; else ps ww 2>/dev/null | awk -v total=$(awk '/^MemTotal:/{print $2}' /proc/meminfo) '$1 ~ /^[0-9]+$/ {v=$3; unit=substr(v,length(v),1); mult=1; if(unit=="m"||unit=="M")mult=1024; else if(unit=="g"||unit=="G")mult=1048576; sub(/[mMgG]$/, "", v); pct=total>0?v*mult*100/total:0; cmd=$5; gsub(/;/, "_", cmd); print pct, $1, cmd}' | sort -rn | head -10 | awk '{printf "%s;%.1f;%s,", $2, $1, $3}' | sed 's/,$//'; fi)`,
// Get mounted disk info. GNU and BusyBox support df -T; the awk
// parser also accepts the legacy POSIX -kP layout as a fallback.
// PVE/LXC guests often expose ZFS datasets and host bind mounts without a
// /dev/* source, so keep non-pseudo filesystems (not only block devices).
// Skip FUSE/cloud network mounts (rclone, CloudDrive, ufs, …): their
// quotas inflate System Overview totals and are not local block capacity.
// When Capacity is "-" (some CT/cgroup views), derive percent from used/total.
// If the full table yields nothing, fall back to df on "/" alone.
// Do not blanket-skip /run: udisks may mount real volumes under /run/media;
// tmpfs/udev/shm rows are dropped by filesystem-source checks instead.
`disks=$( { ${linuxDiskTable}; } | ${linuxDiskAwk} | sed 's/,$//' ); [ -n "$disks" ] || disks=$( { ${linuxDiskRoot}; } | ${linuxDiskAwk} | sed 's/,$//' )`,
// Get network interface stats from /proc/net/dev (interface:rx_bytes:tx_bytes), excluding lo and virtual interfaces
`net=$(cat /proc/net/dev 2>/dev/null | awk 'NR>2 {gsub(/^[ \\t]+/, ""); split($0, a, ":"); iface=a[1]; if(iface != "lo" && iface !~ /^veth/ && iface !~ /^docker/ && iface !~ /^br-/) {split(a[2], b); printf "%s:%s:%s,", iface, b[1], b[9]}}' | sed 's/,$//' || echo "")`,
`hostname_value=$(hostname 2>/dev/null || uname -n 2>/dev/null || echo "")`,
@@ -928,16 +1115,53 @@ function createSessionOpsApi(ctx) {
`uptime=$(awk '{printf "%.0f",$1}' /proc/uptime 2>/dev/null || echo "")`,
`loadavg=$(awk '{print $1" "$2" "$3}' /proc/loadavg 2>/dev/null || echo "")`,
// Output all stats (using CPURAW and PERCORERAW instead of CPU and PERCORE)
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|DISKS:$disks|NET:$net|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
].join('; ');
// Auto-detect OS via uname — only Linux and macOS are supported
// Get mounted disk info. GNU and BusyBox support df -T; the awk parser
// also accepts the legacy POSIX -kP layout as a fallback. PVE/LXC guests
// often expose ZFS datasets and host bind mounts without a /dev/* source,
// so keep non-pseudo filesystems. Skip FUSE/cloud/NFS/CIFS network mounts:
// their quotas are not local capacity. Keep a loop-backed rootfs while
// dropping snap loops, derive missing percentages, and fall back to "/".
const linuxDiskStatsCommand = [
`disks=$( { ${linuxDiskTable}; } | ${linuxDiskAwk} | sed 's/,$//' )`,
`[ -n "$disks" ] || disks=$( { ${linuxDiskRoot}; } | ${linuxDiskAwk} | sed 's/,$//' )`,
`echo "DISKS:$disks"`,
].join('; ');
// Dropbear rejects command requests larger than 9000 bytes by closing
// the entire SSH transport, including an already-open interactive shell.
// Keep Linux's large disk parser in its own request so future stats
// additions cannot repeat issue #2924.
const dropbearMaxCommandBytes = 9000;
const latencyMarker = "NC_LATENCY_MARK";
const statsCommand = `printf "${latencyMarker}|"; ostype=$(uname -s 2>/dev/null || echo "Unknown"); if [ "$ostype" = "Darwin" ]; then ${macosStatsCommand}; elif [ "$ostype" = "Linux" ]; then ${linuxStatsCommand}; else echo "UNSUPPORTED_OS:$ostype"; fi`;
const statsExecOptions = {
openingTimeoutMs: 10000,
runTimeoutMs: 10000,
maxOutputBytes: 1024 * 1024,
setTimeoutFn: setTimeout,
clearTimeoutFn: clearTimeout,
};
const executeStatsCommand = (command, options = statsExecOptions) => {
const commandBytes = Buffer.byteLength(command, 'utf8');
if (commandBytes > dropbearMaxCommandBytes) {
const error = new Error(`Server stats command exceeds Dropbear limit (${commandBytes} bytes)`);
error.code = "SSH_EXEC_COMMAND_LIMIT";
return Promise.reject(error);
}
return executeBoundedSshCommand(conn, command, options);
};
const tcpLatencyTarget = getTcpLatencyTarget(session);
const tcpLatencyPromise = tcpLatencyTarget && typeof measureTcpConnectLatency === 'function'
? Promise.resolve(measureTcpConnectLatency(tcpLatencyTarget)).catch(() => null)
: Promise.resolve(null);
const formatStatsError = (error) => (
error?.code === "SSH_EXEC_OPEN_TIMEOUT" || error?.code === "SSH_EXEC_RUN_TIMEOUT"
? "Timeout getting server stats"
: error?.message || String(error)
);
return new Promise((resolve) => {
let settled = false;
const settle = (result) => {
@@ -946,20 +1170,24 @@ function createSessionOpsApi(ctx) {
resolve(result);
return true;
};
void executeBoundedSshCommand(conn, statsCommand, {
openingTimeoutMs: 10000,
runTimeoutMs: 10000,
maxOutputBytes: 1024 * 1024,
setTimeoutFn: setTimeout,
clearTimeoutFn: clearTimeout,
}).then(async ({ stdout }) => {
void (async () => {
try {
const { stdout } = await executeStatsCommand(statsCommand);
if (settled) return;
let combinedStdout = String(stdout || '').trim();
const primaryOutput = combinedStdout.replace(new RegExp(`^${latencyMarker}\\|?`), '');
if (primaryOutput.startsWith('CPURAW:')) {
const diskResult = await executeStatsCommand(linuxDiskStatsCommand);
combinedStdout = [combinedStdout, String(diskResult.stdout || '').trim()]
.filter(Boolean)
.join('|');
}
const measuredLatency = await tcpLatencyPromise;
if (settled) return;
const latencyMs = Number.isFinite(measuredLatency) ? measuredLatency : null;
// Parse the output
const output = stdout.trim().replace(new RegExp(`^${latencyMarker}\\|?`), '');
const output = combinedStdout.replace(new RegExp(`^${latencyMarker}\\|?`), '');
// Unsupported OS — stop polling this session
if (output.startsWith('UNSUPPORTED_OS:')) {
@@ -1287,14 +1515,10 @@ function createSessionOpsApi(ctx) {
loadAverage,
},
});
}, (error) => {
settle({
success: false,
error: error?.code === "SSH_EXEC_OPEN_TIMEOUT" || error?.code === "SSH_EXEC_RUN_TIMEOUT"
? "Timeout getting server stats"
: error?.message || String(error),
});
});
} catch (error) {
settle({ success: false, error: formatStatsError(error) });
}
})();
});
}
@@ -21,8 +21,12 @@ function makePwdStream(cwd, loginPid) {
}
function makeApi(session, siblingSessions = [], overrides = {}) {
const sessions = overrides.sessions || new Map([
["session-1", session],
...siblingSessions,
]);
return createSessionOpsApi({
sessions: new Map([["session-1", session], ...siblingSessions]),
sessions,
setTimeout: overrides.setTimeout || setTimeout,
clearTimeout: overrides.clearTimeout || clearTimeout,
quoteShellArg,
@@ -207,6 +211,468 @@ test("an unshared terminal remembers the shell pid discovered by its cwd probe",
assert.equal(session.shellPid, "3131");
});
test("immediate parked reconnect does not guess cwd from an exiting shell", async () => {
let execCalls = 0;
const session = {
blockUntargetedCwdProbe: true,
connRef: { count: 1 },
stream: {},
conn: { exec() { execCalls += 1; } },
};
const api = makeApi(session);
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.equal(result.success, false);
assert.equal(execCalls, 0);
assert.equal(session.shellPid, undefined);
});
test("parked reconnect recovers cwd only after the new shell is unambiguous", async () => {
let scan = ["111", "222", "333"];
let scanCalls = 0;
let targetedPwdCalls = 0;
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef: { count: 1 },
stream: {},
conn: {
exec(command, callback) {
if (command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) {
scanCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from(`${scan.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`));
stream.emit("close", 0);
});
return;
}
targetedPwdCalls += 1;
assert.match(command, /TARGET_LOGIN=222/);
callback(null, makePwdStream("/new/cwd", "222"));
},
},
};
const api = makeApi(session);
const ambiguous = await api.getSessionPwd(null, {
sessionId: "session-1",
});
assert.equal(ambiguous.success, false);
assert.equal(session.shellPid, undefined);
assert.equal(targetedPwdCalls, 0);
scan = ["222"];
const withoutAnotherCommand = await api.getSessionPwd(null, {
sessionId: "session-1",
});
assert.equal(withoutAnotherCommand.success, false);
assert.equal(scanCalls, 1, "an ambiguous attempt consumes its command/output signal");
// A failed/ambiguous attempt consumes the command/output signal. A later
// real command can safely authorize one new uniqueness check.
session.allowCwdRecovery = true;
const recovered = await api.getSessionPwd(null, {
sessionId: "session-1",
});
assert.deepEqual(recovered, { success: true, cwd: "/new/cwd" });
assert.equal(session.shellPid, "222");
assert.equal(session.blockUntargetedCwdProbe, false);
assert.equal(targetedPwdCalls, 1);
assert.equal(scanCalls, 2);
});
test("parked reconnect never binds a scan that only sees the old shell", async () => {
let targetedPwdCalls = 0;
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef: { count: 1 },
stream: {},
conn: {
exec(command, callback) {
if (!command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) {
targetedPwdCalls += 1;
return;
}
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from("111\n__NETCATTY_SHELL_SCAN_COMPLETE__\n"));
stream.emit("close", 0);
});
},
},
};
const api = makeApi(session);
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.equal(result.success, false);
assert.equal(session.shellPid, undefined);
assert.equal(targetedPwdCalls, 0);
});
test("parked reconnect with an unknown old shell remains fail closed", async () => {
let execCalls = 0;
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: [], hasUnknownOldShell: true },
connRef: { count: 1 },
stream: {},
conn: { exec() { execCalls += 1; } },
};
const api = makeApi(session);
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.equal(result.success, false);
assert.equal(execCalls, 0);
assert.equal(session.shellPid, undefined);
});
test("parked reconnect does not scan while another terminal shares the transport", async () => {
let execCalls = 0;
const connRef = { count: 2, shellCloseGeneration: 0 };
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef,
stream: {},
conn: { exec() { execCalls += 1; } },
};
const api = makeApi(session, [["session-2", { connRef, stream: {} }]]);
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
assert.equal(result.success, false);
assert.equal(execCalls, 0);
assert.equal(session.shellPid, undefined);
});
test("a shell close during recovery invalidates the PID scan result", async () => {
let scanStream;
let targetedPwdCalls = 0;
const connRef = {
count: 1,
shellCloseGeneration: 0,
closedShellPids: new Set(),
closedShellPidUnknown: false,
};
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef,
stream: {},
conn: {
exec(command, callback) {
if (!command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) {
targetedPwdCalls += 1;
return;
}
scanStream = new EventEmitter();
scanStream.stderr = new EventEmitter();
scanStream.close = () => {};
callback(null, scanStream);
},
},
};
const api = makeApi(session);
const pending = api.getSessionPwd(null, { sessionId: "session-1" });
connRef.shellCloseGeneration += 1;
connRef.closedShellPids.add("333");
scanStream.emit("data", Buffer.from("333\n__NETCATTY_SHELL_SCAN_COMPLETE__\n"));
scanStream.emit("close", 0);
const result = await pending;
assert.equal(result.success, false);
assert.equal(targetedPwdCalls, 0);
assert.equal(session.shellPid, undefined);
});
test("concurrent cwd recovery uses one scan and one targeted probe", async () => {
let scanCalls = 0;
let targetedPwdCalls = 0;
const connRef = { count: 1, shellCloseGeneration: 0 };
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef,
stream: {},
conn: {
exec(command, callback) {
if (command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) {
scanCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from("222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n"));
stream.emit("close", 0);
});
return;
}
targetedPwdCalls += 1;
callback(null, makePwdStream("/new/cwd", "222"));
},
},
};
const api = makeApi(session);
const [left, right] = await Promise.all([
api.getSessionPwd(null, { sessionId: "session-1" }),
api.getSessionPwd(null, { sessionId: "session-1" }),
]);
assert.deepEqual(left, { success: true, cwd: "/new/cwd" });
assert.deepEqual(right, left);
assert.equal(scanCalls, 1);
assert.equal(targetedPwdCalls, 1);
});
test("a late cwd request joins recovery after PID scan but before targeted pwd completes", async () => {
let scanCalls = 0;
let targetedPwdCalls = 0;
let targetedStream;
const connRef = { count: 1, shellCloseGeneration: 0 };
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef,
stream: {},
conn: {
exec(command, callback) {
if (command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) {
scanCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from("222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n"));
stream.emit("close", 0);
});
return;
}
targetedPwdCalls += 1;
targetedStream = new EventEmitter();
targetedStream.stderr = new EventEmitter();
targetedStream.close = () => {};
callback(null, targetedStream);
},
},
};
const api = makeApi(session);
const first = api.getSessionPwd(null, { sessionId: "session-1" });
while (!targetedStream) await new Promise((resolve) => setImmediate(resolve));
assert.equal(session.shellPid, undefined, "candidate PID stays private until pwd succeeds");
const late = api.getSessionPwd(null, { sessionId: "session-1" });
assert.equal(scanCalls, 1);
assert.equal(targetedPwdCalls, 1);
targetedStream.emit("data", Buffer.from("/new/cwd\n"));
targetedStream.stderr.emit("data", Buffer.from("NETCATTY_LOGIN_PID=222\n"));
targetedStream.emit("close", 0);
const [left, right] = await Promise.all([first, late]);
assert.deepEqual(left, { success: true, cwd: "/new/cwd" });
assert.deepEqual(right, left);
assert.equal(session.shellPid, "222");
assert.equal(scanCalls, 1);
assert.equal(targetedPwdCalls, 1);
});
test("targeted recovery ignores cwd returned after the session is replaced", async () => {
let targetedStream;
const connRef = { count: 1, shellCloseGeneration: 0 };
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef,
stream: {},
conn: {
exec(command, callback) {
if (command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from("222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n"));
stream.emit("close", 0);
});
return;
}
targetedStream = new EventEmitter();
targetedStream.stderr = new EventEmitter();
targetedStream.close = () => {};
callback(null, targetedStream);
},
},
};
const sessions = new Map([["session-1", session]]);
const api = makeApi(session, [], { sessions });
const pending = api.getSessionPwd(null, { sessionId: "session-1" });
while (!targetedStream) await new Promise((resolve) => setImmediate(resolve));
const replacement = { conn: session.conn, connRef, stream: {} };
sessions.set("session-1", replacement);
targetedStream.emit("data", Buffer.from("/old/cwd\n"));
targetedStream.stderr.emit("data", Buffer.from("NETCATTY_LOGIN_PID=222\n"));
targetedStream.emit("close", 0);
const result = await pending;
assert.equal(result.success, false);
assert.equal(replacement.shellPid, undefined);
assert.equal(session.shellPid, undefined);
});
test("a sibling close during targeted pwd invalidates the recovery result", async () => {
let targetedStream;
const connRef = {
count: 1,
shellCloseGeneration: 0,
closedShellPids: new Set(),
closedShellPidUnknown: false,
};
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef,
stream: {},
conn: {
exec(command, callback) {
if (command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from("222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n"));
stream.emit("close", 0);
});
return;
}
targetedStream = new EventEmitter();
targetedStream.stderr = new EventEmitter();
targetedStream.close = () => {};
callback(null, targetedStream);
},
},
};
const sessions = new Map([["session-1", session]]);
const api = makeApi(session, [], { sessions });
const pending = api.getSessionPwd(null, { sessionId: "session-1" });
while (!targetedStream) await new Promise((resolve) => setImmediate(resolve));
// A second shell opened and closed while targeted pwd was pending. Its local
// session is gone again, but the close generation proves the terminal set
// changed and the candidate can no longer be committed safely.
connRef.shellCloseGeneration += 1;
connRef.closedShellPids.add("222");
targetedStream.emit("data", Buffer.from("/closed-sibling/cwd\n"));
targetedStream.stderr.emit("data", Buffer.from("NETCATTY_LOGIN_PID=222\n"));
targetedStream.emit("close", 0);
const result = await pending;
assert.equal(result.success, false);
assert.equal(session.shellPid, undefined);
assert.equal(session.blockUntargetedCwdProbe, true);
assert.notEqual(session.parkedReconnectRisk, null);
});
test("targeted recovery open timeout preserves and disables the shared transport", async () => {
const timers = [];
const setTimeoutFn = (callback) => {
const timer = { callback, active: true };
timers.push(timer);
return timer;
};
const clearTimeoutFn = (timer) => { if (timer) timer.active = false; };
let execCalls = 0;
let endCalls = 0;
let destroyCalls = 0;
const connRef = { count: 2, shellCloseGeneration: 0 };
const conn = {
end() { endCalls += 1; },
destroy() { destroyCalls += 1; },
exec(command, callback) {
execCalls += 1;
if (!command.includes("__NETCATTY_SHELL_SCAN_COMPLETE__")) return;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
stream.emit("data", Buffer.from("222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n"));
stream.emit("close", 0);
},
};
const session = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["111"], hasUnknownOldShell: false },
connRef,
stream: {},
conn,
};
const sessions = new Map([["session-1", session]]);
const api = makeApi(session, [], {
sessions,
setTimeout: setTimeoutFn,
clearTimeout: clearTimeoutFn,
});
const pending = api.getSessionPwd(null, { sessionId: "session-1" });
while (execCalls < 2) await Promise.resolve();
sessions.delete("session-1");
const openingTimer = timers.find((timer) => timer.active);
assert.ok(openingTimer);
openingTimer.callback();
const result = await pending;
assert.equal(result.success, false);
assert.equal(connRef.cwdRecoveryDisabled, true);
assert.equal(endCalls, 0);
assert.equal(destroyCalls, 0);
const replacement = {
blockUntargetedCwdProbe: true,
allowCwdRecovery: true,
parkedReconnectRisk: { oldShellPids: ["222"], hasUnknownOldShell: false },
connRef,
stream: {},
conn,
};
sessions.set("session-2", replacement);
const replacementApi = createSessionOpsApi({
sessions,
setTimeout: setTimeoutFn,
clearTimeout: clearTimeoutFn,
quoteShellArg,
log: () => {},
});
const retry = await replacementApi.getSessionPwd(null, { sessionId: "session-2" });
assert.equal(retry.success, false);
assert.equal(execCalls, 2, "disabled transport must not accumulate another hung open");
});
test("an SFTP reference does not make one terminal cwd ambiguous", async () => {
const session = {
connRef: { count: 2 },
@@ -4,6 +4,7 @@ const { spawnSync } = require("node:child_process");
const { EventEmitter } = require("node:events");
const { createSessionOpsApi } = require("./sessionOps.cjs");
const { selectServerStatsFixtureOutput } = require("./serverStatsTestHelpers.cjs");
const {
borrowTransport,
createTransport,
@@ -33,10 +34,7 @@ function fakeStream(stdout) {
function fakeConn(stdout) {
return {
exec(command, cb) {
const output = command.includes("NC_LATENCY_MARK") && !stdout.includes("NC_LATENCY_MARK")
? `NC_LATENCY_MARK|${stdout}`
: stdout;
cb(null, fakeStream(output));
cb(null, fakeStream(selectServerStatsFixtureOutput(command, stdout)));
},
};
}
@@ -220,6 +218,13 @@ function runStatsCommandWithNetworkFuseDf(command, { forceLegacy = false } = {})
" printf '%s\\n' 'user@host:/media on /mnt/sshfs type fuse.sshfs (rw)'",
" printf '%s\\n' 'CloudNAS on /CloudNAS type fuse.CloudDrive (rw)'",
" printf '%s\\n' 'ufs-backend on /mnt/ufs type fuse.ufs (rw)'",
" printf '%s\\n' 'nas.local:/volume1/media on /mnt/nas type nfs4 (rw)'",
" printf '%s\\n' '//nas.local/share on /mnt/smb type cifs (rw)'",
" printf '%s\\n' 'mergerfs on /mnt/pool type fuse.mergerfs (rw)'",
" printf '%s\\n' 'gluster on /mnt/gluster type fuse.glusterfs (rw)'",
" printf '%s\\n' 'ceph-fuse on /mnt/ceph type fuse.ceph-fuse (rw)'",
" printf '%s\\n' 'unionfs on /mnt/unionfs type fuse.unionfs-fuse (rw)'",
" printf '%s\\n' 'CloudFS on /mnt/CloudNAS/openlist type fuse (rw)'",
"}",
"df() {",
...(forceLegacy ? [" if [ \"$1\" = '-kPT' ]; then return 1; fi"] : []),
@@ -239,6 +244,13 @@ function runStatsCommandWithNetworkFuseDf(command, { forceLegacy = false } = {})
" printf '%s\\n' 'user@host:/media fuse.sshfs 2147483648 1073741824 1073741824 50% /mnt/sshfs'",
" printf '%s\\n' 'CloudNAS fuse.CloudDrive 4294967296 2147483648 2147483648 50% /CloudNAS'",
" printf '%s\\n' 'ufs-backend fuse.ufs 1048576000 524288000 524288000 50% /mnt/ufs'",
" printf '%s\\n' 'nas.local:/volume1/media nfs4 20971520000 8388608000 12582912000 40% /mnt/nas'",
" printf '%s\\n' '//nas.local/share cifs 10485760000 4194304000 6291456000 40% /mnt/smb'",
" printf '%s\\n' 'mergerfs fuse.mergerfs 31457280000 9437184000 22020096000 30% /mnt/pool'",
" printf '%s\\n' 'gluster fuse.glusterfs 15728640000 7340032000 8388608000 47% /mnt/gluster'",
" printf '%s\\n' 'ceph-fuse fuse.ceph-fuse 12582912000 6291456000 6291456000 50% /mnt/ceph'",
" printf '%s\\n' 'unionfs fuse.unionfs-fuse 8388608000 2097152000 6291456000 25% /mnt/unionfs'",
" printf '%s\\n' 'CloudFS fuse 10995116277760 0 10995116277760 0% /mnt/CloudNAS/openlist'",
" printf '%s\\n' 'tmpfs tmpfs 102400 100 102300 1% /run'",
" return 0",
" fi",
@@ -254,6 +266,12 @@ function runStatsCommandWithNetworkFuseDf(command, { forceLegacy = false } = {})
" printf '%s\\n' 'user@host:/media 2147483648 1073741824 1073741824 50% /mnt/sshfs'",
" printf '%s\\n' 'CloudDrive 4294967296 2147483648 2147483648 50% /CloudNAS/CloudDrive'",
" printf '%s\\n' 'ufs 1048576000 524288000 524288000 50% /mnt/ufs'",
" printf '%s\\n' 'nas.local:/volume1/media 20971520000 8388608000 12582912000 40% /mnt/nas'",
" printf '%s\\n' '//nas.local/share 10485760000 4194304000 6291456000 40% /mnt/smb'",
" printf '%s\\n' 'mergerfs 31457280000 9437184000 22020096000 30% /mnt/pool'",
" printf '%s\\n' 'gluster 15728640000 7340032000 8388608000 47% /mnt/gluster'",
" printf '%s\\n' 'ceph-fuse 12582912000 6291456000 6291456000 50% /mnt/ceph'",
" printf '%s\\n' 'unionfs 8388608000 2097152000 6291456000 25% /mnt/unionfs'",
" printf '%s\\n' 'tmpfs 102400 100 102300 1% /run'",
"}",
command,
@@ -332,6 +350,52 @@ test("getServerStats uses mount metadata when df filesystem types are unavailabl
assert.equal(result.stats.diskTotal, 100);
});
function runStatsCommandWithUntypedScopedIpv6NfsDf(command) {
const script = [
"uname() { printf '%s\\n' Linux; }",
"nproc() { printf '%s\\n' 2; }",
"ps() { return 1; }",
"top() { return 1; }",
"mount() { return 1; }",
"df() {",
" if [ \"$1\" = '-kPT' ]; then return 1; fi",
" printf '%s\\n' 'Filesystem 1024-blocks Used Available Capacity Mounted on'",
" printf '%s\\n' '/dev/sda1 104857600 20971520 83886080 20% /'",
" printf '%s\\n' '[fe80::1%eth0]:/export 20971520000 8388608000 12582912000 40% /mnt/nfs6'",
" printf '%s\\n' 'ceph-fuse 12582912000 6291456000 6291456000 50% /mnt/ceph'",
" printf '%s\\n' 'gluster 15728640000 7340032000 8388608000 47% /mnt/gluster'",
"}",
command,
].join("\n");
return spawnSync("sh", ["-c", script], { encoding: "utf8" });
}
test("getServerStats excludes scoped IPv6 NFS sources without filesystem types", async () => {
const sessions = new Map();
sessions.set("sid", {
type: "ssh",
_reuseEndpoint: { hostname: "nfs6.example.test", port: 22 },
conn: {
exec(command, cb) {
const execution = runStatsCommandWithUntypedScopedIpv6NfsDf(command);
assert.equal(execution.status, 0, execution.stderr);
cb(null, fakeStream(execution.stdout));
},
},
});
const api = makeSessionOps(sessions);
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
assert.equal(result.success, true);
assert.deepEqual(result.stats.disks, [
{ mountPoint: "/", used: 20, total: 100, percent: 20, capacityKey: "/dev/sda1" },
]);
assert.equal(result.stats.diskPercent, 20);
assert.equal(result.stats.diskUsed, 20);
assert.equal(result.stats.diskTotal, 100);
});
test("getServerStats does not fall back to a root FUSE quota", async () => {
const sessions = new Map();
sessions.set("sid", {
@@ -382,6 +446,50 @@ test("getServerStats keeps a local fuseblk root filesystem", async () => {
assert.equal(result.stats.diskTotal, 1024);
});
function runStatsCommandWithLoopRootDf(command) {
const script = [
"uname() { printf '%s\\n' Linux; }",
"nproc() { printf '%s\\n' 2; }",
"ps() { return 1; }",
"top() { return 1; }",
"mount() { return 1; }",
"df() {",
" if [ \"$1\" != '-kPT' ]; then return 1; fi",
" printf '%s\\n' 'Filesystem Type 1024-blocks Used Available Capacity Mounted on'",
" printf '%s\\n' '/dev/loop0 ext4 8388608 2097152 6291456 25% /'",
" printf '%s\\n' '/dev/loop1 squashfs 131072 131072 0 100% /snap/example/1'",
"}",
command,
].join("\n");
return spawnSync("sh", ["-c", script], { encoding: "utf8" });
}
test("getServerStats keeps a loop-backed root while skipping snap loops", async () => {
const sessions = new Map();
sessions.set("sid", {
type: "ssh",
_reuseEndpoint: { hostname: "ct-loop.example.test", port: 22 },
conn: {
exec(command, cb) {
const execution = runStatsCommandWithLoopRootDf(command);
assert.equal(execution.status, 0, execution.stderr);
cb(null, fakeStream(execution.stdout));
},
},
});
const api = makeSessionOps(sessions);
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
assert.equal(result.success, true);
assert.deepEqual(result.stats.disks, [
{ mountPoint: "/", used: 2, total: 8, percent: 25, capacityKey: "/dev/loop0", filesystemType: "ext4" },
]);
assert.equal(result.stats.diskPercent, 25);
assert.equal(result.stats.diskUsed, 2);
assert.equal(result.stats.diskTotal, 8);
});
test("getServerStats reads commands after BusyBox top's CPU column", async () => {
const sessions = new Map();
sessions.set("sid", {
@@ -748,6 +856,64 @@ test("getServerStats parses macOS stats and avoids blocking top command", async
assert.equal(typeof result.stats.latencyMs, "number");
});
test("getServerStats keeps every remote command within Dropbear's command limit", async () => {
const commands = [];
const sessions = new Map();
sessions.set("sid", {
type: "ssh",
_reuseEndpoint: { hostname: "openwrt.example.test", port: 22 },
conn: {
exec(command, cb) {
commands.push(command);
const output = command.includes("CPURAW:")
? `NC_LATENCY_MARK|${LINUX_STATS}`
: command.includes("DISKS:")
? "DISKS:"
: "";
cb(null, fakeStream(output));
},
},
});
const api = makeSessionOps(sessions);
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
assert.equal(result.success, true);
assert.equal(commands.length, 2, "Linux base and disk stats must use separate commands");
for (const command of commands) {
assert.ok(
Buffer.byteLength(command, "utf8") <= 9000,
`remote command is ${Buffer.byteLength(command, "utf8")} bytes`,
);
}
});
test("getServerStats settles when the split disk command fails", async () => {
const sessions = new Map();
sessions.set("sid", {
type: "ssh",
conn: {
exec(command, cb) {
if (command.includes('echo "DISKS:$disks"')) {
cb(new Error("disk stats rejected"));
return;
}
cb(null, fakeStream(`NC_LATENCY_MARK|${LINUX_STATS}`));
},
},
});
const api = makeSessionOps(sessions);
const result = await Promise.race([
api.getServerStats({ sender: {} }, { sessionId: "sid" }),
new Promise((resolve) => setTimeout(() => resolve({ timedOut: true }), 100)),
]);
assert.notEqual(result.timedOut, true);
assert.equal(result.success, false);
assert.equal(result.error, "disk stats rejected");
});
test("getServerStats reports pending (not a hard failure) for a Mosh session before the handshake swap", async () => {
const sessions = new Map();
// Connected (renderer polls) but moshStatsAuth not yet assigned.
+41 -82
View File
@@ -15,8 +15,8 @@ const {
} = require("../terminalInterruptDiagnostics.cjs");
const { runWhenProxyConnectionReady } = require("../proxyUtils.cjs");
const { getAttachHomeWebContentsId } = require("../terminalAttachRestore.cjs");
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
const { openBoundedSshShellCallback, isSshChannelOpenRateLimitedError } = require("../boundedSshChannelOpen.cjs");
const { openBoundedSshShellCallback } = require("../boundedSshChannelOpen.cjs");
const { listInteractiveShellPids: listInteractiveShellPidsShared } = require("../sshInteractiveShells.cjs");
const {
annotateMacLocalNetworkErrorMessage,
resolveFirstTcpEndpoint,
@@ -189,86 +189,9 @@ async function prepareAgentForwardingOptions(options, resolveForwardingAgentSock
function createStartSessionApi(ctx) {
with (ctx) {
const listInteractiveShellPids = async (conn) => {
if (!conn || typeof conn.exec !== "function") {
return Promise.resolve({ available: false, pids: [], ages: {} });
}
const scanCompleteMarker = "__NETCATTY_SHELL_SCAN_COMPLETE__";
// Emit "pid etimes" when possible. etimes (elapsed seconds) is a
// wrap-safe ordering key: higher means older. Plain "pid" remains valid
// for hosts that lack etimes.
const script = `SELF=$$
ps_output=$(ps -e -o pid=,ppid=,tty=,comm=,etimes= 2>/dev/null) || ps_output=$(ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null) || exit 69
{
printf '%s\n' "$ps_output" | awk -v pp="$PPID" -v self="$SELF" '
function isshell(c) { sub(/^.*\\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
$1 != self && $2 == pp && $3 !~ /^\\?+$/ && isshell($4) {
if (NF >= 5 && $5 ~ /^[0-9]+$/) print $1, $5+0
else print $1
}
'
if [ -r /proc/$SELF/environ ]; then
conn=$(tr '\\0' '\\n' < /proc/$SELF/environ 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n1)
if [ -n "$conn" ]; then
for d in /proc/[0-9]*; do
pid=$(basename "$d")
[ "$pid" = "$SELF" ] && continue
[ -r "$d/environ" ] || continue
conn2=$(tr '\\0' '\\n' < "$d/environ" 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n1)
[ "$conn2" = "$conn" ] || continue
comm=$(cat "$d/comm" 2>/dev/null)
case "$comm" in sh|bash|zsh|fish|ksh|dash|ash|csh|tcsh) ;; *) continue ;; esac
ppid=$(awk '{ print $4 }' "$d/stat" 2>/dev/null)
pcomm=$(cat "/proc/$ppid/comm" 2>/dev/null)
case "$pcomm" in sshd|dropbear|dropbearmulti) ;; *) continue ;; esac
tty=$(ps -p "$pid" -o tty= 2>/dev/null | tr -d '[:space:]')
[ -n "$tty" ] && [ "$tty" != "?" ] || continue
etimes=$(ps -p "$pid" -o etimes= 2>/dev/null | tr -d '[:space:]')
case "$etimes" in
''|*[!0-9]*) printf '%s\\n' "$pid" ;;
*) printf '%s %s\\n' "$pid" "$etimes" ;;
esac
done
fi
fi
} | awk '/^[0-9]+/ && !seen[$1]++ { print }'
printf '%s\n' '${scanCompleteMarker}'`;
try {
const result = await executeBoundedSshCommand(
conn,
`exec sh -c ${quoteShellArg(script)}`,
{
openingTimeoutMs: 1500,
runTimeoutMs: 1500,
maxOutputBytes: 1024 * 1024,
},
);
const lines = result.stdout.split(/\r?\n/);
const completed = lines.includes(scanCompleteMarker);
const available = completed && (result.code === null || result.code === 0);
const pids = [];
const ages = {};
if (available) {
for (const line of lines) {
const match = /^(\d+)(?:\s+(\d+))?$/.exec(String(line || "").trim());
if (!match) continue;
const pid = match[1];
if (!pids.includes(pid)) pids.push(pid);
if (match[2] !== undefined) ages[pid] = Number(match[2]);
}
}
return { available, pids, ages };
} catch (error) {
return {
available: false,
rateLimited: isSshChannelOpenRateLimitedError(error),
pids: [],
ages: {},
};
}
};
const listInteractiveShellPids = (conn) => listInteractiveShellPidsShared(conn, {
quoteShellArg,
});
const listInteractiveShellPidsResilient = async (conn, opts = {}) => {
const attempts = Math.max(1, Number(opts.attempts) || 1);
@@ -546,6 +469,10 @@ printf '%s\n' '${scanCompleteMarker}'`;
});
return;
}
if (session.blockUntargetedCwdProbe && session.pendingCwdRecoveryAfterUserCommand) {
session.pendingCwdRecoveryAfterUserCommand = false;
session.allowCwdRecovery = true;
}
// data is Buffer from ssh2 — feed raw bytes to ZMODEM sentry.
// In normal mode, sentry's onData callback handles decoding and buffering.
sshZmodemSentry.consume(data);
@@ -834,7 +761,13 @@ printf '%s\n' '${scanCompleteMarker}'`;
failReuse(setupErr);
return;
}
const reconnectAfterLastShellClose =
consumePendingShellReconnectRisk(connRef);
const copiedSession = sessions.get(sessionId);
if (copiedSession && reconnectAfterLastShellClose) {
copiedSession.blockUntargetedCwdProbe = true;
copiedSession.parkedReconnectRisk = reconnectAfterLastShellClose;
}
if (copiedSession) {
if (typeof transferConnectionRef === "function") {
transferConnectionRef(refHolder, copiedSession);
@@ -873,6 +806,32 @@ printf '%s\n' '${scanCompleteMarker}'`;
// copy source (or another tab) still lacks shellPid — otherwise
// waitForNew sees multiple "new" PIDs and returns null.
const needsUntrackedReconcile = listUnassignedSiblings().length > 0;
const blockedEndpointSibling = !options.sourceSessionId
&& listUnassignedSiblings().some(
(candidate) => candidate.blockUntargetedCwdProbe === true,
);
// Idle-park reconnect after the last interactive shell closed:
// no sibling tabs share this transport, so post-open discovery
// cannot disambiguate anything. Skip the exec — bastions often
// tear down the interactive session when a second channel opens,
// racing start completion as
// "Terminal session closed before its output route opened" (#2923).
// Copy Tab (sourceSessionId) still needs discovery even when the
// source closes mid-open and leaves an empty baseline.
if (
baseline.length === 0
&& !options.sourceSessionId
&& (!needsUntrackedReconcile || blockedEndpointSibling)
) {
if (copiedSession && blockedEndpointSibling) {
copiedSession.blockUntargetedCwdProbe = true;
copiedSession.parkedReconnectRisk = {
oldShellPids: [],
hasUnknownOldShell: true,
};
}
return null;
}
if (baseline.length === 0 || needsUntrackedReconcile) {
const discovery = await listInteractiveShellPidsResilient(conn, {
initialDelayMs: discoveryBackoffMs,
+74 -3
View File
@@ -607,6 +607,25 @@ function sameEndpoint(a, b) {
&& left.keepaliveCountMax === right.keepaliveCountMax;
}
function sameTransportEndpoint(a, b) {
const left = normalizeEndpoint(a);
const right = normalizeEndpoint(b);
if (!left || !right) return false;
// SFTP sudo changes the channel/subsystem setup, not the authenticated SSH
// transport. Channel borrowers (SFTP/port forwarding) may reuse the terminal
// connection and apply sudo when opening their own channel.
if (left.hostId && right.hostId && left.hostId !== right.hostId) return false;
return left.hostname === right.hostname
&& left.port === right.port
&& left.username === right.username
&& left.protocol === right.protocol
&& left.jumpFingerprint === right.jumpFingerprint
&& left.proxyFingerprint === right.proxyFingerprint
&& left.authFingerprint === right.authFingerprint
&& left.keepaliveIntervalMs === right.keepaliveIntervalMs
&& left.keepaliveCountMax === right.keepaliveCountMax;
}
/**
* True when a requested open can reuse an existing transport/session endpoint.
*
@@ -619,7 +638,11 @@ function sameEndpoint(a, b) {
* transport; cannot use a nofwd transport when the request needs ForwardAgent.
*/
function endpointAllowsReuse(requested, existing, kind = "channel") {
if (!sameEndpoint(requested, existing)) return false;
if (kind === "shell") {
if (!sameEndpoint(requested, existing)) return false;
} else if (!sameTransportEndpoint(requested, existing)) {
return false;
}
const req = normalizeEndpoint(requested);
const have = normalizeEndpoint(existing);
if (!req || !have) return false;
@@ -884,6 +907,10 @@ function createTransport({
idleSince: null,
idleDeadlineAt: null,
createdAt: nowFn(),
pendingShellReconnectRisk: null,
closedShellPids: new Set(),
closedShellPidUnknown: false,
shellCloseGeneration: 0,
meta: meta || null,
endedReason: null,
_poolOnConnectionClose: null,
@@ -963,6 +990,9 @@ function transferConnectionRef(fromHolder, toHolder) {
if (!lease) return false;
lease.holder = toHolder;
if (lease.kind === LEASE_KINDS.shell && toHolder?.stream) {
lease.meta = { ...(lease.meta || {}), activeShellChannel: true };
}
leasesById.set(leaseId, { transport, holder: toHolder });
fromHolder.connRef = null;
@@ -1014,10 +1044,35 @@ function returnTransport(leaseIdOrHolder) {
}
const { transport } = entry;
const releasedLease = transport.leases.get(leaseId);
leasesById.delete(leaseId);
transport.leases.delete(leaseId);
transport.count = transport.leases.size;
if (
releasedLease?.kind === LEASE_KINDS.shell
&& releasedLease.meta?.activeShellChannel === true
) {
transport.shellCloseGeneration = (transport.shellCloseGeneration || 0) + 1;
const releasedPid = String(releasedLease.holder?.shellPid || "");
if (/^\d+$/.test(releasedPid)) transport.closedShellPids.add(releasedPid);
else transport.closedShellPidUnknown = true;
const hasActiveShell = [...transport.leases.values()].some(
(lease) => lease.kind === LEASE_KINDS.shell && lease.meta?.activeShellChannel === true,
);
if (!hasActiveShell) {
// A locally closed shell channel can outlive its lease briefly on the
// server. Remember that provenance even when SFTP/forward leases keep the
// transport live, so the next shell does not guess cwd from the old PID.
transport.pendingShellReconnectRisk = {
oldShellPids: [...transport.closedShellPids],
hasUnknownOldShell: transport.closedShellPidUnknown,
};
transport.closedShellPids.clear();
transport.closedShellPidUnknown = false;
}
}
if (entry.holder && typeof entry.holder === "object") {
if (entry.holder.connRef === transport) entry.holder.connRef = null;
entry.holder._sshTransportLeaseId = null;
@@ -1044,6 +1099,13 @@ function returnTransport(leaseIdOrHolder) {
};
}
function consumePendingShellReconnectRisk(transport) {
if (!transport?.pendingShellReconnectRisk) return false;
const risk = transport.pendingShellReconnectRisk;
transport.pendingShellReconnectRisk = null;
return risk;
}
function discardTransport(transportOrId, reason = "discard") {
const transport = typeof transportOrId === "string"
? transportsById.get(transportOrId)
@@ -1239,7 +1301,11 @@ function createConnectionRef(session, conn, chainConnections) {
holder: session,
// Unique per connection generation: same sessionId can reconnect while an
// old lease is still draining (same-session reconnect path).
meta: { source: "createConnectionRef", sessionId: session?.id || null },
meta: {
source: "createConnectionRef",
sessionId: session?.id || null,
activeShellChannel: true,
},
});
return transport;
@@ -1268,7 +1334,11 @@ function acquireConnectionRef(session, connRef) {
holder: session,
// Always allocate a unique lease id — stable session/sftp ids can collide
// across reconnect generations while old leases drain.
meta: { source: "acquireConnectionRef", holderId: session?.id || null },
meta: {
source: "acquireConnectionRef",
holderId: session?.id || null,
activeShellChannel: kind === LEASE_KINDS.shell && Boolean(session?.stream),
},
});
}
@@ -1401,5 +1471,6 @@ module.exports = {
acquireConnectionRef,
releaseConnectionRef,
transferConnectionRef,
consumePendingShellReconnectRisk,
findReusableSession,
};
@@ -10,6 +10,7 @@ const {
acquireConnectionRef,
releaseConnectionRef,
transferConnectionRef,
consumePendingShellReconnectRisk,
findReusableSession,
createTransport,
borrowTransport,
@@ -335,6 +336,20 @@ test("releaseConnectionRef on a session without a descriptor is a safe no-op", (
assert.equal(releaseConnectionRef(undefined), false);
});
test("last shell close marks reconnect risk while an SFTP lease keeps the transport live", () => {
const owner = { id: "shell", stream: {} };
const sftp = { id: "sftp", __sshLeaseKind: "sftp" };
const transport = createConnectionRef(owner, makeConn(), []);
acquireConnectionRef(sftp, transport);
assert.equal(releaseConnectionRef(owner), false);
assert.deepEqual(consumePendingShellReconnectRisk(transport), {
oldShellPids: [],
hasUnknownOldShell: true,
});
assert.equal(consumePendingShellReconnectRisk(transport), false);
});
test("single-channel connection parks on release when TTL is 0", () => {
const conn = makeConn();
const chain = [makeChainConn()];
@@ -943,6 +958,16 @@ test("connection endpoint can record negotiated agent forwarding instead of the
assert.equal(normalizeEndpoint(endpoint).agentForwarding, false);
});
test("channel reuse can borrow a normal SSH transport for sudo SFTP", () => {
const base = { hostname: "sudo.example", username: "root", port: 22 };
const normalShell = buildConnectionReuseEndpoint(base, { sftpSudo: false });
const sudoSftp = buildConnectionReuseEndpoint(base, { sftpSudo: true });
assert.notEqual(buildEndpointKey(normalShell), buildEndpointKey(sudoSftp));
assert.equal(endpointAllowsReuse(sudoSftp, normalShell, "channel"), true);
assert.equal(endpointAllowsReuse(sudoSftp, normalShell, "shell"), false);
});
test("endpointAllowsReuse: shell exact vs channel asymmetric for agentForwarding", () => {
const base = { hostname: "a.example", username: "root", port: 22 };
// Channel (default): request needs ForwardAgent, existing never enabled it -> reject.
+91
View File
@@ -0,0 +1,91 @@
"use strict";
const { executeBoundedSshCommand } = require("./boundedSshExec.cjs");
const { isSshChannelOpenRateLimitedError } = require("./boundedSshChannelOpen.cjs");
const SCAN_COMPLETE_MARKER = "__NETCATTY_SHELL_SCAN_COMPLETE__";
function buildInteractiveShellPidCommand(quoteShellArg) {
const script = `SELF=$$
ps_output=$(ps -e -o pid=,ppid=,tty=,comm=,etimes= 2>/dev/null) || ps_output=$(ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null) || exit 69
{
printf '%s\n' "$ps_output" | awk -v pp="$PPID" -v self="$SELF" '
function isshell(c) { sub(/^.*\\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
$1 != self && $2 == pp && $3 !~ /^\\?+$/ && isshell($4) {
if (NF >= 5 && $5 ~ /^[0-9]+$/) print $1, $5+0
else print $1
}
'
if [ -r /proc/$SELF/environ ]; then
conn=$(tr '\\0' '\\n' < /proc/$SELF/environ 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n1)
if [ -n "$conn" ]; then
for d in /proc/[0-9]*; do
pid=$(basename "$d")
[ "$pid" = "$SELF" ] && continue
[ -r "$d/environ" ] || continue
conn2=$(tr '\\0' '\\n' < "$d/environ" 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n1)
[ "$conn2" = "$conn" ] || continue
comm=$(cat "$d/comm" 2>/dev/null)
case "$comm" in sh|bash|zsh|fish|ksh|dash|ash|csh|tcsh) ;; *) continue ;; esac
ppid=$(awk '{ print $4 }' "$d/stat" 2>/dev/null)
pcomm=$(cat "/proc/$ppid/comm" 2>/dev/null)
case "$pcomm" in sshd|dropbear|dropbearmulti) ;; *) continue ;; esac
tty=$(ps -p "$pid" -o tty= 2>/dev/null | tr -d '[:space:]')
[ -n "$tty" ] && [ "$tty" != "?" ] || continue
etimes=$(ps -p "$pid" -o etimes= 2>/dev/null | tr -d '[:space:]')
case "$etimes" in
''|*[!0-9]*) printf '%s\\n' "$pid" ;;
*) printf '%s %s\\n' "$pid" "$etimes" ;;
esac
done
fi
fi
} | awk '/^[0-9]+/ && !seen[$1]++ { print }'
printf '%s\n' '${SCAN_COMPLETE_MARKER}'`;
return `exec sh -c ${quoteShellArg(script)}`;
}
async function listInteractiveShellPids(conn, options = {}) {
if (!conn || typeof conn.exec !== "function" || typeof options.quoteShellArg !== "function") {
return { available: false, pids: [], ages: {} };
}
try {
const result = await executeBoundedSshCommand(
conn,
buildInteractiveShellPidCommand(options.quoteShellArg),
{
openingTimeoutMs: options.openingTimeoutMs ?? 1500,
runTimeoutMs: options.runTimeoutMs ?? 1500,
maxOutputBytes: 1024 * 1024,
setTimeoutFn: options.setTimeoutFn,
clearTimeoutFn: options.clearTimeoutFn,
invalidateOnOpenTimeout: options.invalidateOnOpenTimeout,
},
);
const lines = result.stdout.split(/\r?\n/);
const available = lines.includes(SCAN_COMPLETE_MARKER)
&& (result.code === null || result.code === 0);
const pids = [];
const ages = {};
if (available) {
for (const line of lines) {
const match = /^(\d+)(?:\s+(\d+))?$/.exec(String(line || "").trim());
if (!match) continue;
const pid = match[1];
if (!pids.includes(pid)) pids.push(pid);
if (match[2] !== undefined) ages[pid] = Number(match[2]);
}
}
return { available, pids, ages };
} catch (error) {
return {
available: false,
rateLimited: isSshChannelOpenRateLimitedError(error),
openTimedOut: error?.code === "SSH_EXEC_OPEN_TIMEOUT",
pids: [],
ages: {},
};
}
}
module.exports = { buildInteractiveShellPidCommand, listInteractiveShellPids };
+47 -1
View File
@@ -1528,6 +1528,18 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
}, trace);
}
const writeResult = session.stream.write(outgoing);
if (
session.blockUntargetedCwdProbe
&& !payload.automated
&& payload.sensitive !== true
&& !isTerminalReportSequence(data)
&& /[\r\n]/.test(String(data || ""))
) {
// Arm recovery only after this generation's interactive stream really
// accepted a submitted user command. Blocked transfers and failed or
// superseded async input never reach this point.
session.pendingCwdRecoveryAfterUserCommand = true;
}
if (shouldLogInterruptWrite) {
logTerminalInterruptDebug("ssh-stream-write-done", {
writeResult,
@@ -1963,6 +1975,21 @@ function clearSessionPtyBuffer(event, payload) {
}
}
function shouldRevokeOpenedSessionOwnership(payload) {
// Disconnect / reconnect tear down the transport but keep the tab and session
// id. Those closes must not drop host_open ownership.
return payload?.retainOwnership !== true;
}
function reportOpenedSessionClosed(sessionId, payload) {
if (!shouldRevokeOpenedSessionOwnership(payload)) return;
try {
reportOpenedSessionActivity?.({ sessionId, phase: "closed" });
} catch {
// Ownership cleanup must not interfere with session teardown.
}
}
/**
* Close a session
*/
@@ -1970,6 +1997,9 @@ function closeSession(event, payload) {
const session = sessions.get(payload.sessionId);
const {
abortPendingBoot,
forgetBootEpoch,
hasNewerBootEpoch,
hasPendingBootAfter,
sessionMatchesBootEpoch,
} = require("./sessionBootEpoch.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
@@ -1983,8 +2013,22 @@ function closeSession(event, payload) {
if (session && !sessionMatchesBootEpoch(session, payload?.bootEpoch)) {
return { skipped: true, reason: "boot-epoch-mismatch" };
}
if (!session) {
// A direct-mode backend can remove a naturally exited session before the
// renderer closes its tab. That later close is still authoritative for AI
// ownership, unless it belongs to an older boot than a pending reconnect.
if (
hasPendingBootAfter(payload.sessionId, payload?.bootEpoch)
|| hasNewerBootEpoch(payload.sessionId, payload?.bootEpoch)
) {
return { skipped: true, reason: "boot-epoch-mismatch" };
}
releaseAttachedSessionState(payload.sessionId);
reportOpenedSessionClosed(payload.sessionId, payload);
forgetBootEpoch(payload.sessionId, payload?.bootEpoch);
return { closed: false, reason: "missing" };
}
releaseAttachedSessionState(payload.sessionId);
if (!session) return { closed: false, reason: "missing" };
terminalFlowPauseArbiter.clearSession(payload.sessionId);
session.closed = true;
fanoutSessionLifecycleEvent(
@@ -2056,6 +2100,8 @@ function closeSession(event, payload) {
}
ptyProcessTree.unregisterPid(payload.sessionId);
sessions.delete(payload.sessionId);
forgetBootEpoch(payload.sessionId, payload?.bootEpoch);
reportOpenedSessionClosed(payload.sessionId, payload);
return { closed: true };
}
@@ -29,3 +29,133 @@ test("renderer terminal input reports activity for the matching session", () =>
{ sessionId: "session-1", phase: "touch" },
]);
});
test("direct-mode close reports ownership cleanup after the backend already exited", () => {
const activity = [];
terminalBridge.init({
sessions: new Map(),
electronModule: {},
reportOpenedSessionActivity: (event) => activity.push(event),
});
const result = terminalBridge.closeSession({ sender: {} }, {
sessionId: "already-exited",
bootEpoch: 3,
});
assert.deepEqual(result, { closed: false, reason: "missing" });
assert.deepEqual(activity, [{ sessionId: "already-exited", phase: "closed" }]);
});
test("direct-mode disconnect close keeps host_open ownership for reconnect", () => {
const activity = [];
const sessions = new Map([
["session-keep", {
proc: { kill() {} },
webContentsId: 1,
}],
]);
terminalBridge.init({
sessions,
electronModule: { webContents: { fromId: () => ({ isDestroyed: () => true, send() {} }) } },
reportOpenedSessionActivity: (event) => activity.push(event),
});
const result = terminalBridge.closeSession({ sender: {} }, {
sessionId: "session-keep",
retainOwnership: true,
});
assert.deepEqual(result, { closed: true });
assert.equal(sessions.has("session-keep"), false);
assert.deepEqual(activity, []);
});
test("direct-mode disconnect close of an already-exited session keeps ownership", () => {
const activity = [];
terminalBridge.init({
sessions: new Map(),
electronModule: {},
reportOpenedSessionActivity: (event) => activity.push(event),
});
const result = terminalBridge.closeSession({ sender: {} }, {
sessionId: "already-exited",
bootEpoch: 3,
retainOwnership: true,
});
assert.deepEqual(result, { closed: false, reason: "missing" });
assert.deepEqual(activity, []);
});
test("stale direct-mode close cannot clean up a newer same-id boot", () => {
const activity = [];
const { claimSessionSlot } = require("./sessionBootEpoch.cjs");
const lifecycle = new Map();
claimSessionSlot(lifecycle, "reconnected", {}, 4);
lifecycle.delete("reconnected");
terminalBridge.init({
sessions: new Map(),
electronModule: {},
reportOpenedSessionActivity: (event) => activity.push(event),
});
const stale = terminalBridge.closeSession({ sender: {} }, {
sessionId: "reconnected",
bootEpoch: 3,
});
assert.deepEqual(stale, { skipped: true, reason: "boot-epoch-mismatch" });
assert.deepEqual(activity, []);
const current = terminalBridge.closeSession({ sender: {} }, {
sessionId: "reconnected",
bootEpoch: 4,
});
assert.deepEqual(current, { closed: false, reason: "missing" });
assert.deepEqual(activity, [{ sessionId: "reconnected", phase: "closed" }]);
});
test("only a submitted non-sensitive user command arms cwd recovery", () => {
const stream = { write() { return true; } };
const session = { stream, webContentsId: 1, blockUntargetedCwdProbe: true };
const sessions = new Map([["session-1", session]]);
terminalBridge.init({ sessions, electronModule: { webContents: { fromId: () => null } } });
terminalBridge.writeToSession({ sender: {} }, {
sessionId: "session-1", data: "pwd", automated: false,
});
assert.notEqual(session.pendingCwdRecoveryAfterUserCommand, true);
terminalBridge.writeToSession({ sender: {} }, {
sessionId: "session-1", data: "secret\r", sensitive: true,
});
assert.notEqual(session.pendingCwdRecoveryAfterUserCommand, true);
terminalBridge.writeToSession({ sender: {} }, {
sessionId: "session-1", data: "pwd\r", automated: false,
});
assert.equal(session.pendingCwdRecoveryAfterUserCommand, true);
});
test("blocked transfer input and failed writes do not arm cwd recovery", () => {
const blocked = {
stream: { write() { throw new Error("must not write during transfer"); } },
webContentsId: 1,
blockUntargetedCwdProbe: true,
zmodemSentry: { isActive: () => true, cancel() {} },
};
const failed = {
stream: { write() { throw Object.assign(new Error("closed"), { code: "EPIPE" }); } },
webContentsId: 1,
blockUntargetedCwdProbe: true,
};
const sessions = new Map([["blocked", blocked], ["failed", failed]]);
terminalBridge.init({ sessions, electronModule: { webContents: { fromId: () => null } } });
terminalBridge.writeToSession({ sender: {} }, { sessionId: "blocked", data: "pwd\r" });
terminalBridge.writeToSession({ sender: {} }, { sessionId: "failed", data: "pwd\r" });
assert.notEqual(blocked.pendingCwdRecoveryAfterUserCommand, true);
assert.notEqual(failed.pendingCwdRecoveryAfterUserCommand, true);
});
+32 -6
View File
@@ -1125,13 +1125,27 @@ function createTerminalWorkerManager(options = {}) {
}
}
function notifySessionClosed(sessionId, reason) {
function notifySessionClosed(sessionId, reason, options = {}) {
if (!sessionId) return;
const explicit = options?.explicit === true;
const event = explicit
? { sessionId, reason, explicit: true }
: { sessionId, reason };
for (const listener of [...sessionClosedListeners]) {
try { listener(Object.freeze({ sessionId, reason })); } catch {}
try { listener(Object.freeze(event)); } catch {}
}
}
function notifyRendererSessionClosed(sessionId, payload) {
// Disconnect / reconnect keep the tab and session id, so they must not
// look like an explicit tab close to host_open ownership.
if (payload?.retainOwnership === true) {
notifySessionClosed(sessionId, "closed");
return;
}
notifySessionClosed(sessionId, "closed", { explicit: true });
}
function drainOutputSession(sessionId, requestId) {
if (!sessionId || !requestId || !outputPortReady.has(sessionId)) return false;
const worker = ensureStarted();
@@ -1473,7 +1487,9 @@ function createTerminalWorkerManager(options = {}) {
clearAttachHome(sessionId);
terminalOutputChannel?.closeSession?.(sessionId);
suppressedPendingOutputSessions.add(sessionId);
notifySessionClosed(sessionId, message.payload?.reason || "superseded");
// Always label superseded exits as such — payload reason may be
// "closed"/"error" and must not be mistaken for an authoritative end.
notifySessionClosed(sessionId, "superseded");
const targets = new Set([displayWebContentsId, homeWebContentsId]);
if (onRendererEvent) {
for (const webContentsId of targets) {
@@ -1502,7 +1518,7 @@ function createTerminalWorkerManager(options = {}) {
finishPendingSessionStart(originEntry);
pending.delete(message.originRequestId);
originEntry.reject(new Error("Terminal session was superseded by a newer start request"));
notifySessionClosed(sessionId, message.payload?.reason);
notifySessionClosed(sessionId, "superseded");
return;
}
const displayWebContentsId =
@@ -1752,6 +1768,11 @@ function createTerminalWorkerManager(options = {}) {
&& payload?.sessionId
&& closedSessions.has(payload.sessionId)
&& !pendingSessionStartSequences.has(payload.sessionId)) {
// Worker already tombstoned the session (clean exit / crash). A later
// renderer close still has to notify host listeners as explicit so
// auto-close can drop host_open ownership, unless this is a disconnect
// that keeps the tab for reconnect.
notifyRendererSessionClosed(payload.sessionId, payload);
return Promise.resolve(undefined);
}
const requestId = randomUUID();
@@ -1813,7 +1834,9 @@ function createTerminalWorkerManager(options = {}) {
finishPendingSessionStart(entry);
entry?.reject(error);
}
if (notifyClosedAfterPost) notifySessionClosed(payload.sessionId, "closed");
if (notifyClosedAfterPost) {
notifyRendererSessionClosed(payload.sessionId, payload);
}
return promise;
}
@@ -1900,6 +1923,7 @@ function createTerminalWorkerManager(options = {}) {
&& closedSessions.has(payload.sessionId)
&& !pendingSessionStartSequences.has(payload.sessionId)) {
closeOutputSession(payload.sessionId);
notifyRendererSessionClosed(payload.sessionId, payload);
return;
}
if (channel === "netcatty:close" && payload?.sessionId) {
@@ -1921,7 +1945,9 @@ function createTerminalWorkerManager(options = {}) {
retireWorkerAfterIpcFailure(worker, error);
postError = error;
}
if (shouldNotifyClosed) notifySessionClosed(payload.sessionId, "closed");
if (shouldNotifyClosed) {
notifyRendererSessionClosed(payload.sessionId, payload);
}
if (postError) throw postError;
return;
}
+107 -12
View File
@@ -2162,16 +2162,104 @@ test("explicit close notifies host session lifecycle exactly once before the wor
await start;
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
child.emit("message", {
kind: "renderer-event",
webContentsId: 7,
channel: "netcatty:exit",
payload: { sessionId: "session-1", reason: "exited" },
});
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
});
test("retainOwnership close does not mark host_open cleanup as explicit", async () => {
const child = new FakeChild();
const closed = [];
const manager = createTerminalWorkerManager({
utilityProcess: { fork: () => child },
terminalOutputChannel: { openSession: () => true, closeSession() {} },
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
workerScriptPath: "/worker.cjs",
});
manager.onSessionClosed((event) => closed.push(event));
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
child.emit("message", {
kind: "response",
requestId: child.messages[0].requestId,
result: { sessionId: "session-1" },
});
await start;
manager.send("netcatty:close", {
sessionId: "session-1",
retainOwnership: true,
}, { webContentsId: 7 });
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
});
async function startWorkerSessionForLifecycle(manager, child) {
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
child.emit("message", {
kind: "response",
requestId: child.messages[0].requestId,
result: { sessionId: "session-1" },
});
await start;
}
function createLifecycleManager(child, closed) {
const manager = createTerminalWorkerManager({
utilityProcess: { fork: () => child },
terminalOutputChannel: { openSession: () => true, closeSession() {} },
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
workerScriptPath: "/worker.cjs",
});
manager.onSessionClosed((event) => closed.push(event));
return manager;
}
test("fire-and-forget close after a worker exit still emits an explicit lifecycle event", async () => {
const child = new FakeChild();
const closed = [];
const manager = createLifecycleManager(child, closed);
await startWorkerSessionForLifecycle(manager, child);
child.emit("message", {
kind: "renderer-event",
webContentsId: 7,
channel: "netcatty:exit",
payload: { sessionId: "session-1", reason: "exited", exitCode: 0 },
});
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "exited" }]);
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
assert.deepEqual(closed, [
{ sessionId: "session-1", reason: "exited" },
{ sessionId: "session-1", reason: "closed", explicit: true },
]);
});
test("awaited close after a worker exit still emits an explicit lifecycle event", async () => {
const child = new FakeChild();
const closed = [];
const manager = createLifecycleManager(child, closed);
await startWorkerSessionForLifecycle(manager, child);
child.emit("message", {
kind: "renderer-event",
webContentsId: 7,
channel: "netcatty:exit",
payload: { sessionId: "session-1", reason: "exited", exitCode: 0 },
});
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "exited" }]);
await manager.request("netcatty:close:await", { sessionId: "session-1" }, { webContentsId: 7 });
assert.deepEqual(closed, [
{ sessionId: "session-1", reason: "exited" },
{ sessionId: "session-1", reason: "closed", explicit: true },
]);
});
test("a duplicate exit from an old session generation cannot close a reconnected session", async () => {
const child = new FakeChild();
const closed = [];
@@ -2357,7 +2445,7 @@ test("an old-generation exit after explicit close cannot cancel a pending reconn
await reconnect;
assert.equal(manager.hasOpenSession("session-1"), true);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
assert.deepEqual(routed, [{ sessionId: "session-1", data: "NEW-EARLY" }]);
});
@@ -2404,7 +2492,7 @@ test("a closed pending start response records its generation before a later reco
await second;
assert.equal(manager.hasOpenSession("session-1"), true);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
});
test("a much older exit cannot cancel a reconnect after multiple generations", async () => {
@@ -3092,7 +3180,10 @@ test("closing a crashed session is idempotent until a new worker reuses its id",
}
await close;
assert.equal(children.length, 1);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "worker-exit" }]);
assert.deepEqual(closed, [
{ sessionId: "session-1", reason: "worker-exit" },
{ sessionId: "session-1", reason: "closed", explicit: true },
]);
const secondStart = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
const secondChild = children[1];
@@ -3105,6 +3196,7 @@ test("closing a crashed session is idempotent until a new worker reuses its id",
secondChild.emit("exit", 1);
assert.deepEqual(closed, [
{ sessionId: "session-1", reason: "worker-exit" },
{ sessionId: "session-1", reason: "closed", explicit: true },
{ sessionId: "session-1", reason: "worker-exit" },
]);
});
@@ -3615,7 +3707,7 @@ test("an internal same-id replacement exit retires only the old generation", asy
data: "NEW-EARLY",
webContentsId: 8,
}]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
assert.equal(outputOpen, true);
assert.equal(
child.messages.some((message) => (
@@ -3690,7 +3782,7 @@ test("a failed replacement is not reported closed again when the worker exits",
await assert.rejects(replacement, /start failed/u);
child.emit("exit", 1);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
});
test("manager and runtime preserve a completed same-id replacement end to end", async () => {
@@ -3770,7 +3862,7 @@ test("manager and runtime preserve a completed same-id replacement end to end",
{ sessionId: "session-1", data: "OLD", webContentsId: 7 },
{ sessionId: "session-1", data: "NEW-EARLY", webContentsId: 8 },
]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
assert.equal(outputWebContentsId, 8);
assert.equal(
child.messages.some((message) => (
@@ -4183,7 +4275,7 @@ test("a superseded start failure cannot cancel the queued same-id restart", asyn
await second;
assert.equal(manager.hasOpenSession("session-1"), true);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "failed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
});
test("a failed reconnect after worker exit keeps later close cleanup idempotent", async () => {
@@ -4237,7 +4329,10 @@ test("a failed reconnect after worker exit keeps later close cleanup idempotent"
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
assert.equal(children.length, 2);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "worker-exit" }]);
assert.deepEqual(closed, [
{ sessionId: "session-1", reason: "worker-exit" },
{ sessionId: "session-1", reason: "closed", explicit: true },
]);
});
test("a same-id reconnect that outputs before another worker exit closes its new lifecycle", async () => {
@@ -4350,7 +4445,7 @@ test("closing a same-id reconnect before its response closes the new lifecycle o
assert.equal(closeMessage.channel, "netcatty:close");
assert.deepEqual(closed, [
{ sessionId: "session-1", reason: "worker-exit" },
{ sessionId: "session-1", reason: "closed" },
{ sessionId: "session-1", reason: "closed", explicit: true },
]);
secondChild.emit("message", {
@@ -4362,7 +4457,7 @@ test("closing a same-id reconnect before its response closes the new lifecycle o
secondChild.emit("message", { kind: "session-exit", sessionId: "session-1", exitCode: 0 });
assert.deepEqual(closed, [
{ sessionId: "session-1", reason: "worker-exit" },
{ sessionId: "session-1", reason: "closed" },
{ sessionId: "session-1", reason: "closed", explicit: true },
]);
});
@@ -4675,7 +4770,7 @@ test("an awaited close still notifies lifecycle cleanup when worker IPC is alrea
);
await assert.rejects(close, /IPC closed/u);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
assert.equal(manager.hasOpenSession("session-1"), false);
});
+52 -18
View File
@@ -771,11 +771,17 @@ async function handleUpload(zsession, opts) {
const fileStats = filePaths.map((fp) => fs.statSync(fp));
// Conflict handling (SSH only — callbacks absent on local/telnet/serial).
// On any failure we fall back to today's behavior (rz silently skips).
// On probe failure we still offer files; rz -y (drag-drop) or an explicit
// overwrite decision should replace same-named remotes. If the receiver
// still ZSKIPs, we fail below instead of reporting a false success (#2863).
const isDragDropUpload = Boolean(dragDrop?.filePaths?.length);
let plan = { offerIndices: allNames.map((_, i) => i), removeIndices: [], aborted: false };
let probeDir = null;
let probeModes = null;
if (opts.probeReceiveConflicts && opts.requestOverwriteDecision) {
// Drag-drop already starts rz with -y, so let the receiver replace files in
// place. Pre-deleting a conflict would lose the original if the offer or
// transfer fails before the replacement is committed.
if (!isDragDropUpload && opts.probeReceiveConflicts && opts.requestOverwriteDecision) {
try {
const probe = await opts.probeReceiveConflicts(allNames);
if (probe && probe.dir && Array.isArray(probe.existing) && probe.existing.length > 0) {
@@ -803,10 +809,16 @@ async function handleUpload(zsession, opts) {
}
}
const offers = plan.offerIndices.map((i) => ({ filePath: filePaths[i], stat: fileStats[i], name: allNames[i] }));
const offers = plan.offerIndices.map((i) => ({
originalIndex: i,
filePath: filePaths[i],
stat: fileStats[i],
name: allNames[i],
}));
const skippedOfferIndices = [];
for (let i = 0; i < offers.length; i++) {
const { filePath, stat, name } = offers[i];
const { originalIndex, filePath, stat, name } = offers[i];
opts.resetUploadBackpressure?.();
safeSend(getWebContents(), "netcatty:zmodem:progress", {
@@ -831,7 +843,8 @@ async function handleUpload(zsession, opts) {
});
if (!xfer) {
// Receiver skipped this file
// Receiver protected/skipped this file (e.g. rz without -y).
skippedOfferIndices.push(originalIndex);
continue;
}
@@ -892,6 +905,39 @@ async function handleUpload(zsession, opts) {
}
}
// rz re-creates overwritten files with the remote umask, dropping their
// original permission bits. Restore modes for files that landed on disk
// (including when a later offer is ZSKIP'd and we abort the batch — #1079).
// Filter by original offer index (not basename) so a duplicate-name ZSKIP
// does not suppress mode restore for an earlier accepted overwrite.
async function restoreAcceptedOverwriteModes(skippedIndices) {
if (!plan.removeIndices.length || !probeDir || !opts.restoreRemoteModes) return;
const skippedSet = skippedIndices?.length ? new Set(skippedIndices) : null;
const restoreIndices = skippedSet
? plan.removeIndices.filter((i) => !skippedSet.has(i))
: plan.removeIndices;
if (!restoreIndices.length) return;
const restores = buildModeRestores(probeDir, allNames, restoreIndices, probeModes);
if (!restores.length) return;
try {
await opts.restoreRemoteModes(restores);
} catch (err) {
console.warn("[ZMODEM] restoreRemoteModes failed:", err?.message || err);
}
}
if (skippedOfferIndices.length > 0) {
try { zsession.abort(); } catch { /* ignore */ }
abortRemoteProcess(opts.writeToRemote);
await restoreAcceptedOverwriteModes(skippedOfferIndices);
const listed = skippedOfferIndices.map((idx) => allNames[idx]).join(", ");
throw new Error(
skippedOfferIndices.length === offers.length
? `Remote protected existing files and skipped the upload (not overwritten): ${listed}`
: `Remote skipped some files (not overwritten): ${listed}`,
);
}
await waitForUploadHandshake(
zsession.close(),
uploadSessionCloseTimeoutMs,
@@ -899,19 +945,7 @@ async function handleUpload(zsession, opts) {
opts,
);
// rz re-creates overwritten files with the remote umask, dropping their
// original permission bits. Now that everything is on disk, restore them
// to the modes captured before the rm (issue #1079).
if (plan.removeIndices.length && probeDir && opts.restoreRemoteModes) {
const restores = buildModeRestores(probeDir, allNames, plan.removeIndices, probeModes);
if (restores.length) {
try {
await opts.restoreRemoteModes(restores);
} catch (err) {
console.warn("[ZMODEM] restoreRemoteModes failed:", err?.message || err);
}
}
}
await restoreAcceptedOverwriteModes();
} finally {
if (dragDropTempPaths.length) {
+283
View File
@@ -520,3 +520,286 @@ test("handleUpload allows a longer final wait after upload backpressure", async
assert.equal(closed, true);
fs.rmSync(tempDir, { recursive: true, force: true });
});
// Issue #2863: rz protect/skip must not look like a successful upload.
test("handleUpload fails when the remote skips an offered file", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const filePath = path.join(tempDir, "upload.txt");
fs.writeFileSync(filePath, "payload");
let closed = false;
const zsession = {
async send_offer() {
return undefined; // ZSKIP / protect mode
},
async close() {
closed = true;
},
};
await assert.rejects(
handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: () => true,
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.txt"],
}),
}),
/skipped|not overwritten|protect/i,
);
assert.equal(closed, false);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("drag-drop upload relies on rz overwrite without probing, prompting, or pre-deleting", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const filePath = path.join(tempDir, "upload.txt");
fs.writeFileSync(filePath, "payload");
let probed = false;
let prompted = false;
let removed = [];
let offerNames = [];
const zsession = {
async send_offer(params) {
offerNames.push(params.name);
return {
send() {},
async end() {},
};
},
async close() {},
};
await handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: () => true,
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.txt"],
}),
probeReceiveConflicts: async () => {
probed = true;
return {
dir: "/home/u",
existing: ["upload.txt"],
modes: { "upload.txt": "644" },
};
},
requestOverwriteDecision: async () => {
prompted = true;
return { action: "skip", applyToRest: false };
},
removeRemoteFiles: async (paths) => {
removed.push(...paths);
},
});
assert.equal(probed, false);
assert.equal(prompted, false);
assert.deepEqual(removed, []);
assert.deepEqual(offerNames, ["upload.txt"]);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("drag-drop offers duplicate remote names independently without pre-deleting", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const firstPath = path.join(tempDir, "first.txt");
const secondPath = path.join(tempDir, "second.txt");
fs.writeFileSync(firstPath, "first");
fs.writeFileSync(secondPath, "second");
const offered = [];
const removed = [];
const zsession = {
async send_offer(params) {
offered.push(params.name);
return {
send() {},
async end() {},
};
},
async close() {},
};
await handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: () => true,
takeDragDropUpload: () => ({
filePaths: [firstPath, secondPath],
remoteNames: ["x.txt", "x.txt"],
}),
probeReceiveConflicts: async () => {
throw new Error("drag-drop should not probe conflicts");
},
requestOverwriteDecision: async () => {
throw new Error("drag-drop should not prompt for conflicts");
},
removeRemoteFiles: async (paths) => {
removed.push(...paths);
},
});
assert.deepEqual(offered, ["x.txt", "x.txt"]);
assert.deepEqual(removed, []);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("failed drag-drop offer does not delete any existing conflict targets", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const firstPath = path.join(tempDir, "a.txt");
const secondPath = path.join(tempDir, "b.txt");
fs.writeFileSync(firstPath, "a");
fs.writeFileSync(secondPath, "b");
const removed = [];
const zsession = {
async send_offer(params) {
if (params.name === "a.txt") {
throw new Error("simulated early failure");
}
return {
send() {},
async end() {},
};
},
abort() {},
async close() {},
};
await assert.rejects(
handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: () => true,
takeDragDropUpload: () => ({
filePaths: [firstPath, secondPath],
remoteNames: ["a.txt", "b.txt"],
}),
probeReceiveConflicts: async () => ({
dir: "/home/u",
existing: ["a.txt", "b.txt"],
modes: { "a.txt": "644", "b.txt": "600" },
}),
requestOverwriteDecision: async () => ({ action: "skip", applyToRest: false }),
removeRemoteFiles: async (paths) => {
removed.push(...paths);
},
}),
/simulated early failure/,
);
assert.deepEqual(removed, []);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload restores modes for accepted overwrites before partial ZSKIP failure", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const acceptedPath = path.join(tempDir, "a.sh");
const skippedPath = path.join(tempDir, "b.txt");
fs.writeFileSync(acceptedPath, "a");
fs.writeFileSync(skippedPath, "b");
let offerCount = 0;
let restored = [];
const zsession = {
async send_offer(params) {
offerCount += 1;
if (params.name === "b.txt") return undefined;
return {
send() {},
async end() {},
};
},
abort() {},
async close() {},
};
await assert.rejects(
handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: () => true,
selectUploadFiles: async () => ({
canceled: false,
filePaths: [acceptedPath, skippedPath],
}),
probeReceiveConflicts: async () => ({
dir: "/home/u",
existing: ["a.sh", "b.txt"],
modes: { "a.sh": "755", "b.txt": "644" },
}),
requestOverwriteDecision: async () => ({ action: "overwrite", applyToRest: true }),
removeRemoteFiles: async () => {},
restoreRemoteModes: async (entries) => {
restored = entries;
},
}),
/Remote skipped some files/,
);
assert.equal(offerCount, 2);
assert.deepEqual(restored, [{ path: "/home/u/a.sh", mode: "755" }]);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("mode restore tracks skipped offers by index for duplicate basenames", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const firstDir = path.join(tempDir, "first");
const secondDir = path.join(tempDir, "second");
fs.mkdirSync(firstDir);
fs.mkdirSync(secondDir);
const firstPath = path.join(firstDir, "x.txt");
const secondPath = path.join(secondDir, "x.txt");
fs.writeFileSync(firstPath, "first");
fs.writeFileSync(secondPath, "second");
let offerCount = 0;
let restored = [];
const zsession = {
async send_offer() {
offerCount += 1;
// Accept the first duplicate-name offer; ZSKIP the second.
if (offerCount > 1) return undefined;
return {
send() {},
async end() {},
};
},
abort() {},
async close() {},
};
await assert.rejects(
handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => null,
writeToRemote: () => true,
selectUploadFiles: async () => ({
canceled: false,
filePaths: [firstPath, secondPath],
}),
probeReceiveConflicts: async () => ({
dir: "/home/u",
existing: ["x.txt"],
modes: { "x.txt": "600" },
}),
requestOverwriteDecision: async () => ({ action: "overwrite", applyToRest: true }),
removeRemoteFiles: async () => {},
restoreRemoteModes: async (entries) => {
restored = entries;
},
}),
/Remote skipped some files/,
);
assert.equal(offerCount, 2);
// Filename-based skip filtering would drop this restore; index-based keeps it.
assert.deepEqual(restored, [{ path: "/home/u/x.txt", mode: "600" }]);
fs.rmSync(tempDir, { recursive: true, force: true });
});
+7 -2
View File
@@ -529,6 +529,7 @@ function createPreloadApi(ctx) {
const payload = {
sessionId,
...(Number.isFinite(options?.bootEpoch) ? { bootEpoch: Number(options.bootEpoch) } : {}),
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
};
const epochScoped = Number.isFinite(options?.bootEpoch);
// Unscoped closes keep the historical sync listener teardown so reconnect
@@ -811,10 +812,11 @@ function createPreloadApi(ctx) {
const result = await ipcRenderer.invoke("netcatty:sftp:open", options);
return result.sftpId;
},
openSftpForSession: async (sessionId, expectedEndpoint) => {
openSftpForSession: async (sessionId, options) => {
const result = await ipcRenderer.invoke("netcatty:sftp:openForSession", {
...(options || {}),
sessionId,
expectedEndpoint,
expectedEndpoint: options,
});
return result.sftpId;
},
@@ -1720,6 +1722,9 @@ function createPreloadApi(ctx) {
aiMcpUpdateSessions: async (sessions, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:mcp:update-sessions", { sessions, chatSessionId });
},
aiMcpUpdateLiveSessions: async (sessions) => {
return ipcRenderer.invoke("netcatty:ai:mcp:update-live-sessions", { sessions });
},
aiMcpMergeSessions: async (sessions, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:mcp:merge-sessions", { sessions, chatSessionId });
},
@@ -191,3 +191,31 @@ test("listLocalTree keeps the entries listener until the tree-end marker arrives
assert.equal(batches[0][0].relativePath, "project/nested/deep.txt");
assert.equal(removed, true);
});
test("openSftpForSession keeps the SSH source session id when options include an SFTP session id", async () => {
const invoked = [];
const api = createPreloadApi({
webUtils: {},
ipcRenderer: {
on() {},
removeListener() {},
async invoke(channel, payload) {
invoked.push({ channel, payload });
return { sftpId: "opened-sftp" };
},
},
});
const sftpId = await api.openSftpForSession("ssh-session-1", {
sessionId: "sftp-left-browse-session",
hostname: "192.168.9.138",
port: 22,
username: "zlhrs",
});
assert.equal(sftpId, "opened-sftp");
assert.equal(invoked.length, 1);
assert.equal(invoked[0].channel, "netcatty:sftp:openForSession");
assert.equal(invoked[0].payload.sessionId, "ssh-session-1");
assert.equal(invoked[0].payload.expectedEndpoint.sessionId, "sftp-left-browse-session");
});
@@ -141,3 +141,16 @@ test('every built-in terminal theme keeps side panel text readable', () => {
}
}
});
test('Netcatty Light side panel tokens are light so color-scheme can match the composer', () => {
const theme = TERMINAL_THEMES.find((entry) => entry.id === 'netcatty-light');
assert.ok(theme);
assert.equal(theme.type, 'light');
const vars = buildTerminalSidePanelCssVars(theme);
const backgroundLuminance = getHslTokenRelativeLuminance(vars['--background']);
assert.notEqual(backgroundLuminance, null);
assert.ok(
(backgroundLuminance as number) > 0.5,
'Netcatty Light --background must stay light for the AI composer surface',
);
});
+3 -3
View File
@@ -1,14 +1,14 @@
{
version = "1.1.77";
version = "1.1.78";
sources = {
x86_64-linux = {
appImageArch = "x86_64";
hash = "sha256-ciShhwtTig5leU/TNGV/s3ViaZp0Yd4t40m4wpz66AQ=";
hash = "sha256-e+ppcULY31xAlRqzub9LoyyXJYcAWcI2U0IXoluLp2U=";
};
aarch64-linux = {
appImageArch = "arm64";
hash = "sha256-esESxIY9uCXARsHjo5fZqcrvUqUPuBSvv6AGHRGww1A=";
hash = "sha256-hhJUOKEAz4VgFaK5rGdza5ZpU9olYA9phsoWPLIjVkk=";
};
};
}
+15
View File
@@ -107,6 +107,21 @@ declare global {
hostChain?: Array<{ hostId: string; label?: string; hostname?: string }>;
activePortForwards?: Array<{ ruleId: string; label?: string; type?: string; localPort?: number; status?: string }>;
}>, chatSessionId?: string): Promise<{ ok: boolean }>;
/** Update the app-owned live session snapshot used by existing AI scopes. */
aiMcpUpdateLiveSessions?(sessions: Array<{
sessionId: string;
hostId?: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
hostChain?: Array<{ hostId: string; label?: string; hostname?: string }>;
activePortForwards?: Array<{ ruleId: string; label?: string; type?: string; localPort?: number; status?: string }>;
}>): Promise<{ ok: boolean; count?: number; error?: string }>;
/** Merge sessions into a chat scope without dropping existing entries. */
aiMcpMergeSessions?(sessions: Array<{
sessionId: string;
+4
View File
@@ -117,6 +117,10 @@ export interface ScriptDialogRequest {
}
export interface ScriptRunParams {
/** Optional caller-generated id so a queued run can be cancelled before it starts. */
runId?: string;
/** Return after the run enters the backend queue instead of waiting for completion. */
returnWhenQueued?: boolean;
scriptId?: string;
scriptLabel?: string;
content: string;
+1 -1
View File
@@ -363,7 +363,7 @@ declare global {
respondTerminalOutputDrain?(requestId: string): void;
notifyTerminalSessionDisplayReady?(sessionId: string): void;
ackSessionFlow(sessionId: string, bytes: number): void;
closeSession(sessionId: string, options?: { bootEpoch?: number }): void | Promise<void>;
closeSession(sessionId: string, options?: { bootEpoch?: number; retainOwnership?: boolean }): void | Promise<void>;
/** Move a live session's output port to this renderer (same PTY). */
rebindTerminalSessionOutput?(sessionId: string, authorization: string): Promise<{
success: boolean;
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more