Compare commits

..
Author SHA1 Message Date
bincxz ef364b69fd Fix theme header button alignment 2026-08-12 17:50:10 +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
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
陈大猫 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 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
bincxz 6bf4268e8e fix(ssh): scope parked reconnect cwd guard 2026-08-12 13:17:50 +08: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 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
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
62 changed files with 3264 additions and 255 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");
});
-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/,
);
});
+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,
+114 -19
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);
@@ -2511,9 +2525,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 +2566,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
})) {
connectScriptsConsumedRef.current = true;
}
if (connectScriptsBatchRef.current === batch) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
return;
}
@@ -2550,11 +2578,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 +2592,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 +2606,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
},
})
.then(() => {
if (!batchStillActive()) return;
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
const doneAfterRun = resolvedAfterRun.length === 0
|| resolvedAfterRun.every(
@@ -2585,6 +2617,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 +2633,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 +2644,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
username: host.username,
},
onScriptComplete: (snippet) => {
if (!batchStillActive()) return;
if (snippet.id) {
pendingScriptRunIdRef.current = snippet.id;
} else {
@@ -2615,16 +2653,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(() => {
@@ -3352,20 +3406,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,6 +3418,59 @@ 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();
} catch (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 &&
@@ -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;
+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;
}
+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);
}
+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;
+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 = [];
+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,
+50 -2
View File
@@ -884,6 +884,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 +967,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 +1021,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 +1076,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 +1278,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 +1311,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 +1448,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()];
+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 };
+12
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,
@@ -29,3 +29,47 @@ test("renderer terminal input reports activity for the matching session", () =>
{ sessionId: "session-1", phase: "touch" },
]);
});
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);
});
+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 });
});
@@ -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=";
};
};
}
+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;