mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 19:32:33 +00:00
Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
072377777f | ||
|
|
27b62b2372 | ||
|
|
627e652f56 | ||
|
|
17c2eb6346 | ||
|
|
01102e2d66 | ||
|
|
81dcf849fb | ||
|
|
1dfe1379cd | ||
|
|
27ec58fa40 | ||
|
|
7ac878d00e | ||
|
|
f90f40e624 | ||
|
|
dfc9ed7f61 | ||
|
|
2c2d7e0b55 | ||
|
|
ef364b69fd | ||
|
|
700d603e64 | ||
|
|
f2b3facd9f | ||
|
|
7f08bec0b9 | ||
|
|
bbd4c698a5 | ||
|
|
4beccc3d61 | ||
|
|
60e057318f | ||
|
|
33b3beaea0 | ||
|
|
e65af2fd7b | ||
|
|
5d63a9476c | ||
|
|
58c72ace32 | ||
|
|
75cbda7edc | ||
|
|
6f6d8dc36d | ||
|
|
d0f819efb0 | ||
|
|
9671468412 | ||
|
|
380e96eae5 | ||
|
|
e4f9303de5 | ||
|
|
02ce011934 | ||
|
|
8e45763e4c | ||
|
|
0d92c5d738 | ||
|
|
c8a393f12b | ||
|
|
6bf4268e8e | ||
|
|
7114288d2f | ||
|
|
f02b645af7 | ||
|
|
eb3cd06278 | ||
|
|
7ed0b32489 | ||
|
|
2fe46cbf6f | ||
|
|
53ebbec58c | ||
|
|
0382c089e1 | ||
|
|
253ce57d84 | ||
|
|
9beb7d3108 | ||
|
|
525a78e83e | ||
|
|
0e94950a5c | ||
|
|
a2ea0f6464 | ||
|
|
42b174abb3 | ||
|
|
e2ad0f526e | ||
|
|
f8c7dd44db | ||
|
|
cfeee2ec89 | ||
|
|
be3e971064 | ||
|
|
35bb83f57c | ||
|
|
5279ac2836 | ||
|
|
793ba54bda | ||
|
|
a1ca1a3443 | ||
|
|
3c528c6ab5 | ||
|
|
6dd703afce | ||
|
|
10c399f47b | ||
|
|
a4ec7bab61 | ||
|
|
79215d8896 | ||
|
|
2012fc53c7 | ||
|
|
2be738b8c2 | ||
|
|
93d7f8e1ec | ||
|
|
924c9a0efa | ||
|
|
0d9da150ec | ||
|
|
d307a2ebc3 | ||
|
|
d97439f878 |
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -568,6 +568,7 @@ export function useSettingsStorageSync({
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setShowTabNumberBadgesState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
|
||||
@@ -162,7 +162,6 @@ export async function openTransferSftpSession(
|
||||
if (
|
||||
!wantDedicated
|
||||
&& options?.sourceSessionId
|
||||
&& !host.sftpSudo
|
||||
&& bridge.openSftpForSession
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Host } from "../../../domain/models";
|
||||
import { ensureRemoteSftpSession } from "./ensureRemoteSftpSession";
|
||||
import { ensureRemoteSftpSession, probeSftpSession } from "./ensureRemoteSftpSession";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
const host = {
|
||||
@@ -37,6 +37,24 @@ const remotePane = (connectionId: string): SftpPane => ({
|
||||
|
||||
const noopReleaseConnection = async () => {};
|
||||
|
||||
test("SFTP session probe accepts a virtual root directory", async () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
const ok = await probeSftpSession({
|
||||
realpathSftp: async (sftpId, remotePath) => {
|
||||
calls.push([sftpId, remotePath]);
|
||||
return "/";
|
||||
},
|
||||
}, "sftp-jumpserver");
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(calls, [["sftp-jumpserver", "."]]);
|
||||
});
|
||||
|
||||
test("SFTP session probe does not require home discovery", async () => {
|
||||
const ok = await probeSftpSession(undefined, "sftp-live");
|
||||
assert.equal(ok, true);
|
||||
});
|
||||
|
||||
test("returns an existing mapped SFTP session without reconnecting", async () => {
|
||||
let connectCalls = 0;
|
||||
const sftpId = await ensureRemoteSftpSession({
|
||||
|
||||
@@ -3,6 +3,24 @@ import type { MutableRefObject } from "react";
|
||||
import { isSessionError } from "./errors";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
export interface SftpSessionProbeBridge {
|
||||
realpathSftp?: (sftpId: string, path: string) => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the SFTP protocol itself instead of using remote home discovery.
|
||||
* Restricted/chroot servers can deny SSH exec or expose '/' as their virtual
|
||||
* root while the SFTP session remains fully usable.
|
||||
*/
|
||||
export async function probeSftpSession(
|
||||
bridge: SftpSessionProbeBridge | null | undefined,
|
||||
sftpId: string,
|
||||
): Promise<boolean> {
|
||||
if (!bridge?.realpathSftp) return true;
|
||||
await bridge.realpathSftp(sftpId, ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface EnsureRemoteSftpSessionParams {
|
||||
side: "left" | "right";
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
buildSftpConnectInFlightKey,
|
||||
buildSftpHomeDirCandidates,
|
||||
runSftpConnectOnceByKey,
|
||||
createSftpConnectionId,
|
||||
createPinnedReconnectSideResolver,
|
||||
openSftpConnectionOnce,
|
||||
@@ -15,12 +19,62 @@ import {
|
||||
resolvePinnedReconnectSide,
|
||||
} from "./useSftpConnections.ts";
|
||||
|
||||
test("home dir candidates prefer user home then root", () => {
|
||||
assert.deepEqual(buildSftpHomeDirCandidates("deploy"), ["/home/deploy", "/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates("root"), ["/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates(undefined), ["/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates(null), ["/root"]);
|
||||
});
|
||||
|
||||
test("connection ids stay unique even when connects start in the same millisecond", () => {
|
||||
const ids = ["uuid-a", "uuid-b"];
|
||||
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-a");
|
||||
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-b");
|
||||
});
|
||||
|
||||
test("runSftpConnectOnceByKey shares an in-flight connect for the same tab and endpoint", async () => {
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
let runs = 0;
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
|
||||
const first = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
|
||||
runs += 1;
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
});
|
||||
const second = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
|
||||
runs += 1;
|
||||
});
|
||||
|
||||
assert.equal(runs, 1);
|
||||
releaseFirst?.();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(runs, 1);
|
||||
assert.equal(inFlight.size, 0);
|
||||
});
|
||||
|
||||
test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs", () => {
|
||||
const first = buildSftpConnectInFlightKey({
|
||||
side: "left",
|
||||
tabId: "new-tab-a",
|
||||
targetConnectionKey: "host-key",
|
||||
sourceSessionId: "ssh-session-1",
|
||||
initialPath: "/home",
|
||||
forceNewTab: true,
|
||||
});
|
||||
const second = buildSftpConnectInFlightKey({
|
||||
side: "left",
|
||||
tabId: "new-tab-b",
|
||||
targetConnectionKey: "host-key",
|
||||
sourceSessionId: "ssh-session-1",
|
||||
initialPath: "/home",
|
||||
forceNewTab: true,
|
||||
});
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
const openOptions = {
|
||||
sessionId: "sftp-request-1",
|
||||
hostname: "192.168.9.138",
|
||||
@@ -73,6 +127,43 @@ test("openSftpWithSessionPreference falls back to normal SFTP when session reuse
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1", "openSftp:sftp-request-1"]);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fresh auth", async () => {
|
||||
const calls: string[] = [];
|
||||
let passedOptions: NetcattySSHOptions | undefined;
|
||||
const sftpId = await openSftpWithSessionPreference({
|
||||
bridge: {
|
||||
openSftpForSession: async (sessionId: string, options?: NetcattySSHOptions) => {
|
||||
calls.push(`openForSession:${sessionId}`);
|
||||
passedOptions = options;
|
||||
return "sudo-session-backed-sftp";
|
||||
},
|
||||
openSftp: async () => {
|
||||
calls.push("openSftp");
|
||||
return "fresh-sftp";
|
||||
},
|
||||
},
|
||||
sourceSessionId: "ssh-session-1",
|
||||
openOptions: {
|
||||
...openOptions,
|
||||
sudo: true,
|
||||
password: "sudo-pass",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(sftpId, "sudo-session-backed-sftp");
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
|
||||
assert.equal(passedOptions?.sudo, true);
|
||||
assert.equal(passedOptions?.password, "sudo-pass");
|
||||
});
|
||||
|
||||
test("remote browse connect does not discard sourceSessionId for sudo hosts", () => {
|
||||
const source = readFileSync(new URL("./useSftpConnections.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/host\.sftpSudo\s*\?\s*undefined\s*:\s*options\?\.sourceSessionId/,
|
||||
);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference opens normal SFTP without a source session", async () => {
|
||||
const calls: string[] = [];
|
||||
const sftpId = await openSftpWithSessionPreference({
|
||||
|
||||
@@ -97,6 +97,13 @@ export async function releaseSftpConnectionMetadata(params: {
|
||||
params.onRemoteSessionClosed?.(sftpId);
|
||||
}
|
||||
|
||||
/** Hardcoded home-path candidates when SSH exec / listable realpath fail. */
|
||||
export function buildSftpHomeDirCandidates(username?: string | null): string[] {
|
||||
if (username === "root") return ["/root"];
|
||||
if (username) return [`/home/${username}`, "/root"];
|
||||
return ["/root"];
|
||||
}
|
||||
|
||||
export function createSftpConnectionId(
|
||||
side: "left" | "right",
|
||||
randomUUID: () => string = () => crypto.randomUUID(),
|
||||
@@ -176,6 +183,41 @@ export function createPinnedReconnectSideResolver(
|
||||
};
|
||||
}
|
||||
|
||||
export function runSftpConnectOnceByKey(
|
||||
inFlight: Map<string, Promise<void>>,
|
||||
key: string,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = run().finally(() => {
|
||||
if (inFlight.get(key) === promise) {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
});
|
||||
inFlight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function buildSftpConnectInFlightKey(params: {
|
||||
side: "left" | "right";
|
||||
tabId: string;
|
||||
targetConnectionKey: string;
|
||||
sourceSessionId?: string;
|
||||
initialPath?: string;
|
||||
forceNewTab?: boolean;
|
||||
}): string {
|
||||
return [
|
||||
params.side,
|
||||
params.tabId,
|
||||
params.targetConnectionKey,
|
||||
params.sourceSessionId ?? "",
|
||||
params.initialPath ?? "",
|
||||
params.forceNewTab ? "force-new-tab" : "",
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
@@ -249,6 +291,7 @@ export const useSftpConnections = ({
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
|
||||
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
|
||||
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
|
||||
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
|
||||
const setPendingHostKeyVerification = useCallback((
|
||||
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
|
||||
@@ -426,21 +469,31 @@ export const useSftpConnections = ({
|
||||
// immediately, avoiding race conditions with deferred effects.
|
||||
options?.onTabCreated?.(activeTabId);
|
||||
|
||||
const connectionId = createSftpConnectionId(side);
|
||||
const connectInFlightKey = buildSftpConnectInFlightKey({
|
||||
side,
|
||||
tabId: activeTabId,
|
||||
targetConnectionKey,
|
||||
sourceSessionId: host === "local" ? undefined : options?.sourceSessionId,
|
||||
initialPath: effectiveInitialPath,
|
||||
forceNewTab: options?.forceNewTab,
|
||||
});
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const targetSide = resolveTargetSide();
|
||||
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
return runSftpConnectOnceByKey(connectInFlightRef.current, connectInFlightKey, async () => {
|
||||
const connectionId = createSftpConnectionId(side);
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const targetSide = resolveTargetSide();
|
||||
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
const isTargetConnectionAtPath = (path: string) => {
|
||||
const connection = getTargetPane()?.connection;
|
||||
if (!connection) return navSeqRef.current[side] === connectRequestId;
|
||||
@@ -573,9 +626,7 @@ export const useSftpConnections = ({
|
||||
sharedHostCache?.path,
|
||||
);
|
||||
|
||||
// Sudo never reuses a terminal shell; normalize before UI state so
|
||||
// reusedConnection (spinner/overlay) matches the actual open path.
|
||||
const sourceSessionId = host.sftpSudo ? undefined : options?.sourceSessionId;
|
||||
const sourceSessionId = options?.sourceSessionId;
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
@@ -698,15 +749,7 @@ export const useSftpConnections = ({
|
||||
}
|
||||
|
||||
if (!detected) {
|
||||
const candidates: string[] = [];
|
||||
if (credentials.username === "root") {
|
||||
candidates.push("/root");
|
||||
} else if (credentials.username) {
|
||||
candidates.push(`/home/${credentials.username}`);
|
||||
candidates.push("/root");
|
||||
} else {
|
||||
candidates.push("/root");
|
||||
}
|
||||
const candidates = buildSftpHomeDirCandidates(credentials.username);
|
||||
const statSftp = bridge?.statSftp;
|
||||
if (statSftp) {
|
||||
for (const candidate of candidates) {
|
||||
@@ -784,6 +827,23 @@ export const useSftpConnections = ({
|
||||
// root also failed
|
||||
}
|
||||
}
|
||||
// Last resort: home candidates. Covers provisional "/" from realpath
|
||||
// when discovery treated root as home and listing it failed, so
|
||||
// /home/<user> or /root can still recover the session.
|
||||
if (!fallbackSucceeded) {
|
||||
for (const candidate of buildSftpHomeDirCandidates(credentials.username)) {
|
||||
if (candidate === startPath) continue;
|
||||
try {
|
||||
files = await listRemoteFiles(sftpId, candidate, filenameEncoding);
|
||||
startPath = candidate;
|
||||
homeDir = candidate;
|
||||
fallbackSucceeded = true;
|
||||
break;
|
||||
} catch {
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fallbackSucceeded) {
|
||||
throw new Error("Cannot list any remote directory");
|
||||
}
|
||||
@@ -855,6 +915,7 @@ export const useSftpConnections = ({
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createLatestPayloadSync } from './useExternalMcpSessionSync.ts';
|
||||
|
||||
const hookSource = readFileSync(new URL('./useExternalMcpSessionSync.ts', import.meta.url), 'utf8');
|
||||
const appSource = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
const preloadSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
|
||||
const handlerSource = readFileSync(
|
||||
new URL('../../electron/bridges/aiBridge/agentProcessHandlers.cjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('app live session sync stays mounted and is independent of External MCP enablement', () => {
|
||||
assert.match(appSource, /useExternalMcpSessionSync\(\{/);
|
||||
assert.match(hookSource, /bridge\.aiMcpUpdateLiveSessions\?\.\(nextPayload\)/);
|
||||
assert.match(hookSource, /liveSyncRef\.current\.push\(payload, serialized\)/);
|
||||
assert.match(hookSource, /bridge\.aiMcpUpdateSessions\?\.\(/);
|
||||
assert.match(hookSource, /externalSyncRef\.current\.push\(payload, serialized\)/);
|
||||
assert.doesNotMatch(
|
||||
hookSource,
|
||||
/if \(!enabled\)[\s\S]{0,300}aiMcpUpdateLiveSessions/,
|
||||
);
|
||||
});
|
||||
|
||||
test('live session sync has a complete renderer to main-process IPC path', () => {
|
||||
assert.match(preloadSource, /netcatty:ai:mcp:update-live-sessions/);
|
||||
assert.match(handlerSource, /netcatty:ai:mcp:update-live-sessions/);
|
||||
assert.match(handlerSource, /mcpServerBridge\.updateLiveSessionMetadata/);
|
||||
});
|
||||
|
||||
test('latest payload sync converges to A-B-A while B is in flight', async () => {
|
||||
let releaseB: (() => void) | undefined;
|
||||
const sent: string[] = [];
|
||||
const sync = createLatestPayloadSync(async (payload: string) => {
|
||||
sent.push(payload);
|
||||
if (payload === 'B') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseB = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sync.push('A');
|
||||
const sendingB = sync.push('B');
|
||||
void sync.push('A');
|
||||
releaseB?.();
|
||||
await sendingB;
|
||||
|
||||
assert.deepEqual(sent, ['A', 'B', 'A']);
|
||||
});
|
||||
|
||||
test('cancelling a payload sync drops a queued update', async () => {
|
||||
let releaseA: (() => void) | undefined;
|
||||
const sent: string[] = [];
|
||||
const sync = createLatestPayloadSync(async (payload: string) => {
|
||||
sent.push(payload);
|
||||
if (payload === 'A') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseA = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const sendingA = sync.push('A');
|
||||
void sync.push('B');
|
||||
sync.cancel();
|
||||
releaseA?.();
|
||||
await sendingA;
|
||||
|
||||
assert.deepEqual(sent, ['A']);
|
||||
});
|
||||
@@ -17,6 +17,49 @@ type UseExternalMcpSessionSyncOptions = {
|
||||
portForwardingRules: PortForwardingRule[];
|
||||
};
|
||||
|
||||
export function createLatestPayloadSync<T>(
|
||||
send: (payload: T) => Promise<unknown>,
|
||||
) {
|
||||
let desired: { serialized: string; payload: T } | null = null;
|
||||
let acknowledged = '';
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const pump = async () => {
|
||||
while (!cancelled && desired && desired.serialized !== acknowledged) {
|
||||
const target = desired;
|
||||
try {
|
||||
const result = await send(target.payload);
|
||||
if (cancelled) break;
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
|
||||
if (desired === target) break;
|
||||
continue;
|
||||
}
|
||||
acknowledged = target.serialized;
|
||||
} catch {
|
||||
if (desired === target) break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
push(payload: T, serialized = JSON.stringify(payload)): Promise<void> {
|
||||
if (cancelled) return Promise.resolve();
|
||||
desired = { payload, serialized };
|
||||
if (!inFlight) {
|
||||
inFlight = pump().finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
return inFlight;
|
||||
},
|
||||
cancel(): void {
|
||||
cancelled = true;
|
||||
desired = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isMainAppWindow(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const hash = window.location.hash || '';
|
||||
@@ -43,7 +86,6 @@ export function useExternalMcpSessionSync({
|
||||
void enabledTick;
|
||||
return readExternalMcpStoredEnabled();
|
||||
}, [enabledTick]);
|
||||
const syncGenerationRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const bump = () => setEnabledTick((value) => value + 1);
|
||||
@@ -77,34 +119,41 @@ export function useExternalMcpSessionSync({
|
||||
});
|
||||
}, [sessions, sessionHostsMap, hosts, portForwardingRules]);
|
||||
|
||||
const lastSentSerializedRef = useRef('');
|
||||
const liveSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
|
||||
const externalSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMainAppWindow()) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.aiMcpUpdateLiveSessions) return;
|
||||
const serialized = JSON.stringify(payload);
|
||||
liveSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
|
||||
Promise.resolve(bridge.aiMcpUpdateLiveSessions?.(nextPayload))
|
||||
));
|
||||
void liveSyncRef.current.push(payload, serialized);
|
||||
}, [payload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMainAppWindow()) return;
|
||||
if (!enabled) {
|
||||
syncGenerationRef.current += 1;
|
||||
lastSentSerializedRef.current = '';
|
||||
// Recreate the synchronizer after re-enabling because disabling can
|
||||
// clear the main-process scope even when the renderer payload is equal.
|
||||
externalSyncRef.current?.cancel();
|
||||
externalSyncRef.current = null;
|
||||
return;
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.aiMcpUpdateSessions) return;
|
||||
|
||||
const serialized = JSON.stringify(payload);
|
||||
if (serialized === lastSentSerializedRef.current) return;
|
||||
|
||||
const generation = ++syncGenerationRef.current;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void Promise.resolve(bridge.aiMcpUpdateSessions?.(payload, EXTERNAL_MCP_CHAT_SESSION_ID))
|
||||
.then((result) => {
|
||||
if (generation !== syncGenerationRef.current) return;
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
|
||||
return;
|
||||
}
|
||||
lastSentSerializedRef.current = serialized;
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave the ref unset so the next effect can retry.
|
||||
});
|
||||
externalSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
|
||||
Promise.resolve(bridge.aiMcpUpdateSessions?.(
|
||||
nextPayload,
|
||||
EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
))
|
||||
));
|
||||
void externalSyncRef.current.push(payload, serialized);
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -30,7 +30,7 @@ import { buildSftpHostCredentials } from "./sftp/useSftpHostCredentials";
|
||||
import { useSftpFileWatch } from "./sftp/useSftpFileWatch";
|
||||
import { useSftpSessionCleanup } from "./sftp/useSftpSessionCleanup";
|
||||
import { useSftpSessionErrors } from "./sftp/useSftpSessionErrors";
|
||||
import { ensureRemoteSftpSession } from "./sftp/ensureRemoteSftpSession";
|
||||
import { ensureRemoteSftpSession, probeSftpSession } from "./sftp/ensureRemoteSftpSession";
|
||||
import { openTransferSftpSession } from "./sftp/dedicatedTransferResume";
|
||||
import {
|
||||
createTransferPoolKeyCache,
|
||||
@@ -316,9 +316,7 @@ export const useSftpState = (
|
||||
// Prefer borrowing the live (or parked) terminal SSH transport so MFA is
|
||||
// not repeated. Transport leases keep the shared conn alive after the
|
||||
// terminal tab closes until the transfer SFTP lease is returned.
|
||||
const sourceSessionId = !host.sftpSudo
|
||||
? resolveTransferSourceSessionId?.(host.id, host)
|
||||
: undefined;
|
||||
const sourceSessionId = resolveTransferSourceSessionId?.(host.id, host);
|
||||
if (sourceSessionId) {
|
||||
try {
|
||||
logger.info(
|
||||
@@ -608,16 +606,7 @@ export const useSftpState = (
|
||||
forceReconnect: ensureOptions?.forceReconnect,
|
||||
releaseConnection,
|
||||
tabId,
|
||||
probeSession: async (sftpId) => {
|
||||
// Lightweight liveness check; any session-error from the bridge
|
||||
// triggers a reconnect in ensureRemoteSftpSession.
|
||||
if (!bridge?.getSftpHomeDir) return true;
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
if (result && result.success === false) {
|
||||
throw new Error(result.error || "SFTP session not found");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
probeSession: (sftpId) => probeSftpSession(bridge, sftpId),
|
||||
});
|
||||
},
|
||||
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
|
||||
@@ -704,6 +693,12 @@ export const useSftpState = (
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection || pane.connection.isLocal) continue;
|
||||
if (sftpSessionsRef.current.has(pane.connection.id)) continue;
|
||||
const connectedHost = connectedHostByTabIdRef.current.get(pane.id) ?? null;
|
||||
const vaultHost = hosts.find((host) => host.id === pane.connection?.hostId) ?? null;
|
||||
const targetHost = connectedHost && connectedHost !== "local" ? connectedHost : vaultHost;
|
||||
const resolvedSourceSessionId = targetHost
|
||||
? resolveBrowseSourceSessionId?.(targetHost.id, targetHost)
|
||||
: undefined;
|
||||
try {
|
||||
await ensureRemoteSftpSession({
|
||||
side,
|
||||
@@ -713,16 +708,10 @@ export const useSftpState = (
|
||||
connect,
|
||||
resolveConnectedHost: (id) => connectedHostByTabIdRef.current.get(id) ?? null,
|
||||
resolveHostById: (hostId) => hosts.find((host) => host.id === hostId) ?? null,
|
||||
resolveSourceSessionId: resolveBrowseSourceSessionId,
|
||||
probeSession: async (sftpId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSftpHomeDir) return true;
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
if (result && result.success === false) {
|
||||
throw new Error(result.error || "SFTP session not found");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
resolveSourceSessionId: resolvedSourceSessionId
|
||||
? () => resolvedSourceSessionId
|
||||
: resolveBrowseSourceSessionId,
|
||||
probeSession: (sftpId) => probeSftpSession(netcattyBridge.get(), sftpId),
|
||||
releaseConnection,
|
||||
});
|
||||
logger.info(`[SFTP] Restored browse session on ${side}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -86,6 +86,7 @@ import { scheduleDeferredTerminalFocus } from "./systemManager/tmuxActionFocus";
|
||||
import {
|
||||
connectionKeyMatchesHost,
|
||||
findReusableSftpSidePanelTab,
|
||||
isPendingSameEndpointSshSession,
|
||||
shouldAcceptPendingSftpUpload,
|
||||
shouldResetSftpSidePanelSourceSession,
|
||||
shouldSkipSftpSidePanelAutoConnect,
|
||||
@@ -560,6 +561,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
activeHost.username,
|
||||
activeHost.sftpFileProtocol,
|
||||
);
|
||||
const pendingSameEndpointSession = sessions.find((session) => (
|
||||
isPendingSameEndpointSshSession(session, activeHost)
|
||||
));
|
||||
if (!activeSessionId && pendingSameEndpointSession) {
|
||||
return;
|
||||
}
|
||||
const sessionChanged = shouldResetSftpSidePanelSourceSession(
|
||||
lastSourceSessionIdRef.current,
|
||||
activeSessionId,
|
||||
@@ -737,7 +744,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
|
||||
},
|
||||
});
|
||||
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive]);
|
||||
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHost || !isVisible) return;
|
||||
|
||||
+125
-24
@@ -225,8 +225,16 @@ import {
|
||||
} from "./terminal/terminalRuntimeMount";
|
||||
import type { CreateXTermRuntimeContext } from "./terminal/runtime/createXTermRuntime";
|
||||
import { TerminalView } from "./terminal/TerminalView";
|
||||
import {
|
||||
cancelConnectAutomationBatch,
|
||||
createConnectAutomationBatch,
|
||||
trackConnectAutomationStop,
|
||||
type ConnectAutomationBatch,
|
||||
} from "./terminal/connectAutomationBatch";
|
||||
import {
|
||||
getInitialTerminalStatus,
|
||||
resolveTerminalVaultInitialized,
|
||||
shouldResetConnectAutomationOnReconnect,
|
||||
shouldSuppressHostStartupCommandOnReconnect,
|
||||
shouldStartTerminalBackend,
|
||||
} from "./terminal/restoredSessionGate";
|
||||
@@ -270,6 +278,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
workspaceId,
|
||||
restoreState,
|
||||
vaultInitializedOverride,
|
||||
pendingInitialCwd,
|
||||
shellType,
|
||||
lastCwd,
|
||||
@@ -359,10 +368,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, []);
|
||||
// Reactive vault-ready flag so restored panes re-arm boot after hydration
|
||||
// (module isVaultInitialized() alone is not a React dependency).
|
||||
const vaultInitialized = useVaultSnapshotField("isVaultInitialized");
|
||||
const sharedVaultInitialized = useVaultSnapshotField("isVaultInitialized");
|
||||
const vaultInitialized = resolveTerminalVaultInitialized(
|
||||
sharedVaultInitialized,
|
||||
vaultInitializedOverride,
|
||||
);
|
||||
const connectScriptsConsumedRef = useRef(false);
|
||||
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
|
||||
const connectScriptsInFlightRef = useRef(false);
|
||||
const connectScriptsBatchRef = useRef<ConnectAutomationBatch | null>(null);
|
||||
const pendingScriptRunIdRef = useRef<string | null>(null);
|
||||
const pendingScriptHandledRef = useRef<Snippet | null>(null);
|
||||
const pendingScriptRef = useRef(pendingScript);
|
||||
@@ -1603,7 +1617,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalCwdTracker,
|
||||
]);
|
||||
|
||||
const cleanupSession = async () => {
|
||||
const cleanupSession = async (options?: { retainOwnership?: boolean }) => {
|
||||
const closingSessionId = sessionRef.current;
|
||||
xtermRuntimeRef.current?.flushKittyKeyboardReleases();
|
||||
sessionRef.current = null;
|
||||
@@ -1631,7 +1645,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Still notify main so in-flight SSH passphrase prompts for this UI
|
||||
// sessionId are aborted even before a backend session was attached.
|
||||
try {
|
||||
await terminalBackend.closeSession(sessionId, { bootEpoch: resolveCloseBootEpoch() });
|
||||
await terminalBackend.closeSession(sessionId, {
|
||||
bootEpoch: resolveCloseBootEpoch(),
|
||||
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to cancel pending session boot on disconnect", err);
|
||||
}
|
||||
@@ -1740,7 +1757,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
clearTerminalSessionFlowAck(closingSessionId);
|
||||
}
|
||||
try {
|
||||
await terminalBackend.closeSession(closingSessionId, { bootEpoch: resolveCloseBootEpoch() });
|
||||
await terminalBackend.closeSession(closingSessionId, {
|
||||
bootEpoch: resolveCloseBootEpoch(),
|
||||
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to close SSH session", err);
|
||||
}
|
||||
@@ -2511,9 +2531,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!shouldEvaluateConnect && !hasPendingWork) return;
|
||||
if (connectScriptsInFlightRef.current) return;
|
||||
|
||||
const batch = createConnectAutomationBatch();
|
||||
connectScriptsBatchRef.current = batch;
|
||||
connectScriptsInFlightRef.current = true;
|
||||
const batchStillActive = () => (
|
||||
!batch.controller.signal.aborted && connectScriptsBatchRef.current === batch
|
||||
);
|
||||
|
||||
// Defer until xterm has rendered login output and the main-process output tap
|
||||
// has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer).
|
||||
let timerFired = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
timerFired = true;
|
||||
if (!batchStillActive()) return;
|
||||
const runPending = Boolean(pendingOne);
|
||||
const connectQueueNow = connectScriptsConsumedRef.current
|
||||
? []
|
||||
@@ -2542,6 +2572,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
})) {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
}
|
||||
if (connectScriptsBatchRef.current === batch) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2550,11 +2584,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
|
||||
connectScriptsInFlightRef.current = true;
|
||||
|
||||
void runConnectScriptsSequential({
|
||||
scripts: scriptsToRun,
|
||||
sessionId,
|
||||
signal: batch.controller.signal,
|
||||
onCancelableRunChange: (stopCurrentRun) => (
|
||||
trackConnectAutomationStop(batch, stopCurrentRun)
|
||||
),
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -2562,6 +2598,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
username: host.username,
|
||||
},
|
||||
onScriptComplete: (snippet) => {
|
||||
if (!batchStillActive()) return;
|
||||
if (snippet.id && connectIdsInBatch.has(snippet.id)) {
|
||||
connectScriptsCompletedIdsRef.current.add(snippet.id);
|
||||
}
|
||||
@@ -2575,6 +2612,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (!batchStillActive()) return;
|
||||
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
|
||||
const doneAfterRun = resolvedAfterRun.length === 0
|
||||
|| resolvedAfterRun.every(
|
||||
@@ -2585,6 +2623,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
if (!batchStillActive()) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
|
||||
connectScriptsConsumedRef.current = true;
|
||||
@@ -2600,6 +2639,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
await runConnectScriptsSequential({
|
||||
scripts: [pendingScriptToMark],
|
||||
sessionId,
|
||||
signal: batch.controller.signal,
|
||||
onCancelableRunChange: (stopCurrentRun) => (
|
||||
trackConnectAutomationStop(batch, stopCurrentRun)
|
||||
),
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -2607,6 +2650,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
username: host.username,
|
||||
},
|
||||
onScriptComplete: (snippet) => {
|
||||
if (!batchStillActive()) return;
|
||||
if (snippet.id) {
|
||||
pendingScriptRunIdRef.current = snippet.id;
|
||||
} else {
|
||||
@@ -2615,16 +2659,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
},
|
||||
});
|
||||
} catch (pendingErr) {
|
||||
if (!batchStillActive()) return;
|
||||
const pendingMessage = pendingErr instanceof Error ? pendingErr.message : String(pendingErr);
|
||||
toast.error(pendingMessage.includes('Observer mode') ? t('scripts.observer.blocked') : pendingMessage);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
connectScriptsInFlightRef.current = false;
|
||||
if (
|
||||
!batch.controller.signal.aborted
|
||||
&& connectScriptsBatchRef.current === batch
|
||||
) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
});
|
||||
}, 400);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
if (!timerFired && connectScriptsBatchRef.current === batch) {
|
||||
batch.controller.abort();
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
} else if (statusRef.current !== "connected") {
|
||||
void cancelConnectAutomationBatch(batch).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [effectiveTerminalProtocol, host, isPendingScriptAlreadyHandled, moshShellReady, pendingScript, pendingScriptId, scriptSessionName, sessionId, snippets, status, t]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3249,7 +3309,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setPendingHostKeyRequestId(null);
|
||||
setError(null);
|
||||
setProgressLogs((prev) => [...prev, "Disconnected by user."]);
|
||||
void cleanupSession();
|
||||
void cleanupSession({ retainOwnership: true });
|
||||
updateStatus("disconnected");
|
||||
setChainProgress(null);
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
@@ -3352,20 +3412,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
if (!termRef.current) return;
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
prepareRestoredReconnect();
|
||||
// A clone's first connection can fail (auth/host-key/transport) before the
|
||||
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
|
||||
// intent for non-restored sessions, so re-arm it here; the callback no-ops
|
||||
// once the cwd was consumed or when there is no pending inherited cwd.
|
||||
prepareInitialCwdIntent();
|
||||
} else {
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
|
||||
}
|
||||
// Claim the retry before awaiting close. A close/cancel/unmount during the
|
||||
// awaited backend cleanup invalidates this token and stops the continuation.
|
||||
// Claim the retry before awaiting either script cancellation or backend
|
||||
// cleanup. A second reconnect/cancel invalidates this continuation.
|
||||
const retryToken = Symbol("retry");
|
||||
retryTokenRef.current = retryToken;
|
||||
reconnectPreparationTokenRef.current = retryToken;
|
||||
@@ -3376,8 +3424,61 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const connectAutomationBatch = connectScriptsBatchRef.current;
|
||||
if (connectAutomationBatch) {
|
||||
try {
|
||||
await cancelConnectAutomationBatch(connectAutomationBatch);
|
||||
} catch (error) {
|
||||
finishReconnectPreparation();
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message);
|
||||
if (mode === "auto" && retryTokenStillCurrent()) {
|
||||
// Return to disconnected so the existing auto-reconnect loop can
|
||||
// schedule another attempt, including another exact stop request.
|
||||
updateStatus("disconnected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!retryTokenStillCurrent()) {
|
||||
finishReconnectPreparation();
|
||||
return;
|
||||
}
|
||||
if (connectScriptsBatchRef.current === connectAutomationBatch) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
// Manual reconnect skips the disconnected status transition that normally
|
||||
// clears these refs, so onConnect scripts would otherwise stay consumed.
|
||||
if (shouldResetConnectAutomationOnReconnect(
|
||||
restoreState === "restored-disconnected" ? "restored" : "manual",
|
||||
)) {
|
||||
// The prior batch is now stopped, so its callbacks cannot mutate these
|
||||
// newly allocated guards for the fresh manual connection.
|
||||
connectScriptsConsumedRef.current = false;
|
||||
connectScriptsCompletedIdsRef.current = new Set();
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
prepareRestoredReconnect();
|
||||
// A clone's first connection can fail (auth/host-key/transport) before the
|
||||
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
|
||||
// intent for non-restored sessions, so re-arm it here; the callback no-ops
|
||||
// once the cwd was consumed or when there is no pending inherited cwd.
|
||||
prepareInitialCwdIntent();
|
||||
} else {
|
||||
// An automatic reconnect replaces the transport but remains the same user
|
||||
// session. Stop old automation above, then preserve the existing policy of
|
||||
// not starting onConnect scripts again on the replacement transport.
|
||||
connectScriptsConsumedRef.current = true;
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupSession();
|
||||
await cleanupSession({ retainOwnership: true });
|
||||
} catch (error) {
|
||||
finishReconnectPreparation();
|
||||
throw error;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -50,7 +50,7 @@ test("sftp host picker uses single-line quick switcher row layout", () => {
|
||||
assert.doesNotMatch(source, /text-xs text-muted-foreground truncate/);
|
||||
});
|
||||
|
||||
test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
|
||||
test("sftp host picker passes sourceSessionId for sudo connected hosts", () => {
|
||||
assert.match(source, /sftpSourceSessionIdForHost/);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -60,11 +60,6 @@ test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
|
||||
source,
|
||||
/onSelectHost\(\s*item\.entry\.host,\s*sourceSessionId \? \{ sourceSessionId \} : undefined,\s*\)/,
|
||||
);
|
||||
// Must not always pass sessionId as a reuse hint (sudo would hide the spinner).
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/onSelectHost\(item\.entry\.host, \{ sourceSessionId: item\.entry\.sessionId \}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("sftp host picker virtualizes the host list", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import {
|
||||
connectionKeyMatchesHost,
|
||||
findReusableSftpSidePanelTab,
|
||||
isPendingSameEndpointSshSession,
|
||||
isRemoteSftpTabHealthy,
|
||||
shouldAcceptPendingSftpUpload,
|
||||
shouldResetSftpSidePanelSourceSession,
|
||||
@@ -77,6 +78,35 @@ test("shouldSkipSftpSidePanelAutoConnect rejects when the active tab has no endp
|
||||
);
|
||||
});
|
||||
|
||||
test("isPendingSameEndpointSshSession only waits for actively connecting SSH sessions", () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
hostname: "server.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
};
|
||||
const baseSession = {
|
||||
hostId: "host-1",
|
||||
hostname: "server.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "connecting" }, host),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "disconnected" }, host),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "connected" }, host),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("connectionKeyMatchesHost accepts host-id prefix keys", () => {
|
||||
assert.equal(connectionKeyMatchesHost("host-1:server:22:ssh::root", "host-1"), true);
|
||||
assert.equal(connectionKeyMatchesHost("host-2:server:22:ssh::root", "host-1"), false);
|
||||
|
||||
@@ -33,6 +33,36 @@ export function shouldSkipSftpSidePanelAutoConnect(
|
||||
return isRemoteSftpTabHealthy(activeTab, hasBackendSession);
|
||||
}
|
||||
|
||||
export function isPendingSameEndpointSshSession(
|
||||
session: {
|
||||
hostId?: string | null;
|
||||
status?: string;
|
||||
hostname?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
protocol?: string | null;
|
||||
moshEnabled?: boolean;
|
||||
etEnabled?: boolean;
|
||||
},
|
||||
host: {
|
||||
id: string;
|
||||
hostname: string;
|
||||
port?: number | null;
|
||||
username: string;
|
||||
},
|
||||
): boolean {
|
||||
return Boolean(
|
||||
session.hostId === host.id
|
||||
&& session.status === "connecting"
|
||||
&& session.hostname === host.hostname
|
||||
&& (session.port ?? 22) === (host.port ?? 22)
|
||||
&& session.username === host.username
|
||||
&& (session.protocol === "ssh" || session.protocol === undefined)
|
||||
&& !session.moshEnabled
|
||||
&& !session.etEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a stored endpoint key still belongs to the live connection's host. */
|
||||
export function connectionKeyMatchesHost(
|
||||
connectionKey: string | null | undefined,
|
||||
|
||||
@@ -394,7 +394,7 @@ test("manual disconnect keeps the session pane for reconnect", () => {
|
||||
assert.match(body, /isBootActiveRef\.current = false/);
|
||||
assert.match(body, /setIsCancelling\(true\)/);
|
||||
assert.match(body, /updateStatus\("disconnected"\)/);
|
||||
assert.match(body, /void cleanupSession\(\)/);
|
||||
assert.match(body, /void cleanupSession\(\{ retainOwnership: true \}\)/);
|
||||
assert.match(source, /trackSessionCleanup/);
|
||||
assert.doesNotMatch(body, /onCloseSession/);
|
||||
assert.match(source, /handleDisconnect: \(attachExistingSession \|\| compactToolbar\) \? undefined : handleDisconnect/);
|
||||
|
||||
@@ -13,3 +13,10 @@ test("hidden selected theme uses the normal theme item row", () => {
|
||||
assert.match(source, /hiddenSelectedTheme && \(\s*<ThemeItem/);
|
||||
assert.doesNotMatch(source, /terminal\.hiddenTheme\.title[\s\S]*terminal\.hiddenTheme\.desc/);
|
||||
});
|
||||
|
||||
test("theme side panel tabs are vertically centered in the header", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1\.5 gap-0\.5 border-b'/,
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -136,6 +136,8 @@ export interface TerminalProps {
|
||||
sessionId: string;
|
||||
workspaceId?: string;
|
||||
restoreState?: TerminalSession["restoreState"];
|
||||
/** Secondary windows hydrate their own vault state outside the main snapshot store. */
|
||||
vaultInitializedOverride?: boolean;
|
||||
pendingInitialCwd?: string;
|
||||
shellType?: TerminalSession["shellType"];
|
||||
lastCwd?: string;
|
||||
|
||||
@@ -164,7 +164,7 @@ test("reconnect preparation cancels an in-flight disconnected hibernate", () =>
|
||||
const claimIndex = reconnectBody.indexOf(
|
||||
"reconnectPreparationTokenRef.current = retryToken",
|
||||
);
|
||||
const cleanupIndex = reconnectBody.indexOf("await cleanupSession()");
|
||||
const cleanupIndex = reconnectBody.indexOf("await cleanupSession({ retainOwnership: true })");
|
||||
const startNewSessionIndex = reconnectBody.indexOf("const startNewSession = () =>");
|
||||
|
||||
assert.ok(claimIndex >= 0 && claimIndex < cleanupIndex);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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\(\)/);
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -117,23 +117,23 @@ test("listSftpConnectedHosts includes hosts with SFTP sudo for picker display",
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0]?.sessionId, "s-sudo");
|
||||
assert.equal(result[0]?.host.sftpSudo, true);
|
||||
// Transfer/open must still refuse shell reuse for sudo.
|
||||
// Sudo SFTP still opens a new subsystem/channel, but it should borrow the
|
||||
// already-authenticated SSH transport before falling back to fresh auth.
|
||||
assert.equal(
|
||||
resolveSftpTransferSourceSessionId(sessions, hostsById, "a", result[0]?.host),
|
||||
undefined,
|
||||
"s-sudo",
|
||||
);
|
||||
// Picker/connect must not pass sessionId as a reuse hint for sudo hosts.
|
||||
assert.equal(sftpSourceSessionIdForHost(result[0]?.host, result[0]?.sessionId), undefined);
|
||||
assert.equal(sftpSourceSessionIdForHost(result[0]?.host, result[0]?.sessionId), "s-sudo");
|
||||
});
|
||||
|
||||
test("sftpSourceSessionIdForHost keeps non-sudo reuse and drops sudo", () => {
|
||||
test("sftpSourceSessionIdForHost keeps sudo and non-sudo reuse hints", () => {
|
||||
assert.equal(
|
||||
sftpSourceSessionIdForHost(host({ id: "a", label: "Alpha" }), "s1"),
|
||||
"s1",
|
||||
);
|
||||
assert.equal(
|
||||
sftpSourceSessionIdForHost(host({ id: "a", label: "Alpha", sftpSudo: true }), "s1"),
|
||||
undefined,
|
||||
"s1",
|
||||
);
|
||||
assert.equal(sftpSourceSessionIdForHost(undefined, "s1"), "s1");
|
||||
assert.equal(sftpSourceSessionIdForHost(host({ id: "a", label: "Alpha" }), undefined), undefined);
|
||||
@@ -313,6 +313,34 @@ test("resolveSftpTransferSourceSessionId matches among multi-tab same hostId end
|
||||
assert.equal(resolveSftpTransferSourceSessionId(sessions, hostsById, "h1"), "s-new");
|
||||
});
|
||||
|
||||
test("resolveSftpTransferSourceSessionId ignores SFTP browse connection ids", () => {
|
||||
const vault = host({ id: "h1", label: "Prod", hostname: "prod.example.test", username: "alice", port: 22 });
|
||||
const hostsById = new Map([["h1", vault]]);
|
||||
const sessions = [
|
||||
session({
|
||||
id: "terminal-ssh-1",
|
||||
hostId: "h1",
|
||||
status: "connected",
|
||||
hostname: "prod.example.test",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
}),
|
||||
session({
|
||||
id: "sftp-left-accidental-browse-id",
|
||||
hostId: "h1",
|
||||
status: "connected",
|
||||
hostname: "prod.example.test",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
}),
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
resolveSftpTransferSourceSessionId(sessions, hostsById, "h1", vault),
|
||||
"terminal-ssh-1",
|
||||
);
|
||||
});
|
||||
|
||||
test("sftpPickerSessionsEqual ignores title-only changes", () => {
|
||||
const prev = [session({ id: "s1", hostId: "a", status: "connected", dynamicTitle: "old" })];
|
||||
const next = [session({ id: "s1", hostId: "a", status: "connected", dynamicTitle: "new" })];
|
||||
|
||||
@@ -26,6 +26,7 @@ export type SftpPickerSessionFields = Pick<
|
||||
* Connecting sessions and Mosh/ET transports have no reusable ssh2 shell conn.
|
||||
*/
|
||||
const isReusableSftpSourceSession = (session: SftpPickerSessionFields): boolean => {
|
||||
if (session.id.startsWith("sftp-")) return false;
|
||||
if (session.status !== "connected") return false;
|
||||
if (session.moshEnabled || session.etEnabled) return false;
|
||||
const protocol = session.protocol;
|
||||
@@ -99,8 +100,8 @@ export const sftpPickerSessionsEqual = (
|
||||
* One entry per hostId — keeps the most recently listed SSH terminal session.
|
||||
*
|
||||
* Includes hosts with sftpSudo: they still belong under "Connected" when a
|
||||
* terminal is open, but connect paths must not reuse the shell (see
|
||||
* resolveSftpTransferSourceSessionId and useSftpConnections sudo guards).
|
||||
* terminal is open, and SFTP can borrow the already-authenticated SSH transport
|
||||
* before silently falling back to a fresh connection.
|
||||
*/
|
||||
export const listSftpConnectedHosts = (
|
||||
sessions: ReadonlyArray<SftpPickerSessionFields>,
|
||||
@@ -131,15 +132,14 @@ export const listSftpConnectedHosts = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop terminal-session reuse when the host requires a dedicated sudo SFTP
|
||||
* channel. Used by the picker and browse connect path so UI state
|
||||
* (`reusedConnection`) and the actual open agree.
|
||||
* Return the live terminal session id that SFTP should try before fresh auth.
|
||||
* The main process validates that the hinted session really matches the target.
|
||||
*/
|
||||
export const sftpSourceSessionIdForHost = (
|
||||
host: Pick<Host, "sftpSudo"> | null | undefined,
|
||||
sourceSessionId: string | undefined,
|
||||
): string | undefined => {
|
||||
if (!sourceSessionId || host?.sftpSudo) return undefined;
|
||||
if (!sourceSessionId) return undefined;
|
||||
return sourceSessionId;
|
||||
};
|
||||
|
||||
@@ -160,7 +160,6 @@ export const resolveSftpTransferSourceSessionId = (
|
||||
hostId: string,
|
||||
host?: Pick<Host, "hostname" | "username" | "port" | "sftpSudo" | "protocol">,
|
||||
): string | undefined => {
|
||||
if (host?.sftpSudo) return undefined;
|
||||
let lastMatch: string | undefined;
|
||||
for (const session of sessions) {
|
||||
if (session.hostId !== hostId) continue;
|
||||
@@ -168,7 +167,6 @@ export const resolveSftpTransferSourceSessionId = (
|
||||
const vaultHost = hostsById.get(session.hostId);
|
||||
if (!vaultHost) continue;
|
||||
if (vaultHost.protocol === "serial" || isPluginHostProtocol(vaultHost.protocol)) continue;
|
||||
if (vaultHost.sftpSudo) continue;
|
||||
const liveHost = hostForLiveSession(vaultHost, session);
|
||||
if (host && !sftpHostEndpointsEqual(liveHost, host)) continue;
|
||||
lastMatch = session.id;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -21,6 +21,18 @@ export function isNetworkOrFuseCapacityKey(capacityKey: string | undefined): boo
|
||||
if (lower === "/dev/fuse" || lower.endsWith("/fuse")) return true;
|
||||
if (lower === "rclone" || lower.startsWith("rclone:")) return true;
|
||||
if (lower.includes("clouddrive")) return true;
|
||||
// CIFS/SMB (`//server/share`) and NFS (`host:/export` or `[ipv6]:/export`).
|
||||
// Keep synthetic local keys such as APFS pools and overlay/overlayfs roots
|
||||
// out of this heuristic.
|
||||
if (lower.startsWith("//")) return true;
|
||||
if (
|
||||
!lower.startsWith("apfs:")
|
||||
&& !lower.startsWith("overlay:")
|
||||
&& !lower.startsWith("overlayfs:")
|
||||
&& /^([a-z0-9._-]+|\[[0-9a-f:]+(?:%[a-z0-9._-]+)?\]):\//.test(lower)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
lower === "sshfs"
|
||||
|| lower === "s3fs"
|
||||
@@ -28,6 +40,11 @@ export function isNetworkOrFuseCapacityKey(capacityKey: string | undefined): boo
|
||||
|| lower === "mergerfs"
|
||||
|| lower === "unionfs"
|
||||
|| lower === "unionfs-fuse"
|
||||
|| lower === "ceph"
|
||||
|| lower === "ceph-fuse"
|
||||
|| lower === "cephfs"
|
||||
|| lower === "gluster"
|
||||
|| lower === "glusterfs"
|
||||
|| lower === "ufs"
|
||||
) {
|
||||
return true;
|
||||
@@ -39,12 +56,31 @@ export function isNetworkOrFuseFilesystemType(filesystemType: string | undefined
|
||||
const type = filesystemType?.trim().toLowerCase();
|
||||
if (!type) return false;
|
||||
if (type.includes("clouddrive")) return true;
|
||||
if (/^fuse\.(rclone|sshfs|s3fs|gcsfuse|ufs)$/.test(type)) return true;
|
||||
if (
|
||||
/^fuse\.(rclone|sshfs|s3fs|gcsfuse|ufs|mergerfs|unionfs|unionfs-fuse|ceph|ceph-fuse|cephfs|glusterfs)$/
|
||||
.test(type)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
"fuse",
|
||||
"rclone",
|
||||
"sshfs",
|
||||
"s3fs",
|
||||
"gcsfuse",
|
||||
"mergerfs",
|
||||
"unionfs",
|
||||
"unionfs-fuse",
|
||||
"nfs",
|
||||
"nfs4",
|
||||
"cifs",
|
||||
"smb",
|
||||
"smb3",
|
||||
"smbfs",
|
||||
"afs",
|
||||
"ceph",
|
||||
"cephfs",
|
||||
"glusterfs",
|
||||
].includes(type);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,16 @@ function registerAgentProcessHandlers(ctx) {
|
||||
return { ok: true, count: list.length };
|
||||
});
|
||||
|
||||
// App-owned live session state is independent of the optional External MCP
|
||||
// surface. It lets host_open-owned chat scopes observe connection changes
|
||||
// even when the opened terminal never mounts its own AI side panel.
|
||||
ipcMain.handle("netcatty:ai:mcp:update-live-sessions", async (event, { sessions: sessionList }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
return mcpServerBridge.updateLiveSessionMetadata(
|
||||
Array.isArray(sessionList) ? sessionList : [],
|
||||
);
|
||||
});
|
||||
|
||||
// Merge (do not replace) session metadata into a chat scope. Used when agents
|
||||
// open a host mid-turn so terminal tools can target the new sessionId
|
||||
// without waiting for the next full scope push.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -94,8 +94,14 @@ function disconnectExternalMcpClients() {
|
||||
// Per-scope metadata: chatSessionId → { sessionIds: string[], metadata: Map<sessionId, meta> }
|
||||
// Each chat session only sees the hosts registered for its scope.
|
||||
const scopedMetadata = new Map();
|
||||
let liveSessionMetadata = new Map();
|
||||
let sessionMetadataRevision = 0;
|
||||
let terminalSessionCloseRevision = 0;
|
||||
const closedTerminalSessionRevisions = new Map();
|
||||
const MAX_CLOSED_TERMINAL_SESSION_REVISIONS = 2048;
|
||||
const scopedAttachments = new Map(); // chatSessionId -> Map<filePath, attachment>
|
||||
const { createSessionOwnershipRegistry } = require("./mcpServerBridge/sessionOwnership.cjs");
|
||||
const { retainOwnedSessions, mergeRetentionMeta } = require("./mcpServerBridge/retainOwnedSessions.cjs");
|
||||
const {
|
||||
createSessionIdleManager,
|
||||
normalizeSessionIdleTimeoutMinutes,
|
||||
@@ -368,6 +374,8 @@ const {
|
||||
releaseSessionExecution,
|
||||
} = backgroundJobApi;
|
||||
|
||||
let disposeWorkerSessionClosed = null;
|
||||
|
||||
function init(deps) {
|
||||
sessions = deps.sessions;
|
||||
terminalWorkerManager = deps.terminalWorkerManager || null;
|
||||
@@ -378,6 +386,23 @@ function init(deps) {
|
||||
if (deps.commandBlocklist) {
|
||||
commandBlocklist = deps.commandBlocklist;
|
||||
}
|
||||
try { disposeWorkerSessionClosed?.dispose?.(); } catch { /* ignore */ }
|
||||
disposeWorkerSessionClosed = null;
|
||||
// Only explicit UI/tab closes drop host_open ownership. Shell "exited"
|
||||
// events (including missing/nonzero exitCode) and recoverable exits
|
||||
// (error / worker-exit / superseded / quiet transport "closed") keep
|
||||
// ownership so a disconnected tab can reconnect with the same session id.
|
||||
// Auto-close clean exits still forget ownership when the renderer closes
|
||||
// the tab and the worker emits explicit:true.
|
||||
if (typeof terminalWorkerManager?.onSessionClosed === "function") {
|
||||
disposeWorkerSessionClosed = terminalWorkerManager.onSessionClosed((event) => {
|
||||
const sessionId = event?.sessionId;
|
||||
if (!sessionId) return;
|
||||
if (event?.explicit === true) {
|
||||
forgetClosedTerminalSession(sessionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function listActivePortForwards() {
|
||||
@@ -441,6 +466,7 @@ function shutdownHost({ preserveScopedMetadata = false } = {}) {
|
||||
if (!preserveScopedMetadata) {
|
||||
scopedMetadata.clear();
|
||||
scopedAttachments.clear();
|
||||
liveSessionMetadata.clear();
|
||||
}
|
||||
for (const [, job] of backgroundJobs) {
|
||||
try {
|
||||
@@ -598,12 +624,50 @@ function syncLiveSessionsToExternalScope(chatSessionId = EXTERNAL_MCP_CHAT_SESSI
|
||||
return { ok: true, count: sessionList.length, chatSessionId };
|
||||
}
|
||||
|
||||
function findSessionMetaAcrossScopes(sessionId) {
|
||||
for (const scoped of scopedMetadata.values()) {
|
||||
function findSessionMetaAcrossScopes(sessionId, excludeChatSessionId = null) {
|
||||
let latest = liveSessionMetadata.get(sessionId) || null;
|
||||
let latestRevision = Number.isSafeInteger(latest?._revision) ? latest._revision : -1;
|
||||
for (const [scopeId, scoped] of scopedMetadata.entries()) {
|
||||
if (excludeChatSessionId && scopeId === excludeChatSessionId) continue;
|
||||
const meta = scoped?.metadata?.get?.(sessionId);
|
||||
if (meta) return meta;
|
||||
if (!meta) continue;
|
||||
const revision = Number.isSafeInteger(meta._revision) ? meta._revision : 0;
|
||||
if (revision > latestRevision) {
|
||||
latest = meta;
|
||||
latestRevision = revision;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* App-owned live session snapshot. Unlike the External MCP scope, this is
|
||||
* always refreshed by the main renderer and is never exposed as a scope of
|
||||
* its own. It only supplies current state for session ids a chat already owns.
|
||||
*/
|
||||
function updateLiveSessionMetadata(sessionList) {
|
||||
const incoming = Array.isArray(sessionList) ? sessionList : [];
|
||||
const updateRevision = ++sessionMetadataRevision;
|
||||
const next = new Map();
|
||||
for (const entry of incoming) {
|
||||
if (!entry || typeof entry !== "object" || !entry.sessionId) continue;
|
||||
next.set(entry.sessionId, {
|
||||
hostname: entry.hostname || "",
|
||||
label: entry.label || "",
|
||||
os: entry.os || "",
|
||||
username: entry.username || "",
|
||||
protocol: entry.protocol || "",
|
||||
shellType: entry.shellType || "",
|
||||
deviceType: entry.deviceType || "",
|
||||
connected: entry.connected !== false,
|
||||
hostId: entry.hostId || "",
|
||||
hostChain: Array.isArray(entry.hostChain) ? entry.hostChain : [],
|
||||
activePortForwards: Array.isArray(entry.activePortForwards) ? entry.activePortForwards : [],
|
||||
_revision: updateRevision,
|
||||
});
|
||||
}
|
||||
liveSessionMetadata = next;
|
||||
return { ok: true, count: next.size };
|
||||
}
|
||||
|
||||
function seedExternalScopeFromOtherScopes(chatSessionId) {
|
||||
@@ -612,7 +676,10 @@ function seedExternalScopeFromOtherScopes(chatSessionId) {
|
||||
if (scopeId === chatSessionId || !scoped?.metadata) continue;
|
||||
for (const [sessionId, meta] of scoped.metadata.entries()) {
|
||||
if (!sessionId || !meta) continue;
|
||||
if (!byId.has(sessionId)) {
|
||||
const existing = byId.get(sessionId);
|
||||
const existingRevision = Number.isSafeInteger(existing?._revision) ? existing._revision : 0;
|
||||
const revision = Number.isSafeInteger(meta._revision) ? meta._revision : 0;
|
||||
if (!existing || revision > existingRevision) {
|
||||
byId.set(sessionId, { sessionId, ...meta });
|
||||
}
|
||||
}
|
||||
@@ -680,14 +747,34 @@ function beginChatExecution(chatSessionId, sessionId, command) {
|
||||
* @param {string} [chatSessionId] - AI chat session ID for per-scope isolation
|
||||
*/
|
||||
function updateSessionMetadata(sessionList, chatSessionId) {
|
||||
const incoming = Array.isArray(sessionList) ? sessionList : [];
|
||||
const updateRevision = ++sessionMetadataRevision;
|
||||
// Authoritative empty replace: clear retention ownership for this scope so a
|
||||
// later non-empty sync cannot resurrect sessions via cross-scope fallback.
|
||||
if (chatSessionId && incoming.length === 0) {
|
||||
openedSessionOwnership.releaseScopeOwnership(chatSessionId);
|
||||
}
|
||||
// host_open merges the new session into this chat scope, but the AI side
|
||||
// panel later pushes a full replace of only the currently focused tab —
|
||||
// which would drop mid-turn opened sessions. Retain ownership-tracked ids.
|
||||
const effectiveList = chatSessionId
|
||||
? retainOwnedSessions({
|
||||
incomingSessions: incoming,
|
||||
ownedSessionIds: openedSessionOwnership.listOwned(chatSessionId),
|
||||
previousById: scopedMetadata.get(chatSessionId)?.metadata || null,
|
||||
// Skip the current scope so a stale connected:false snapshot cannot
|
||||
// shadow a fresher copy from External MCP / another chat tab.
|
||||
findFallbackMeta: (sessionId) => findSessionMetaAcrossScopes(sessionId, chatSessionId),
|
||||
})
|
||||
: incoming;
|
||||
debugLog("updateSessionMetadata", {
|
||||
chatSessionId,
|
||||
count: Array.isArray(sessionList) ? sessionList.length : 0,
|
||||
sessionIds: Array.isArray(sessionList) ? sessionList.map(s => s.sessionId) : [],
|
||||
count: effectiveList.length,
|
||||
sessionIds: effectiveList.map(s => s.sessionId),
|
||||
});
|
||||
const ids = sessionList.map(s => s.sessionId);
|
||||
const ids = effectiveList.map(s => s.sessionId);
|
||||
const metaMap = new Map();
|
||||
for (const s of sessionList) {
|
||||
for (const s of effectiveList) {
|
||||
metaMap.set(s.sessionId, {
|
||||
hostname: s.hostname || "",
|
||||
label: s.label || "",
|
||||
@@ -700,6 +787,7 @@ function updateSessionMetadata(sessionList, chatSessionId) {
|
||||
hostId: s.hostId || "",
|
||||
hostChain: Array.isArray(s.hostChain) ? s.hostChain : [],
|
||||
activePortForwards: Array.isArray(s.activePortForwards) ? s.activePortForwards : [],
|
||||
_revision: Number.isSafeInteger(s._revision) ? s._revision : updateRevision,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -919,11 +1007,92 @@ function resolveScopedSessionIds(chatSessionId, explicitScopedIds = null) {
|
||||
/**
|
||||
* Look up metadata for a sessionId, scoped to a specific chat session.
|
||||
* Falls back to session object properties if no scoped metadata is found.
|
||||
* When this scope still has a stale connected:false snapshot (common after
|
||||
* host_open + tab switch), refresh connected from a fresher cross-scope copy.
|
||||
* Owned host_open sessions also refresh while still connected so another
|
||||
* scope's empty activePortForwards reaches getContext without a later sidebar
|
||||
* replace.
|
||||
*/
|
||||
function getSessionMeta(sessionId, chatSessionId) {
|
||||
if (!chatSessionId) return null;
|
||||
const scoped = scopedMetadata.get(chatSessionId);
|
||||
return scoped?.metadata?.get(sessionId) || null;
|
||||
const meta = scoped?.metadata?.get(sessionId) || null;
|
||||
if (!meta) return null;
|
||||
const owned = openedSessionOwnership.validate(chatSessionId, sessionId).ok;
|
||||
// Live non-owned snapshots stay local. Owned sessions (and stale
|
||||
// connected:false copies) consult other scopes.
|
||||
if (!owned && meta.connected !== false) return toPublicSessionMeta(meta);
|
||||
const fresher = findSessionMetaAcrossScopes(sessionId, chatSessionId);
|
||||
if (!fresher) return toPublicSessionMeta(meta);
|
||||
if (!owned && fresher.connected === false) return toPublicSessionMeta(meta);
|
||||
const refreshed = mergeRetentionMeta(meta, fresher);
|
||||
if (!refreshed) return toPublicSessionMeta(meta);
|
||||
scoped.metadata.set(sessionId, refreshed);
|
||||
return toPublicSessionMeta(refreshed);
|
||||
}
|
||||
|
||||
function toPublicSessionMeta(meta) {
|
||||
if (!meta || typeof meta !== "object") return meta;
|
||||
const { _revision, ...publicMeta } = meta;
|
||||
return publicMeta;
|
||||
}
|
||||
|
||||
function buildOpenedSessionMeta(result, sessionId) {
|
||||
const host = result?.host && typeof result.host === "object" ? result.host : {};
|
||||
return {
|
||||
sessionId,
|
||||
hostname: host.hostname || "",
|
||||
label: host.label || host.hostname || sessionId,
|
||||
os: host.os || "",
|
||||
username: host.username || "",
|
||||
protocol: result?.protocol || host.protocol || "",
|
||||
shellType: "",
|
||||
deviceType: host.deviceType || "",
|
||||
connected: result?.status === "connected",
|
||||
hostId: result?.hostId || host.id || "",
|
||||
hostChain: [],
|
||||
activePortForwards: [],
|
||||
};
|
||||
}
|
||||
|
||||
function ensureOpenedSessionMetadata(chatSessionId, sessionId, result) {
|
||||
if (!chatSessionId || !sessionId) return;
|
||||
if (scopedMetadata.get(chatSessionId)?.metadata?.has(sessionId)) return;
|
||||
const latest = findSessionMetaAcrossScopes(sessionId, chatSessionId);
|
||||
mergeSessionMetadata([
|
||||
latest ? { sessionId, ...latest } : buildOpenedSessionMeta(result, sessionId),
|
||||
], chatSessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop host_open ownership and scoped metadata after a terminal session is
|
||||
* actually closed (explicit UI tab close or agent session_close).
|
||||
*/
|
||||
function forgetClosedTerminalSession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
terminalSessionCloseRevision += 1;
|
||||
closedTerminalSessionRevisions.delete(sessionId);
|
||||
closedTerminalSessionRevisions.set(sessionId, terminalSessionCloseRevision);
|
||||
while (closedTerminalSessionRevisions.size > MAX_CLOSED_TERMINAL_SESSION_REVISIONS) {
|
||||
closedTerminalSessionRevisions.delete(closedTerminalSessionRevisions.keys().next().value);
|
||||
}
|
||||
sessionIdleManager.forgetSession(sessionId);
|
||||
openedSessionOwnership.forgetSession(sessionId);
|
||||
liveSessionMetadata.delete(sessionId);
|
||||
for (const scoped of scopedMetadata.values()) {
|
||||
scoped.sessionIds = scoped.sessionIds.filter((id) => id !== sessionId);
|
||||
scoped.metadata.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function forgetUnownedTerminalSessionMetadata(sessionId) {
|
||||
if (!sessionId) return;
|
||||
openedSessionOwnership.forgetSession(sessionId);
|
||||
liveSessionMetadata.delete(sessionId);
|
||||
for (const scoped of scopedMetadata.values()) {
|
||||
scoped.sessionIds = scoped.sessionIds.filter((id) => id !== sessionId);
|
||||
scoped.metadata.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1192,6 +1361,10 @@ const sessionIdleManager = createSessionIdleManager({
|
||||
function reportOpenedSessionActivity(event = {}) {
|
||||
const sessionId = event?.sessionId;
|
||||
if (!sessionId) return false;
|
||||
if (event.phase === "closed") {
|
||||
forgetClosedTerminalSession(sessionId);
|
||||
return true;
|
||||
}
|
||||
if (event.phase === "begin") {
|
||||
return sessionIdleManager.beginActivity(null, sessionId);
|
||||
}
|
||||
@@ -1231,20 +1404,14 @@ sessionService = createSessionService({
|
||||
endTerminalSessionClose(params.sessionId);
|
||||
if (outcome.closed) return;
|
||||
if (outcome.notFound) {
|
||||
sessionIdleManager.forgetSession(params.sessionId);
|
||||
openedSessionOwnership.forgetSession(params.sessionId);
|
||||
forgetClosedTerminalSession(params.sessionId);
|
||||
return;
|
||||
}
|
||||
sessionIdleManager.resume(params.sessionId);
|
||||
},
|
||||
onClosed: async (sessionId) => {
|
||||
await settleBackgroundJobsForTerminalSession(sessionId);
|
||||
sessionIdleManager.forgetSession(sessionId);
|
||||
openedSessionOwnership.forgetSession(sessionId);
|
||||
for (const scoped of scopedMetadata.values()) {
|
||||
scoped.sessionIds = scoped.sessionIds.filter((id) => id !== sessionId);
|
||||
scoped.metadata.delete(sessionId);
|
||||
}
|
||||
forgetClosedTerminalSession(sessionId);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1267,9 +1434,40 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
|
||||
USER_DENIED_MESSAGE,
|
||||
listPortForwards: () => listActivePortForwards(),
|
||||
sessionService,
|
||||
captureHostOpenScope: (chatSessionId) => openedSessionOwnership.captureGeneration(chatSessionId),
|
||||
onHostOpened: (chatSessionId, sessionId, generation) => {
|
||||
openedSessionOwnership.register(chatSessionId, sessionId, generation);
|
||||
captureHostOpenScope: (chatSessionId) => ({
|
||||
ownershipGeneration: openedSessionOwnership.captureGeneration(chatSessionId),
|
||||
closeRevision: terminalSessionCloseRevision,
|
||||
}),
|
||||
onHostOpened: async (chatSessionId, sessionId, generation, result) => {
|
||||
// The renderer creates the tab before replying to host_open. If the user
|
||||
// explicitly closes it while that reply is still in flight, the eventual
|
||||
// result must not revive ownership or scope metadata for the closed tab.
|
||||
if ((closedTerminalSessionRevisions.get(sessionId) || 0) > generation?.closeRevision) return;
|
||||
if (!openedSessionOwnership.register(
|
||||
chatSessionId,
|
||||
sessionId,
|
||||
generation?.ownershipGeneration ?? generation,
|
||||
)) {
|
||||
// The chat may have been deleted while the renderer was creating the
|
||||
// terminal. The generation revoke correctly rejects ownership, but the
|
||||
// newly created backend session must also be reclaimed or it becomes an
|
||||
// unowned session that no agent can close.
|
||||
// host_open mints a fresh tab/session id. A reconnect with that id is the
|
||||
// same logical tab and must also be reclaimed after its initiating chat
|
||||
// was deleted; unrelated sessions do not reuse the minted id.
|
||||
sessionIdleManager.track(chatSessionId, sessionId);
|
||||
await sessionService.closeTracked({ chatSessionId, sessionId });
|
||||
// A failed close stays idle-tracked so the normal bounded retry can try
|
||||
// again. Remove only scope/live metadata here so a deleted chat cannot be
|
||||
// recreated by the renderer's late merge.
|
||||
forgetUnownedTerminalSessionMetadata(sessionId);
|
||||
return;
|
||||
}
|
||||
closedTerminalSessionRevisions.delete(sessionId);
|
||||
// A full empty scope replace can land after the renderer's best-effort
|
||||
// host_open merge but before this async operation completes. Once the open
|
||||
// succeeds, restore the returned session atomically with its ownership.
|
||||
ensureOpenedSessionMetadata(chatSessionId, sessionId, result);
|
||||
sessionIdleManager.track(chatSessionId, sessionId);
|
||||
},
|
||||
});
|
||||
@@ -1992,6 +2190,8 @@ const configAndCleanupApi = createConfigAndCleanupApi({
|
||||
const { resolveMcpServerRuntimeCommand, buildMcpServerConfig, cleanupScopedMetadata } = configAndCleanupApi;
|
||||
|
||||
function cleanup() {
|
||||
try { disposeWorkerSessionClosed?.dispose?.(); } catch { /* ignore */ }
|
||||
disposeWorkerSessionClosed = null;
|
||||
shutdownHost();
|
||||
}
|
||||
|
||||
@@ -2013,6 +2213,7 @@ module.exports = {
|
||||
applyChatSessionCancelled,
|
||||
checkCommandSafety,
|
||||
updateSessionMetadata,
|
||||
updateLiveSessionMetadata,
|
||||
mergeSessionMetadata,
|
||||
updateAttachmentMetadata,
|
||||
handleListAttachments,
|
||||
@@ -2029,6 +2230,7 @@ module.exports = {
|
||||
hasActiveWorkerJobForTerminalSession,
|
||||
cancelSftpOpsForSession,
|
||||
getSessionMeta,
|
||||
forgetClosedTerminalSession,
|
||||
cleanupScopedMetadata,
|
||||
cleanup,
|
||||
shutdownHost,
|
||||
|
||||
@@ -0,0 +1,816 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
function loadFreshBridge() {
|
||||
const bridgePath = require.resolve("./mcpServerBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
// Also clear retain/ownership deps so a fresh bridge gets fresh module state.
|
||||
for (const key of Object.keys(require.cache)) {
|
||||
if (key.includes(`${require("node:path").sep}mcpServerBridge${require("node:path").sep}`)) {
|
||||
delete require.cache[key];
|
||||
}
|
||||
}
|
||||
return require("./mcpServerBridge.cjs");
|
||||
}
|
||||
|
||||
test("sidebar scope replace keeps host_open-owned sessions in the chat scope", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: null,
|
||||
});
|
||||
bridge.setPermissionMode("auto");
|
||||
bridge.setVaultAgentInvoker(async (op) => {
|
||||
if (op === "host.open") {
|
||||
return { ok: true, sessionId: "sess-opened", hostId: "host-b", status: "connecting" };
|
||||
}
|
||||
return { ok: false, error: `unexpected op ${op}` };
|
||||
});
|
||||
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
},
|
||||
{
|
||||
sessionId: "sess-opened",
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: false,
|
||||
hostId: "host-b",
|
||||
protocol: "ssh",
|
||||
},
|
||||
], "chat-1");
|
||||
|
||||
const opened = await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
|
||||
chatSessionId: "chat-1",
|
||||
hostId: "host-b",
|
||||
});
|
||||
assert.equal(opened.ok, true);
|
||||
assert.equal(opened.sessionId, "sess-opened");
|
||||
|
||||
// AIChatSidePanel-style full replace of only the focused tab.
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
},
|
||||
], "chat-1");
|
||||
|
||||
assert.deepEqual(
|
||||
bridge.getScopedSessionIds("chat-1").sort(),
|
||||
["sess-opened", "sess-original"],
|
||||
);
|
||||
});
|
||||
|
||||
test("authoritative empty scope replace still clears host_open-owned sessions", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: null,
|
||||
});
|
||||
bridge.setPermissionMode("auto");
|
||||
bridge.setVaultAgentInvoker(async () => ({
|
||||
ok: true,
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
status: "connecting",
|
||||
}));
|
||||
|
||||
bridge.updateSessionMetadata([
|
||||
{ sessionId: "sess-opened", hostname: "10.0.0.2", label: "server-b", connected: false },
|
||||
], "chat-1");
|
||||
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
|
||||
chatSessionId: "chat-1",
|
||||
hostId: "host-b",
|
||||
});
|
||||
|
||||
bridge.updateSessionMetadata([], "chat-1");
|
||||
assert.deepEqual(bridge.getScopedSessionIds("chat-1"), []);
|
||||
|
||||
// A later non-empty sync must not resurrect the cleared owned session via
|
||||
// cross-scope fallback / stale ownership.
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-opened",
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
},
|
||||
], "__external_mcp__");
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
},
|
||||
], "chat-1");
|
||||
assert.deepEqual(bridge.getScopedSessionIds("chat-1"), ["sess-original"]);
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1"), null);
|
||||
});
|
||||
|
||||
test("retained host_open metadata refreshes connected from another scope", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: null,
|
||||
});
|
||||
bridge.setPermissionMode("auto");
|
||||
bridge.setVaultAgentInvoker(async () => ({
|
||||
ok: true,
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
status: "connecting",
|
||||
}));
|
||||
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-opened",
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: false,
|
||||
hostId: "host-b",
|
||||
protocol: "ssh",
|
||||
},
|
||||
], "chat-1");
|
||||
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
|
||||
chatSessionId: "chat-1",
|
||||
hostId: "host-b",
|
||||
});
|
||||
|
||||
// Another surface (External MCP / opened tab) learns the session connected.
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-opened",
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
protocol: "ssh",
|
||||
username: "root",
|
||||
},
|
||||
], "__external_mcp__");
|
||||
|
||||
// Original chat sidebar still pushes only the focused tab.
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
},
|
||||
], "chat-1");
|
||||
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1")?.connected, true);
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1")?.username, "root");
|
||||
});
|
||||
|
||||
test("retained host_open metadata accepts a later disconnect from another scope", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: null,
|
||||
});
|
||||
bridge.setPermissionMode("auto");
|
||||
bridge.setVaultAgentInvoker(async () => ({
|
||||
ok: true,
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
status: "connecting",
|
||||
}));
|
||||
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-opened",
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
protocol: "ssh",
|
||||
},
|
||||
], "chat-1");
|
||||
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
|
||||
chatSessionId: "chat-1",
|
||||
hostId: "host-b",
|
||||
});
|
||||
|
||||
// The focused tab moved away; External MCP later learns the host dropped.
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-opened",
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: false,
|
||||
hostId: "host-b",
|
||||
protocol: "ssh",
|
||||
},
|
||||
], "__external_mcp__");
|
||||
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
},
|
||||
], "chat-1");
|
||||
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1")?.connected, false);
|
||||
});
|
||||
|
||||
test("getSessionMeta clears stopped port forwards from another scope while owned session stays connected", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: null,
|
||||
});
|
||||
bridge.setPermissionMode("auto");
|
||||
bridge.setVaultAgentInvoker(async () => ({
|
||||
ok: true,
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
status: "connecting",
|
||||
}));
|
||||
|
||||
const forwards = [{ ruleId: "fwd-1", localPort: 8080, status: "active" }];
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-opened",
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
protocol: "ssh",
|
||||
activePortForwards: forwards,
|
||||
},
|
||||
], "chat-1");
|
||||
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
|
||||
chatSessionId: "chat-1",
|
||||
hostId: "host-b",
|
||||
});
|
||||
|
||||
// Another surface reports the session still up, but forwards already stopped.
|
||||
// chat-1 does not get another metadata replace (focused tab stayed elsewhere).
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-opened",
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
protocol: "ssh",
|
||||
activePortForwards: [],
|
||||
},
|
||||
], "__external_mcp__");
|
||||
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1")?.connected, true);
|
||||
assert.deepEqual(bridge.getSessionMeta("sess-opened", "chat-1")?.activePortForwards, []);
|
||||
});
|
||||
|
||||
test("getSessionMeta does not let another scope clear forwards on a live non-owned session", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: null,
|
||||
});
|
||||
|
||||
const forwards = [{ ruleId: "fwd-1", localPort: 8080, status: "active" }];
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-live",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
activePortForwards: forwards,
|
||||
},
|
||||
], "chat-1");
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-live",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
activePortForwards: [],
|
||||
},
|
||||
], "__external_mcp__");
|
||||
|
||||
assert.deepEqual(
|
||||
bridge.getSessionMeta("sess-live", "chat-1")?.activePortForwards,
|
||||
forwards,
|
||||
);
|
||||
});
|
||||
|
||||
test("ordinary tab close forgets host_open ownership so sidebar sync cannot revive ghosts", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
const closedListeners = new Set();
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: null,
|
||||
terminalWorkerManager: {
|
||||
onSessionClosed(listener) {
|
||||
closedListeners.add(listener);
|
||||
return {
|
||||
dispose: () => closedListeners.delete(listener),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
bridge.setPermissionMode("auto");
|
||||
bridge.setVaultAgentInvoker(async () => ({
|
||||
ok: true,
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
status: "connecting",
|
||||
}));
|
||||
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-opened",
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
},
|
||||
], "chat-1");
|
||||
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
|
||||
chatSessionId: "chat-1",
|
||||
hostId: "host-b",
|
||||
});
|
||||
|
||||
// User closes the host_open tab through the normal UI / worker path.
|
||||
for (const listener of closedListeners) {
|
||||
listener({ sessionId: "sess-opened", reason: "closed", explicit: true });
|
||||
}
|
||||
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
},
|
||||
], "chat-1");
|
||||
|
||||
assert.deepEqual(bridge.getScopedSessionIds("chat-1"), ["sess-original"]);
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1"), null);
|
||||
});
|
||||
|
||||
test("recoverable worker exits keep host_open ownership for reconnect", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
const closedListeners = new Set();
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: null,
|
||||
terminalWorkerManager: {
|
||||
onSessionClosed(listener) {
|
||||
closedListeners.add(listener);
|
||||
return {
|
||||
dispose: () => closedListeners.delete(listener),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
bridge.setPermissionMode("auto");
|
||||
bridge.setVaultAgentInvoker(async () => ({
|
||||
ok: true,
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
status: "connecting",
|
||||
}));
|
||||
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-opened",
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
},
|
||||
], "chat-1");
|
||||
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
|
||||
chatSessionId: "chat-1",
|
||||
hostId: "host-b",
|
||||
});
|
||||
|
||||
for (const event of [
|
||||
{ reason: "error" },
|
||||
{ reason: "worker-exit" },
|
||||
{ reason: "superseded" },
|
||||
{ reason: "closed" },
|
||||
// Shell exits that leave the tab for reconnect (missing/nonzero exitCode).
|
||||
{ reason: "exited" },
|
||||
{ reason: "exited", exitCode: 1 },
|
||||
]) {
|
||||
for (const listener of closedListeners) {
|
||||
listener({ sessionId: "sess-opened", ...event });
|
||||
}
|
||||
}
|
||||
|
||||
// After reconnect, sidebar may push only the focused tab; ownership must
|
||||
// still retain the host_open session.
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
},
|
||||
], "chat-1");
|
||||
|
||||
assert.deepEqual(
|
||||
bridge.getScopedSessionIds("chat-1").sort(),
|
||||
["sess-opened", "sess-original"],
|
||||
);
|
||||
});
|
||||
|
||||
test("clean shell exit keeps ownership until the tab is explicitly closed", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
const closedListeners = new Set();
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: null,
|
||||
terminalWorkerManager: {
|
||||
onSessionClosed(listener) {
|
||||
closedListeners.add(listener);
|
||||
return {
|
||||
dispose: () => closedListeners.delete(listener),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
bridge.setPermissionMode("auto");
|
||||
bridge.setVaultAgentInvoker(async () => ({
|
||||
ok: true,
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
status: "connecting",
|
||||
}));
|
||||
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-opened",
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
},
|
||||
], "chat-1");
|
||||
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
|
||||
chatSessionId: "chat-1",
|
||||
hostId: "host-b",
|
||||
});
|
||||
|
||||
// Worker reports a clean exit; auto-close may still leave the tab briefly,
|
||||
// and disabled auto-close keeps it for reconnect — ownership stays.
|
||||
for (const listener of closedListeners) {
|
||||
listener({ sessionId: "sess-opened", reason: "exited", exitCode: 0 });
|
||||
}
|
||||
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
},
|
||||
], "chat-1");
|
||||
assert.deepEqual(
|
||||
bridge.getScopedSessionIds("chat-1").sort(),
|
||||
["sess-opened", "sess-original"],
|
||||
);
|
||||
|
||||
// Renderer tab close is the authoritative ownership drop.
|
||||
for (const listener of closedListeners) {
|
||||
listener({ sessionId: "sess-opened", reason: "closed", explicit: true });
|
||||
}
|
||||
bridge.updateSessionMetadata([
|
||||
{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
},
|
||||
], "chat-1");
|
||||
assert.deepEqual(bridge.getScopedSessionIds("chat-1"), ["sess-original"]);
|
||||
});
|
||||
|
||||
test("latest metadata revision wins across multiple chat and external scopes", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
bridge.init({ sessions: new Map(), electronModule: null });
|
||||
bridge.setPermissionMode("auto");
|
||||
bridge.setVaultAgentInvoker(async () => ({
|
||||
ok: true,
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
status: "connecting",
|
||||
}));
|
||||
|
||||
bridge.updateSessionMetadata([{
|
||||
sessionId: "sess-opened",
|
||||
connected: true,
|
||||
activePortForwards: [{ ruleId: "old-forward" }],
|
||||
}], "chat-stale");
|
||||
bridge.updateSessionMetadata([{
|
||||
sessionId: "sess-opened",
|
||||
connected: true,
|
||||
activePortForwards: [],
|
||||
}], "chat-owner");
|
||||
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
|
||||
chatSessionId: "chat-owner",
|
||||
hostId: "host-b",
|
||||
});
|
||||
|
||||
bridge.updateSessionMetadata([{
|
||||
sessionId: "sess-opened",
|
||||
connected: false,
|
||||
activePortForwards: [],
|
||||
}], "__external_mcp__");
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-owner")?.connected, false);
|
||||
assert.deepEqual(bridge.getSessionMeta("sess-opened", "chat-owner")?.activePortForwards, []);
|
||||
|
||||
bridge.updateSessionMetadata([{
|
||||
sessionId: "sess-opened",
|
||||
connected: true,
|
||||
username: "reconnected-user",
|
||||
activePortForwards: [{ ruleId: "new-forward" }],
|
||||
}], "chat-live");
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-owner")?.connected, true);
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-owner")?.username, "reconnected-user");
|
||||
assert.deepEqual(
|
||||
bridge.getSessionMeta("sess-opened", "chat-owner")?.activePortForwards,
|
||||
[{ ruleId: "new-forward" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("host_open finishing after an empty scope replace restores its returned session", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
bridge.init({ sessions: new Map(), electronModule: null });
|
||||
bridge.setPermissionMode("auto");
|
||||
let finishOpen;
|
||||
bridge.setVaultAgentInvoker(() => new Promise((resolve) => {
|
||||
finishOpen = resolve;
|
||||
}));
|
||||
|
||||
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-race");
|
||||
const opening = bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
|
||||
chatSessionId: "chat-race",
|
||||
hostId: "host-b",
|
||||
});
|
||||
bridge.updateSessionMetadata([], "chat-race");
|
||||
finishOpen({
|
||||
ok: true,
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
status: "connecting",
|
||||
protocol: "ssh",
|
||||
host: { id: "host-b", hostname: "10.0.0.2", label: "server-b", username: "root" },
|
||||
});
|
||||
|
||||
const opened = await opening;
|
||||
assert.equal(opened.ok, true);
|
||||
assert.deepEqual(bridge.getScopedSessionIds("chat-race"), ["sess-opened"]);
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-race")?.label, "server-b");
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-race")?.connected, false);
|
||||
});
|
||||
|
||||
test("initial external scope seed selects the latest session snapshot", (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
bridge.init({ sessions: new Map(), electronModule: null });
|
||||
|
||||
bridge.updateSessionMetadata([{
|
||||
sessionId: "sess-shared",
|
||||
connected: true,
|
||||
username: "old-user",
|
||||
}], "chat-old");
|
||||
bridge.updateSessionMetadata([{
|
||||
sessionId: "sess-shared",
|
||||
connected: false,
|
||||
username: "new-user",
|
||||
}], "chat-new");
|
||||
|
||||
const seeded = bridge.syncLiveSessionsToExternalScope();
|
||||
assert.equal(seeded.seeded, true);
|
||||
assert.equal(bridge.getSessionMeta("sess-shared", "__external_mcp__")?.connected, false);
|
||||
assert.equal(bridge.getSessionMeta("sess-shared", "__external_mcp__")?.username, "new-user");
|
||||
});
|
||||
|
||||
test("explicit close before host_open returns cannot revive the closed session", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
bridge.init({ sessions: new Map(), electronModule: null });
|
||||
bridge.setPermissionMode("auto");
|
||||
let finishOpen;
|
||||
bridge.setVaultAgentInvoker(() => new Promise((resolve) => {
|
||||
finishOpen = resolve;
|
||||
}));
|
||||
|
||||
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-close-race");
|
||||
const opening = bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
|
||||
chatSessionId: "chat-close-race",
|
||||
hostId: "host-b",
|
||||
});
|
||||
// Renderer-side host.open creates and merges the tab before returning its
|
||||
// result to the main process. Simulate the user closing that tab immediately.
|
||||
bridge.mergeSessionMetadata([{
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
connected: false,
|
||||
}], "chat-close-race");
|
||||
bridge.forgetClosedTerminalSession("sess-opened");
|
||||
finishOpen({
|
||||
ok: true,
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
status: "connecting",
|
||||
protocol: "ssh",
|
||||
});
|
||||
|
||||
assert.equal((await opening).ok, true);
|
||||
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-close-race");
|
||||
assert.deepEqual(bridge.getScopedSessionIds("chat-close-race"), ["sess-original"]);
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-close-race"), null);
|
||||
});
|
||||
|
||||
test("app live sync makes host_open session usable without External MCP or a second AI panel", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
const requests = [];
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: null,
|
||||
terminalWorkerManager: {
|
||||
request(channel, payload, options) {
|
||||
requests.push({ channel, payload, options });
|
||||
if (channel === "netcatty:portforward:list") return Promise.resolve([]);
|
||||
if (channel === "netcatty:ai:exec") {
|
||||
return Promise.resolve({ ok: true, stdout: "ready\n", stderr: "", exitCode: 0 });
|
||||
}
|
||||
throw new Error(`unexpected worker request: ${channel}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
bridge.setPermissionMode("auto");
|
||||
bridge.setVaultAgentInvoker(async () => ({
|
||||
ok: true,
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
status: "connecting",
|
||||
protocol: "ssh",
|
||||
host: { id: "host-b", hostname: "10.0.0.2", label: "server-b", username: "root" },
|
||||
}));
|
||||
|
||||
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-owner");
|
||||
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
|
||||
chatSessionId: "chat-owner",
|
||||
hostId: "host-b",
|
||||
});
|
||||
|
||||
// The app-level session state observes the connection. No External MCP scope
|
||||
// and no AI panel for the opened tab are involved.
|
||||
bridge.updateLiveSessionMetadata([{
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
connected: true,
|
||||
}]);
|
||||
|
||||
// Returning to the original tab replaces its sidebar payload with only A.
|
||||
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-owner");
|
||||
assert.deepEqual(
|
||||
bridge.getScopedSessionIds("chat-owner").sort(),
|
||||
["sess-opened", "sess-original"],
|
||||
);
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-owner")?.connected, true);
|
||||
|
||||
const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", {
|
||||
chatSessionId: "chat-owner",
|
||||
});
|
||||
const openedHost = environment.hosts.find((host) => host.sessionId === "sess-opened");
|
||||
assert.equal(openedHost?.connected, true);
|
||||
|
||||
const executed = await bridge.dispatchBuiltinRpc("netcatty/exec", {
|
||||
chatSessionId: "chat-owner",
|
||||
sessionId: "sess-opened",
|
||||
command: "pwd",
|
||||
});
|
||||
assert.deepEqual(executed, { ok: true, stdout: "ready\n", stderr: "", exitCode: 0 });
|
||||
assert.equal(requests.some((entry) => entry.channel === "netcatty:ai:exec"), true);
|
||||
});
|
||||
|
||||
test("chat cleanup during host_open closes the late-created unowned session", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
bridge.init({ sessions: new Map(), electronModule: null });
|
||||
bridge.setPermissionMode("auto");
|
||||
let finishOpen;
|
||||
const calls = [];
|
||||
bridge.setVaultAgentInvoker((op, params) => {
|
||||
calls.push({ op, params });
|
||||
if (op === "host.open") {
|
||||
return new Promise((resolve) => {
|
||||
finishOpen = resolve;
|
||||
});
|
||||
}
|
||||
if (op === "session.close") return Promise.resolve({ ok: true, sessionId: params.sessionId });
|
||||
throw new Error(`unexpected op ${op}`);
|
||||
});
|
||||
|
||||
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-deleted");
|
||||
const opening = bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
|
||||
chatSessionId: "chat-deleted",
|
||||
hostId: "host-b",
|
||||
});
|
||||
await bridge.cleanupScopedMetadata("chat-deleted");
|
||||
|
||||
// Renderer host.open can still finish its own merge after chat cleanup.
|
||||
bridge.mergeSessionMetadata([{
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
connected: false,
|
||||
}], "chat-deleted");
|
||||
finishOpen({
|
||||
ok: true,
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
status: "connecting",
|
||||
protocol: "ssh",
|
||||
});
|
||||
|
||||
assert.equal((await opening).ok, true);
|
||||
assert.deepEqual(calls.map(({ op }) => op), ["host.open", "session.close"]);
|
||||
assert.deepEqual(bridge.getScopedSessionIds("chat-deleted"), []);
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-deleted"), null);
|
||||
});
|
||||
|
||||
test("failed compensating close keeps the late-created session tracked for retry", async (t) => {
|
||||
const bridge = loadFreshBridge();
|
||||
t.after(() => bridge.cleanup());
|
||||
bridge.init({ sessions: new Map(), electronModule: null });
|
||||
bridge.setPermissionMode("auto");
|
||||
let finishOpen;
|
||||
bridge.setVaultAgentInvoker((op) => {
|
||||
if (op === "host.open") {
|
||||
return new Promise((resolve) => {
|
||||
finishOpen = resolve;
|
||||
});
|
||||
}
|
||||
if (op === "session.close") {
|
||||
return Promise.resolve({ ok: false, error: "renderer unavailable" });
|
||||
}
|
||||
throw new Error(`unexpected op ${op}`);
|
||||
});
|
||||
|
||||
const opening = bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
|
||||
chatSessionId: "chat-deleted",
|
||||
hostId: "host-b",
|
||||
});
|
||||
await bridge.cleanupScopedMetadata("chat-deleted");
|
||||
bridge.mergeSessionMetadata([{
|
||||
sessionId: "sess-opened",
|
||||
hostId: "host-b",
|
||||
connected: false,
|
||||
}], "chat-deleted");
|
||||
finishOpen({ ok: true, sessionId: "sess-opened", hostId: "host-b", status: "connecting" });
|
||||
|
||||
assert.equal((await opening).ok, true);
|
||||
assert.deepEqual(bridge.getScopedSessionIds("chat-deleted"), []);
|
||||
assert.equal(bridge.getSessionMeta("sess-opened", "chat-deleted"), null);
|
||||
assert.equal(
|
||||
bridge.reportOpenedSessionActivity({ sessionId: "sess-opened", phase: "touch" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -166,7 +166,7 @@ function createCapabilityRpcDispatcher(deps) {
|
||||
: null;
|
||||
const result = await handler(params);
|
||||
if (capability.id === "vault.host.open" && result?.ok !== false && result?.sessionId) {
|
||||
deps.onHostOpened?.(params?.chatSessionId, result.sessionId, hostOpenGeneration);
|
||||
await deps.onHostOpened?.(params?.chatSessionId, result.sessionId, hostOpenGeneration, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -169,15 +169,20 @@ test("dispatchCapabilityRpc preserves the host-open scope generation across the
|
||||
assert.equal(chatSessionId, "chat-1");
|
||||
return 7;
|
||||
},
|
||||
onHostOpened: (chatSessionId, sessionId, generation) => {
|
||||
registrations.push({ chatSessionId, sessionId, generation });
|
||||
onHostOpened: (chatSessionId, sessionId, generation, result) => {
|
||||
registrations.push({ chatSessionId, sessionId, generation, result });
|
||||
},
|
||||
invokeVaultAgent: async () => ({ ok: true, sessionId: "session-1" }),
|
||||
});
|
||||
|
||||
await dispatch("public/vault/hosts/open", { chatSessionId: "chat-1", hostId: "host-1" });
|
||||
|
||||
assert.deepEqual(registrations, [{ chatSessionId: "chat-1", sessionId: "session-1", generation: 7 }]);
|
||||
assert.deepEqual(registrations, [{
|
||||
chatSessionId: "chat-1",
|
||||
sessionId: "session-1",
|
||||
generation: 7,
|
||||
result: { ok: true, sessionId: "session-1" },
|
||||
}]);
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc routes vault hosts create to vault service", async () => {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Merge two scoped snapshots without letting an older cross-scope copy
|
||||
* overwrite newer connection state. Metadata revisions are assigned by the
|
||||
* main-process bridge when a renderer update arrives.
|
||||
*
|
||||
* @param {Record<string, unknown> | null | undefined} previous
|
||||
* @param {Record<string, unknown> | null | undefined} fallback
|
||||
* @returns {Record<string, unknown> | null}
|
||||
*/
|
||||
function mergeRetentionMeta(previous, fallback) {
|
||||
if (!previous && !fallback) return null;
|
||||
if (!previous) return fallback && typeof fallback === "object" ? fallback : null;
|
||||
if (!fallback || typeof fallback !== "object") return previous;
|
||||
|
||||
const previousRevision = Number.isSafeInteger(previous._revision) ? previous._revision : 0;
|
||||
const fallbackRevision = Number.isSafeInteger(fallback._revision) ? fallback._revision : 0;
|
||||
// Unversioned direct helper inputs retain the historical fallback-wins
|
||||
// behavior. Bridge-owned metadata is always versioned.
|
||||
const fallbackIsNewer = fallbackRevision >= previousRevision;
|
||||
const newer = fallbackIsNewer ? fallback : previous;
|
||||
const older = fallbackIsNewer ? previous : fallback;
|
||||
const connected = Object.prototype.hasOwnProperty.call(newer, "connected")
|
||||
? newer.connected !== false
|
||||
: older.connected !== false;
|
||||
return {
|
||||
...older,
|
||||
...newer,
|
||||
hostname: newer.hostname || older.hostname,
|
||||
label: newer.label || older.label,
|
||||
os: newer.os || older.os,
|
||||
username: newer.username || older.username,
|
||||
protocol: newer.protocol || older.protocol,
|
||||
shellType: newer.shellType || older.shellType,
|
||||
deviceType: newer.deviceType || older.deviceType,
|
||||
hostId: newer.hostId || older.hostId,
|
||||
hostChain: Array.isArray(newer.hostChain) ? newer.hostChain : older.hostChain,
|
||||
// Explicit empty arrays in the newer snapshot clear stopped forwards.
|
||||
activePortForwards: Array.isArray(newer.activePortForwards)
|
||||
? newer.activePortForwards
|
||||
: older.activePortForwards,
|
||||
connected,
|
||||
...(Math.max(previousRevision, fallbackRevision) > 0
|
||||
? { _revision: Math.max(previousRevision, fallbackRevision) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep host_open-owned sessions in a chat scope when a full metadata replace
|
||||
* would otherwise drop them (e.g. AIChatSidePanel pushing only the current
|
||||
* terminal tab after a mid-turn host_open).
|
||||
*
|
||||
* Empty incoming lists are treated as authoritative clears and are not retained.
|
||||
*
|
||||
* @param {{
|
||||
* incomingSessions: Array<Record<string, unknown>>,
|
||||
* ownedSessionIds: string[],
|
||||
* previousById?: Map<string, Record<string, unknown>> | null,
|
||||
* findFallbackMeta?: ((sessionId: string) => Record<string, unknown> | null | undefined) | null,
|
||||
* }} args
|
||||
* @returns {Array<Record<string, unknown>>}
|
||||
*/
|
||||
function retainOwnedSessions({
|
||||
incomingSessions,
|
||||
ownedSessionIds,
|
||||
previousById = null,
|
||||
findFallbackMeta = null,
|
||||
}) {
|
||||
if (!Array.isArray(incomingSessions) || incomingSessions.length === 0) {
|
||||
return incomingSessions;
|
||||
}
|
||||
if (!Array.isArray(ownedSessionIds) || ownedSessionIds.length === 0) {
|
||||
return incomingSessions;
|
||||
}
|
||||
|
||||
const byId = new Map();
|
||||
for (const entry of incomingSessions) {
|
||||
if (!entry || typeof entry !== "object" || !entry.sessionId) continue;
|
||||
byId.set(String(entry.sessionId), entry);
|
||||
}
|
||||
|
||||
for (const ownedIdRaw of ownedSessionIds) {
|
||||
const ownedId = typeof ownedIdRaw === "string" ? ownedIdRaw.trim() : "";
|
||||
if (!ownedId || byId.has(ownedId)) continue;
|
||||
|
||||
const previous = previousById?.get?.(ownedId) || null;
|
||||
// Always consult fallback — a stale previous connected:false must not
|
||||
// block a fresher cross-scope snapshot (e.g. External MCP / other tab).
|
||||
const fallback = typeof findFallbackMeta === "function"
|
||||
? findFallbackMeta(ownedId)
|
||||
: null;
|
||||
const meta = mergeRetentionMeta(previous, fallback);
|
||||
if (!meta || typeof meta !== "object") continue;
|
||||
|
||||
byId.set(ownedId, {
|
||||
sessionId: ownedId,
|
||||
hostname: meta.hostname || "",
|
||||
label: meta.label || "",
|
||||
os: meta.os || "",
|
||||
username: meta.username || "",
|
||||
protocol: meta.protocol || "",
|
||||
shellType: meta.shellType || "",
|
||||
deviceType: meta.deviceType || "",
|
||||
connected: meta.connected !== false,
|
||||
hostId: meta.hostId || "",
|
||||
hostChain: Array.isArray(meta.hostChain) ? meta.hostChain : [],
|
||||
activePortForwards: Array.isArray(meta.activePortForwards) ? meta.activePortForwards : [],
|
||||
...(Number.isSafeInteger(meta._revision) ? { _revision: meta._revision } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
module.exports = { retainOwnedSessions, mergeRetentionMeta };
|
||||
@@ -0,0 +1,267 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { retainOwnedSessions, mergeRetentionMeta } = require("./retainOwnedSessions.cjs");
|
||||
|
||||
test("retainOwnedSessions keeps host_open-owned sessions dropped by a full scope replace", () => {
|
||||
const previousById = new Map([
|
||||
["sess-original", {
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
}],
|
||||
["sess-opened", {
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
protocol: "ssh",
|
||||
connected: false,
|
||||
hostId: "host-b",
|
||||
}],
|
||||
]);
|
||||
|
||||
const retained = retainOwnedSessions({
|
||||
incomingSessions: [{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
}],
|
||||
ownedSessionIds: ["sess-opened"],
|
||||
previousById,
|
||||
});
|
||||
|
||||
const ids = retained.map((entry) => entry.sessionId).sort();
|
||||
assert.deepEqual(ids, ["sess-opened", "sess-original"]);
|
||||
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
|
||||
assert.equal(opened.label, "server-b");
|
||||
assert.equal(opened.hostId, "host-b");
|
||||
assert.equal(opened.connected, false);
|
||||
});
|
||||
|
||||
test("retainOwnedSessions does not alter authoritative empty replaces", () => {
|
||||
const retained = retainOwnedSessions({
|
||||
incomingSessions: [],
|
||||
ownedSessionIds: ["sess-opened"],
|
||||
previousById: new Map([
|
||||
["sess-opened", { hostname: "10.0.0.2", label: "server-b" }],
|
||||
]),
|
||||
});
|
||||
assert.deepEqual(retained, []);
|
||||
});
|
||||
|
||||
test("retainOwnedSessions falls back to cross-scope metadata when needed", () => {
|
||||
const retained = retainOwnedSessions({
|
||||
incomingSessions: [{ sessionId: "sess-original", label: "server-a" }],
|
||||
ownedSessionIds: ["sess-opened"],
|
||||
previousById: new Map(),
|
||||
findFallbackMeta: (sessionId) => (
|
||||
sessionId === "sess-opened"
|
||||
? { hostname: "10.0.0.2", label: "server-b", hostId: "host-b" }
|
||||
: null
|
||||
),
|
||||
});
|
||||
assert.equal(retained.length, 2);
|
||||
assert.equal(
|
||||
retained.find((entry) => entry.sessionId === "sess-opened")?.label,
|
||||
"server-b",
|
||||
);
|
||||
});
|
||||
|
||||
test("retainOwnedSessions ignores owned ids with no recoverable metadata", () => {
|
||||
const retained = retainOwnedSessions({
|
||||
incomingSessions: [{ sessionId: "sess-original", label: "server-a" }],
|
||||
ownedSessionIds: ["sess-ghost"],
|
||||
previousById: new Map(),
|
||||
});
|
||||
assert.deepEqual(retained.map((entry) => entry.sessionId), ["sess-original"]);
|
||||
});
|
||||
|
||||
test("retainOwnedSessions refreshes connected from fallback even when previous exists", () => {
|
||||
const retained = retainOwnedSessions({
|
||||
incomingSessions: [{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
}],
|
||||
ownedSessionIds: ["sess-opened"],
|
||||
previousById: new Map([
|
||||
["sess-opened", {
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: false,
|
||||
hostId: "host-b",
|
||||
}],
|
||||
]),
|
||||
findFallbackMeta: (sessionId) => (
|
||||
sessionId === "sess-opened"
|
||||
? {
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
username: "root",
|
||||
}
|
||||
: null
|
||||
),
|
||||
});
|
||||
|
||||
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
|
||||
assert.ok(opened);
|
||||
assert.equal(opened.connected, true);
|
||||
assert.equal(opened.username, "root");
|
||||
assert.equal(opened.hostId, "host-b");
|
||||
});
|
||||
|
||||
test("mergeRetentionMeta lets a later disconnected fallback replace connected:true", () => {
|
||||
const merged = mergeRetentionMeta(
|
||||
{
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
},
|
||||
{
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: false,
|
||||
hostId: "host-b",
|
||||
},
|
||||
);
|
||||
assert.equal(merged.connected, false);
|
||||
});
|
||||
|
||||
test("retainOwnedSessions applies a later disconnect from fallback metadata", () => {
|
||||
const retained = retainOwnedSessions({
|
||||
incomingSessions: [{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
}],
|
||||
ownedSessionIds: ["sess-opened"],
|
||||
previousById: new Map([
|
||||
["sess-opened", {
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
}],
|
||||
]),
|
||||
findFallbackMeta: (sessionId) => (
|
||||
sessionId === "sess-opened"
|
||||
? {
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: false,
|
||||
hostId: "host-b",
|
||||
}
|
||||
: null
|
||||
),
|
||||
});
|
||||
|
||||
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
|
||||
assert.ok(opened);
|
||||
assert.equal(opened.connected, false);
|
||||
});
|
||||
|
||||
test("mergeRetentionMeta clears activePortForwards when fallback reports an empty array", () => {
|
||||
const merged = mergeRetentionMeta(
|
||||
{
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
activePortForwards: [{ ruleId: "fwd-1", localPort: 8080, status: "active" }],
|
||||
},
|
||||
{
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
activePortForwards: [],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(merged.activePortForwards, []);
|
||||
});
|
||||
|
||||
test("mergeRetentionMeta keeps prior activePortForwards when fallback omits them", () => {
|
||||
const prior = [{ ruleId: "fwd-1", localPort: 8080, status: "active" }];
|
||||
const merged = mergeRetentionMeta(
|
||||
{
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
activePortForwards: prior,
|
||||
},
|
||||
{
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(merged.activePortForwards, prior);
|
||||
});
|
||||
|
||||
test("retainOwnedSessions applies empty activePortForwards from fallback metadata", () => {
|
||||
const retained = retainOwnedSessions({
|
||||
incomingSessions: [{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
}],
|
||||
ownedSessionIds: ["sess-opened"],
|
||||
previousById: new Map([
|
||||
["sess-opened", {
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: false,
|
||||
hostId: "host-b",
|
||||
activePortForwards: [{ ruleId: "fwd-1", localPort: 8080, status: "active" }],
|
||||
}],
|
||||
]),
|
||||
findFallbackMeta: (sessionId) => (
|
||||
sessionId === "sess-opened"
|
||||
? {
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
activePortForwards: [],
|
||||
}
|
||||
: null
|
||||
),
|
||||
});
|
||||
|
||||
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
|
||||
assert.ok(opened);
|
||||
assert.deepEqual(opened.activePortForwards, []);
|
||||
assert.equal(opened.connected, true);
|
||||
});
|
||||
|
||||
test("mergeRetentionMeta keeps the higher metadata revision in either argument", () => {
|
||||
const oldConnected = {
|
||||
connected: true,
|
||||
username: "old-user",
|
||||
activePortForwards: [{ ruleId: "old-forward" }],
|
||||
_revision: 4,
|
||||
};
|
||||
const newDisconnected = {
|
||||
connected: false,
|
||||
username: "new-user",
|
||||
activePortForwards: [],
|
||||
_revision: 7,
|
||||
};
|
||||
|
||||
for (const merged of [
|
||||
mergeRetentionMeta(oldConnected, newDisconnected),
|
||||
mergeRetentionMeta(newDisconnected, oldConnected),
|
||||
]) {
|
||||
assert.equal(merged.connected, false);
|
||||
assert.equal(merged.username, "new-user");
|
||||
assert.deepEqual(merged.activePortForwards, []);
|
||||
assert.equal(merged._revision, 7);
|
||||
}
|
||||
});
|
||||
@@ -43,6 +43,12 @@ function createSessionOwnershipRegistry() {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function listOwned(chatSessionId) {
|
||||
if (!chatSessionId) return [];
|
||||
const owned = ownedByScope.get(chatSessionId);
|
||||
return owned ? Array.from(owned) : [];
|
||||
}
|
||||
|
||||
function forgetSession(sessionId) {
|
||||
for (const [scopeId, owned] of ownedByScope) {
|
||||
owned.delete(sessionId);
|
||||
@@ -50,6 +56,16 @@ function createSessionOwnershipRegistry() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop retained ownership for a chat scope without revoking its host_open
|
||||
* generation. Used when the renderer pushes an authoritative empty scope
|
||||
* replace so a later non-empty sync cannot resurrect cleared sessions.
|
||||
*/
|
||||
function releaseScopeOwnership(chatSessionId) {
|
||||
if (!chatSessionId) return;
|
||||
ownedByScope.delete(chatSessionId);
|
||||
}
|
||||
|
||||
function clearScope(chatSessionId) {
|
||||
ownedByScope.delete(chatSessionId);
|
||||
const generation = scopeGenerations.get(chatSessionId);
|
||||
@@ -65,7 +81,9 @@ function createSessionOwnershipRegistry() {
|
||||
captureGeneration,
|
||||
register,
|
||||
validate,
|
||||
listOwned,
|
||||
forgetSession,
|
||||
releaseScopeOwnership,
|
||||
clearScope,
|
||||
getTrackedGenerationCountForTests,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,18 @@ test("session ownership is isolated by AI scope", () => {
|
||||
assert.match(ownership.validate("chat-b", "session-1").error, /not opened/i);
|
||||
});
|
||||
|
||||
test("listOwned returns sessions registered for a chat scope", () => {
|
||||
const ownership = createSessionOwnershipRegistry();
|
||||
ownership.register("chat-a", "session-1");
|
||||
ownership.register("chat-a", "session-2");
|
||||
ownership.register("chat-b", "session-3");
|
||||
|
||||
assert.deepEqual(ownership.listOwned("chat-a").sort(), ["session-1", "session-2"]);
|
||||
assert.deepEqual(ownership.listOwned("chat-b"), ["session-3"]);
|
||||
assert.deepEqual(ownership.listOwned("missing"), []);
|
||||
assert.deepEqual(ownership.listOwned(""), []);
|
||||
});
|
||||
|
||||
test("forgetSession revokes ownership from every scope", () => {
|
||||
const ownership = createSessionOwnershipRegistry();
|
||||
ownership.register("chat-a", "session-1");
|
||||
@@ -23,6 +35,22 @@ test("forgetSession revokes ownership from every scope", () => {
|
||||
assert.equal(ownership.validate("chat-b", "session-1").ok, false);
|
||||
});
|
||||
|
||||
test("releaseScopeOwnership clears retained ids without revoking generations", () => {
|
||||
const ownership = createSessionOwnershipRegistry();
|
||||
const generation = ownership.captureGeneration("chat-a");
|
||||
ownership.register("chat-a", "session-1", generation);
|
||||
ownership.register("chat-b", "session-2");
|
||||
|
||||
ownership.releaseScopeOwnership("chat-a");
|
||||
|
||||
assert.deepEqual(ownership.listOwned("chat-a"), []);
|
||||
assert.equal(ownership.validate("chat-a", "session-1").ok, false);
|
||||
assert.equal(ownership.validate("chat-b", "session-2").ok, true);
|
||||
// Late host_open for the same generation must still be allowed.
|
||||
assert.equal(ownership.register("chat-a", "session-3", generation), true);
|
||||
assert.deepEqual(ownership.listOwned("chat-a"), ["session-3"]);
|
||||
});
|
||||
|
||||
test("clearScope only revokes the deleted chat scope", () => {
|
||||
const ownership = createSessionOwnershipRegistry();
|
||||
ownership.register("chat-a", "session-1");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
const pendingBootAborts = new Map();
|
||||
const latestBootEpochs = new Map();
|
||||
|
||||
function normalizeBootEpoch(bootEpoch) {
|
||||
if (!Number.isFinite(bootEpoch)) return undefined;
|
||||
@@ -29,9 +30,13 @@ function registerPendingBootAbort(sessionId, bootEpoch) {
|
||||
try { existing.controller.abort(); } catch { /* ignore */ }
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const normalized = normalizeBootEpoch(bootEpoch);
|
||||
if (normalized !== undefined) {
|
||||
latestBootEpochs.set(sessionId, Math.max(latestBootEpochs.get(sessionId) ?? normalized, normalized));
|
||||
}
|
||||
pendingBootAborts.set(sessionId, {
|
||||
controller,
|
||||
bootEpoch: normalizeBootEpoch(bootEpoch),
|
||||
bootEpoch: normalized,
|
||||
});
|
||||
return controller;
|
||||
}
|
||||
@@ -59,6 +64,34 @@ function abortPendingBoot(sessionId, bootEpoch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasPendingBootAfter(sessionId, bootEpoch) {
|
||||
if (!sessionId) return false;
|
||||
const pending = pendingBootAborts.get(sessionId);
|
||||
const requested = normalizeBootEpoch(bootEpoch);
|
||||
return Boolean(
|
||||
pending
|
||||
&& requested !== undefined
|
||||
&& pending.bootEpoch !== undefined
|
||||
&& pending.bootEpoch > requested
|
||||
);
|
||||
}
|
||||
|
||||
function hasNewerBootEpoch(sessionId, bootEpoch) {
|
||||
if (!sessionId) return false;
|
||||
const requested = normalizeBootEpoch(bootEpoch);
|
||||
const latest = latestBootEpochs.get(sessionId);
|
||||
return requested !== undefined && latest !== undefined && latest > requested;
|
||||
}
|
||||
|
||||
function forgetBootEpoch(sessionId, bootEpoch) {
|
||||
if (!sessionId) return;
|
||||
const requested = normalizeBootEpoch(bootEpoch);
|
||||
const latest = latestBootEpochs.get(sessionId);
|
||||
if (requested === undefined || latest === undefined || latest <= requested) {
|
||||
latestBootEpochs.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingBootAbort(sessionId, controller) {
|
||||
if (!sessionId || !controller) return;
|
||||
const pending = pendingBootAborts.get(sessionId);
|
||||
@@ -161,6 +194,7 @@ function claimSessionSlot(sessions, sessionId, session, bootEpoch) {
|
||||
}
|
||||
if (normalized !== undefined) {
|
||||
session.bootEpoch = normalized;
|
||||
latestBootEpochs.set(sessionId, Math.max(latestBootEpochs.get(sessionId) ?? normalized, normalized));
|
||||
}
|
||||
let displaced;
|
||||
// When a newer boot replaces an older registry entry, mark and return the
|
||||
@@ -197,6 +231,9 @@ module.exports = {
|
||||
claimSessionSlot,
|
||||
clearPendingBootAbort,
|
||||
disposeDisplacedSessionResources,
|
||||
forgetBootEpoch,
|
||||
hasPendingBootAfter,
|
||||
hasNewerBootEpoch,
|
||||
normalizeBootEpoch,
|
||||
registerPendingBootAbort,
|
||||
sessionMatchesBootEpoch,
|
||||
|
||||
@@ -21,17 +21,26 @@ test("pending boot abort cancels older generations but not newer ones", () => {
|
||||
const {
|
||||
abortPendingBoot,
|
||||
clearPendingBootAbort,
|
||||
forgetBootEpoch,
|
||||
hasNewerBootEpoch,
|
||||
registerPendingBootAbort,
|
||||
hasPendingBootAfter,
|
||||
} = require("./sessionBootEpoch.cjs");
|
||||
const older = registerPendingBootAbort("s-pending", 2);
|
||||
const newer = registerPendingBootAbort("s-pending", 4);
|
||||
assert.equal(older.signal.aborted, true);
|
||||
assert.equal(newer.signal.aborted, false);
|
||||
assert.equal(abortPendingBoot("s-pending", 3), false);
|
||||
assert.equal(hasPendingBootAfter("s-pending", 3), true);
|
||||
assert.equal(hasNewerBootEpoch("s-pending", 3), true);
|
||||
assert.equal(newer.signal.aborted, false);
|
||||
assert.equal(abortPendingBoot("s-pending", 4), true);
|
||||
assert.equal(hasPendingBootAfter("s-pending", 3), false);
|
||||
assert.equal(hasNewerBootEpoch("s-pending", 3), true);
|
||||
assert.equal(newer.signal.aborted, true);
|
||||
clearPendingBootAbort("s-pending", newer);
|
||||
forgetBootEpoch("s-pending", 4);
|
||||
assert.equal(hasNewerBootEpoch("s-pending", 3), false);
|
||||
});
|
||||
|
||||
test("claimSessionSlot replaces an older boot epoch and marks it superseded", () => {
|
||||
|
||||
@@ -1945,6 +1945,7 @@ async function openSftpForSession(_event, payload) {
|
||||
const probeTimeoutMs = Number.isFinite(payload?.timeoutMs) && payload.timeoutMs > 0
|
||||
? payload.timeoutMs
|
||||
: SCP_PROBE_TIMEOUT_MS;
|
||||
const sudoRequested = Boolean(payload?.sudo);
|
||||
|
||||
async function probeScpCapability() {
|
||||
const bounded = createBoundedProbeSignal(payload?.abortSignal || null, probeTimeoutMs);
|
||||
@@ -1970,6 +1971,49 @@ async function openSftpForSession(_event, payload) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Forced SCP cannot provide sudo elevation; reject before touching the channel
|
||||
// so session reuse matches the fresh openSftp contract.
|
||||
if (sudoRequested && fileProtocol === "scp") {
|
||||
throw new Error(
|
||||
"Sudo Mode is not supported with File Protocol set to SCP. Disable Sudo Mode or use Auto/SFTP.",
|
||||
);
|
||||
}
|
||||
|
||||
if (sudoRequested) {
|
||||
let sftpWrapper;
|
||||
let sudoActive = true;
|
||||
try {
|
||||
sftpWrapper = await connectSudoSftp(sshClient, payload?.password || "");
|
||||
} catch (e) {
|
||||
// Fallback: if sftp-server binary is missing (exit code 127), try the
|
||||
// standard SFTP subsystem instead of failing completely. Mirrors openSftp
|
||||
// (ESXi / hosts without a standalone sftp-server). Keeps the reused SSH
|
||||
// transport so MFA is not repeated.
|
||||
if (e?.message && e.message.includes("exit code 127")) {
|
||||
console.warn(
|
||||
"[SFTP] openSftpForSession sftp-server not found, falling back to standard SFTP subsystem",
|
||||
);
|
||||
sudoActive = false;
|
||||
sftpWrapper = await requireSftpChannel(client, {
|
||||
signal: payload?.abortSignal,
|
||||
timeoutMs: payload?.timeoutMs,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
client.sftp = sftpWrapper;
|
||||
client.__netcattyFileProtocol = "sftp";
|
||||
client.__netcattySudoMode = sudoActive;
|
||||
if (sudoActive) {
|
||||
sftpWrapper.on?.("close", () => client.end());
|
||||
}
|
||||
throwIfAborted(payload?.abortSignal);
|
||||
copySftpEncodingState(payload?.encodingStateKey, sftpId);
|
||||
sftpClients.set(sftpId, client);
|
||||
return { ok: true, sftpId, fileProtocol: "sftp", sourceSessionId };
|
||||
}
|
||||
|
||||
if (fileProtocol === "scp") {
|
||||
client.__netcattyFileProtocol = "scp";
|
||||
client.sftp = null;
|
||||
|
||||
@@ -14,7 +14,13 @@ const {
|
||||
|
||||
function loadSftpBridgeWithProxySocket(proxySocket, overrides = {}) {
|
||||
const bridgePath = require.resolve("./sftpBridge.cjs");
|
||||
const openConnectionPath = require.resolve("./sftpBridge/openConnection.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
// Reload openConnection when we need to wrap createOpenConnectionApi so the
|
||||
// bridge picks up a patched connectSudoSftp (session-backed sudo tests).
|
||||
if (overrides.connectSudoSftp) {
|
||||
delete require.cache[openConnectionPath];
|
||||
}
|
||||
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
@@ -30,6 +36,25 @@ function loadSftpBridgeWithProxySocket(proxySocket, overrides = {}) {
|
||||
Client: overrides.SSHClient,
|
||||
};
|
||||
}
|
||||
if (
|
||||
overrides.connectSudoSftp
|
||||
&& (request === "./sftpBridge/openConnection.cjs"
|
||||
|| request === openConnectionPath
|
||||
|| (typeof request === "string" && request.endsWith("/sftpBridge/openConnection.cjs")))
|
||||
) {
|
||||
const mod = originalLoad.call(this, request, parent, isMain);
|
||||
const originalCreate = mod.createOpenConnectionApi;
|
||||
return {
|
||||
...mod,
|
||||
createOpenConnectionApi(ctx) {
|
||||
const api = originalCreate(ctx);
|
||||
return {
|
||||
...api,
|
||||
connectSudoSftp: overrides.connectSudoSftp,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
@@ -40,6 +65,20 @@ function loadSftpBridgeWithProxySocket(proxySocket, overrides = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function createFakeSftpChannel() {
|
||||
return {
|
||||
ended: false,
|
||||
readdir: () => {},
|
||||
stat: () => {},
|
||||
mkdir: () => {},
|
||||
unlink: () => {},
|
||||
end() {
|
||||
this.ended = true;
|
||||
},
|
||||
on() {},
|
||||
};
|
||||
}
|
||||
|
||||
class FailingSshClient extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
@@ -270,3 +309,163 @@ test("openSftpForSession honors session.sftpFileProtocol when payload omits file
|
||||
assert.equal(sftpClients.get(opened.sftpId)?.__netcattyFileProtocol, "sftp");
|
||||
await bridge.closeSftp(null, { sftpId: opened.sftpId });
|
||||
});
|
||||
|
||||
test("openSftpForSession rejects sudo with forced SCP before opening a channel", async () => {
|
||||
let sudoCalls = 0;
|
||||
let sftpCalls = 0;
|
||||
const bridge = loadSftpBridgeWithProxySocket(null, {
|
||||
connectSudoSftp: async () => {
|
||||
sudoCalls += 1;
|
||||
throw new Error("connectSudoSftp should not run for scp+sudo");
|
||||
},
|
||||
});
|
||||
const sftpClients = new Map();
|
||||
const conn = {
|
||||
sftp(cb) {
|
||||
sftpCalls += 1;
|
||||
cb(null, createFakeSftpChannel());
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
const session = {
|
||||
conn,
|
||||
stream: {},
|
||||
sftpFileProtocol: "scp",
|
||||
};
|
||||
bridge.init({
|
||||
sftpClients,
|
||||
sessions: new Map([["session-sudo-scp", session]]),
|
||||
electronModule: {},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => bridge.openSftpForSession(null, {
|
||||
sessionId: "session-sudo-scp",
|
||||
sudo: true,
|
||||
fileProtocol: "scp",
|
||||
password: "secret",
|
||||
}),
|
||||
/Sudo Mode is not supported with File Protocol set to SCP/i,
|
||||
);
|
||||
assert.equal(sudoCalls, 0);
|
||||
assert.equal(sftpCalls, 0);
|
||||
assert.equal(sftpClients.size, 0);
|
||||
});
|
||||
|
||||
test("openSftpForSession falls back to standard SFTP when sudo sftp-server exits 127", async () => {
|
||||
let sudoCalls = 0;
|
||||
let sftpCalls = 0;
|
||||
const bridge = loadSftpBridgeWithProxySocket(null, {
|
||||
connectSudoSftp: async () => {
|
||||
sudoCalls += 1;
|
||||
throw new Error("SFTP sudo failed with exit code 127. sftp-server not found");
|
||||
},
|
||||
});
|
||||
const sftpClients = new Map();
|
||||
const fakeSftp = createFakeSftpChannel();
|
||||
const conn = {
|
||||
sftp(cb) {
|
||||
sftpCalls += 1;
|
||||
cb(null, fakeSftp);
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
const session = { conn, stream: {} };
|
||||
bridge.init({
|
||||
sftpClients,
|
||||
sessions: new Map([["session-sudo-127", session]]),
|
||||
electronModule: {},
|
||||
});
|
||||
|
||||
const opened = await bridge.openSftpForSession(null, {
|
||||
sessionId: "session-sudo-127",
|
||||
sudo: true,
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
assert.equal(opened.ok, true);
|
||||
assert.equal(opened.fileProtocol, "sftp");
|
||||
assert.equal(opened.sourceSessionId, "session-sudo-127");
|
||||
assert.equal(sudoCalls, 1);
|
||||
assert.equal(sftpCalls, 1);
|
||||
const client = sftpClients.get(opened.sftpId);
|
||||
assert.equal(client?.__netcattyFileProtocol, "sftp");
|
||||
assert.equal(client?.__netcattySudoMode, false);
|
||||
assert.equal(client?.sftp, fakeSftp);
|
||||
await bridge.closeSftp(null, { sftpId: opened.sftpId });
|
||||
});
|
||||
|
||||
test("openSftpForSession does not fall back to standard SFTP on non-127 sudo errors", async () => {
|
||||
let sftpCalls = 0;
|
||||
const bridge = loadSftpBridgeWithProxySocket(null, {
|
||||
connectSudoSftp: async () => {
|
||||
throw new Error("SFTP sudo failed with exit code 1. The password may be incorrect");
|
||||
},
|
||||
});
|
||||
const sftpClients = new Map();
|
||||
const conn = {
|
||||
sftp(cb) {
|
||||
sftpCalls += 1;
|
||||
cb(null, createFakeSftpChannel());
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
const session = { conn, stream: {} };
|
||||
bridge.init({
|
||||
sftpClients,
|
||||
sessions: new Map([["session-sudo-auth", session]]),
|
||||
electronModule: {},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => bridge.openSftpForSession(null, {
|
||||
sessionId: "session-sudo-auth",
|
||||
sudo: true,
|
||||
password: "wrong",
|
||||
}),
|
||||
/exit code 1/i,
|
||||
);
|
||||
assert.equal(sftpCalls, 0);
|
||||
assert.equal(sftpClients.size, 0);
|
||||
});
|
||||
|
||||
test("openSftpForSession keeps sudo mode when connectSudoSftp succeeds", async () => {
|
||||
const sudoWrapper = createFakeSftpChannel();
|
||||
let closeBound = false;
|
||||
sudoWrapper.on = (event, cb) => {
|
||||
if (event === "close") closeBound = true;
|
||||
return sudoWrapper;
|
||||
};
|
||||
const bridge = loadSftpBridgeWithProxySocket(null, {
|
||||
connectSudoSftp: async () => sudoWrapper,
|
||||
});
|
||||
const sftpClients = new Map();
|
||||
let sftpCalls = 0;
|
||||
const conn = {
|
||||
sftp(cb) {
|
||||
sftpCalls += 1;
|
||||
cb(null, createFakeSftpChannel());
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
const session = { conn, stream: {} };
|
||||
bridge.init({
|
||||
sftpClients,
|
||||
sessions: new Map([["session-sudo-ok", session]]),
|
||||
electronModule: {},
|
||||
});
|
||||
|
||||
const opened = await bridge.openSftpForSession(null, {
|
||||
sessionId: "session-sudo-ok",
|
||||
sudo: true,
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
assert.equal(opened.ok, true);
|
||||
assert.equal(sftpCalls, 0);
|
||||
const client = sftpClients.get(opened.sftpId);
|
||||
assert.equal(client?.__netcattySudoMode, true);
|
||||
assert.equal(client?.sftp, sudoWrapper);
|
||||
assert.equal(closeBound, true);
|
||||
await bridge.closeSftp(null, { sftpId: opened.sftpId });
|
||||
});
|
||||
@@ -932,8 +932,14 @@ function createFileOpsApi(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: SFTP realpath('.') — skip if result is '/' for non-root users
|
||||
// because some SFTP servers start in '/' rather than the user's home
|
||||
// Method 2: SFTP realpath('.'). A virtual/chroot SFTP server (including
|
||||
// bastion products such as JumpServer) can legitimately expose '/' as
|
||||
// the authenticated user's root even when SSH exec channels are denied.
|
||||
//
|
||||
// Accept non-root absolute paths immediately. Accept '/' only when it is
|
||||
// actually listable so we do not suppress renderer candidate probing
|
||||
// (/home/<user>, /root) on servers that merely start cwd at '/' without
|
||||
// granting readdir on the real filesystem root.
|
||||
try {
|
||||
const sftp = await requireSftpChannel(client, {
|
||||
signal,
|
||||
@@ -942,9 +948,33 @@ function createFileOpsApi(ctx) {
|
||||
throwIfAborted(signal);
|
||||
const absPath = await realpathAsync(sftp, ".");
|
||||
throwIfAborted(signal);
|
||||
if (absPath && absPath !== "/") {
|
||||
if (absPath && absPath.startsWith("/") && absPath !== "/") {
|
||||
return { success: true, homeDir: absPath };
|
||||
}
|
||||
if (absPath === "/") {
|
||||
try {
|
||||
if (typeof readdirAsync === "function") {
|
||||
await readdirAsync(sftp, "/");
|
||||
} else if (sftp && typeof sftp.readdir === "function") {
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.readdir("/", (err, items) => {
|
||||
if (err) reject(err);
|
||||
else resolve(items || []);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// No list probe available — keep virtual-root acceptance from #2934.
|
||||
return { success: true, homeDir: "/" };
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
return { success: true, homeDir: "/" };
|
||||
} catch (listErr) {
|
||||
if (signal?.aborted) {
|
||||
throw listErr;
|
||||
}
|
||||
// Non-listable root: fall through so candidate probing can run.
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.aborted) {
|
||||
throw err;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { createFileOpsApi } = require("./fileOps.cjs");
|
||||
|
||||
test("home discovery accepts a virtual SFTP root when SSH exec is unavailable", async () => {
|
||||
const channel = {};
|
||||
const listed = [];
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["jumpserver", { sftp: channel }]]),
|
||||
throwIfAborted() {},
|
||||
requireSftpChannel: async () => channel,
|
||||
realpathAsync: async (resolvedChannel, remotePath) => {
|
||||
assert.equal(resolvedChannel, channel);
|
||||
assert.equal(remotePath, ".");
|
||||
return "/";
|
||||
},
|
||||
readdirAsync: async (resolvedChannel, remotePath) => {
|
||||
listed.push([resolvedChannel, remotePath]);
|
||||
return [{ filename: "data" }];
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.getSftpHomeDir(null, { sftpId: "jumpserver" });
|
||||
|
||||
assert.deepEqual(result, { success: true, homeDir: "/" });
|
||||
assert.deepEqual(listed, [[channel, "/"]]);
|
||||
});
|
||||
|
||||
test("home discovery rejects non-listable root so candidate probing can run", async () => {
|
||||
const channel = {};
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["restricted", { sftp: channel }]]),
|
||||
throwIfAborted() {},
|
||||
requireSftpChannel: async () => channel,
|
||||
realpathAsync: async () => "/",
|
||||
readdirAsync: async () => {
|
||||
const error = new Error("Permission denied");
|
||||
error.code = "EACCES";
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.getSftpHomeDir(null, { sftpId: "restricted" });
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error || "", /Could not determine home directory/);
|
||||
});
|
||||
|
||||
test("home discovery still accepts non-root realpath without listing", async () => {
|
||||
const channel = {};
|
||||
let readdirCalls = 0;
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["normal", { sftp: channel }]]),
|
||||
throwIfAborted() {},
|
||||
requireSftpChannel: async () => channel,
|
||||
realpathAsync: async () => "/home/deploy",
|
||||
readdirAsync: async () => {
|
||||
readdirCalls += 1;
|
||||
return [];
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.getSftpHomeDir(null, { sftpId: "normal" });
|
||||
|
||||
assert.deepEqual(result, { success: true, homeDir: "/home/deploy" });
|
||||
assert.equal(readdirCalls, 0);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -607,6 +607,25 @@ function sameEndpoint(a, b) {
|
||||
&& left.keepaliveCountMax === right.keepaliveCountMax;
|
||||
}
|
||||
|
||||
function sameTransportEndpoint(a, b) {
|
||||
const left = normalizeEndpoint(a);
|
||||
const right = normalizeEndpoint(b);
|
||||
if (!left || !right) return false;
|
||||
// SFTP sudo changes the channel/subsystem setup, not the authenticated SSH
|
||||
// transport. Channel borrowers (SFTP/port forwarding) may reuse the terminal
|
||||
// connection and apply sudo when opening their own channel.
|
||||
if (left.hostId && right.hostId && left.hostId !== right.hostId) return false;
|
||||
return left.hostname === right.hostname
|
||||
&& left.port === right.port
|
||||
&& left.username === right.username
|
||||
&& left.protocol === right.protocol
|
||||
&& left.jumpFingerprint === right.jumpFingerprint
|
||||
&& left.proxyFingerprint === right.proxyFingerprint
|
||||
&& left.authFingerprint === right.authFingerprint
|
||||
&& left.keepaliveIntervalMs === right.keepaliveIntervalMs
|
||||
&& left.keepaliveCountMax === right.keepaliveCountMax;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a requested open can reuse an existing transport/session endpoint.
|
||||
*
|
||||
@@ -619,7 +638,11 @@ function sameEndpoint(a, b) {
|
||||
* transport; cannot use a nofwd transport when the request needs ForwardAgent.
|
||||
*/
|
||||
function endpointAllowsReuse(requested, existing, kind = "channel") {
|
||||
if (!sameEndpoint(requested, existing)) return false;
|
||||
if (kind === "shell") {
|
||||
if (!sameEndpoint(requested, existing)) return false;
|
||||
} else if (!sameTransportEndpoint(requested, existing)) {
|
||||
return false;
|
||||
}
|
||||
const req = normalizeEndpoint(requested);
|
||||
const have = normalizeEndpoint(existing);
|
||||
if (!req || !have) return false;
|
||||
@@ -884,6 +907,10 @@ function createTransport({
|
||||
idleSince: null,
|
||||
idleDeadlineAt: null,
|
||||
createdAt: nowFn(),
|
||||
pendingShellReconnectRisk: null,
|
||||
closedShellPids: new Set(),
|
||||
closedShellPidUnknown: false,
|
||||
shellCloseGeneration: 0,
|
||||
meta: meta || null,
|
||||
endedReason: null,
|
||||
_poolOnConnectionClose: null,
|
||||
@@ -963,6 +990,9 @@ function transferConnectionRef(fromHolder, toHolder) {
|
||||
if (!lease) return false;
|
||||
|
||||
lease.holder = toHolder;
|
||||
if (lease.kind === LEASE_KINDS.shell && toHolder?.stream) {
|
||||
lease.meta = { ...(lease.meta || {}), activeShellChannel: true };
|
||||
}
|
||||
leasesById.set(leaseId, { transport, holder: toHolder });
|
||||
|
||||
fromHolder.connRef = null;
|
||||
@@ -1014,10 +1044,35 @@ function returnTransport(leaseIdOrHolder) {
|
||||
}
|
||||
|
||||
const { transport } = entry;
|
||||
const releasedLease = transport.leases.get(leaseId);
|
||||
leasesById.delete(leaseId);
|
||||
transport.leases.delete(leaseId);
|
||||
transport.count = transport.leases.size;
|
||||
|
||||
if (
|
||||
releasedLease?.kind === LEASE_KINDS.shell
|
||||
&& releasedLease.meta?.activeShellChannel === true
|
||||
) {
|
||||
transport.shellCloseGeneration = (transport.shellCloseGeneration || 0) + 1;
|
||||
const releasedPid = String(releasedLease.holder?.shellPid || "");
|
||||
if (/^\d+$/.test(releasedPid)) transport.closedShellPids.add(releasedPid);
|
||||
else transport.closedShellPidUnknown = true;
|
||||
const hasActiveShell = [...transport.leases.values()].some(
|
||||
(lease) => lease.kind === LEASE_KINDS.shell && lease.meta?.activeShellChannel === true,
|
||||
);
|
||||
if (!hasActiveShell) {
|
||||
// A locally closed shell channel can outlive its lease briefly on the
|
||||
// server. Remember that provenance even when SFTP/forward leases keep the
|
||||
// transport live, so the next shell does not guess cwd from the old PID.
|
||||
transport.pendingShellReconnectRisk = {
|
||||
oldShellPids: [...transport.closedShellPids],
|
||||
hasUnknownOldShell: transport.closedShellPidUnknown,
|
||||
};
|
||||
transport.closedShellPids.clear();
|
||||
transport.closedShellPidUnknown = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.holder && typeof entry.holder === "object") {
|
||||
if (entry.holder.connRef === transport) entry.holder.connRef = null;
|
||||
entry.holder._sshTransportLeaseId = null;
|
||||
@@ -1044,6 +1099,13 @@ function returnTransport(leaseIdOrHolder) {
|
||||
};
|
||||
}
|
||||
|
||||
function consumePendingShellReconnectRisk(transport) {
|
||||
if (!transport?.pendingShellReconnectRisk) return false;
|
||||
const risk = transport.pendingShellReconnectRisk;
|
||||
transport.pendingShellReconnectRisk = null;
|
||||
return risk;
|
||||
}
|
||||
|
||||
function discardTransport(transportOrId, reason = "discard") {
|
||||
const transport = typeof transportOrId === "string"
|
||||
? transportsById.get(transportOrId)
|
||||
@@ -1239,7 +1301,11 @@ function createConnectionRef(session, conn, chainConnections) {
|
||||
holder: session,
|
||||
// Unique per connection generation: same sessionId can reconnect while an
|
||||
// old lease is still draining (same-session reconnect path).
|
||||
meta: { source: "createConnectionRef", sessionId: session?.id || null },
|
||||
meta: {
|
||||
source: "createConnectionRef",
|
||||
sessionId: session?.id || null,
|
||||
activeShellChannel: true,
|
||||
},
|
||||
});
|
||||
|
||||
return transport;
|
||||
@@ -1268,7 +1334,11 @@ function acquireConnectionRef(session, connRef) {
|
||||
holder: session,
|
||||
// Always allocate a unique lease id — stable session/sftp ids can collide
|
||||
// across reconnect generations while old leases drain.
|
||||
meta: { source: "acquireConnectionRef", holderId: session?.id || null },
|
||||
meta: {
|
||||
source: "acquireConnectionRef",
|
||||
holderId: session?.id || null,
|
||||
activeShellChannel: kind === LEASE_KINDS.shell && Boolean(session?.stream),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1401,5 +1471,6 @@ module.exports = {
|
||||
acquireConnectionRef,
|
||||
releaseConnectionRef,
|
||||
transferConnectionRef,
|
||||
consumePendingShellReconnectRisk,
|
||||
findReusableSession,
|
||||
};
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
acquireConnectionRef,
|
||||
releaseConnectionRef,
|
||||
transferConnectionRef,
|
||||
consumePendingShellReconnectRisk,
|
||||
findReusableSession,
|
||||
createTransport,
|
||||
borrowTransport,
|
||||
@@ -335,6 +336,20 @@ test("releaseConnectionRef on a session without a descriptor is a safe no-op", (
|
||||
assert.equal(releaseConnectionRef(undefined), false);
|
||||
});
|
||||
|
||||
test("last shell close marks reconnect risk while an SFTP lease keeps the transport live", () => {
|
||||
const owner = { id: "shell", stream: {} };
|
||||
const sftp = { id: "sftp", __sshLeaseKind: "sftp" };
|
||||
const transport = createConnectionRef(owner, makeConn(), []);
|
||||
acquireConnectionRef(sftp, transport);
|
||||
|
||||
assert.equal(releaseConnectionRef(owner), false);
|
||||
assert.deepEqual(consumePendingShellReconnectRisk(transport), {
|
||||
oldShellPids: [],
|
||||
hasUnknownOldShell: true,
|
||||
});
|
||||
assert.equal(consumePendingShellReconnectRisk(transport), false);
|
||||
});
|
||||
|
||||
test("single-channel connection parks on release when TTL is 0", () => {
|
||||
const conn = makeConn();
|
||||
const chain = [makeChainConn()];
|
||||
@@ -943,6 +958,16 @@ test("connection endpoint can record negotiated agent forwarding instead of the
|
||||
assert.equal(normalizeEndpoint(endpoint).agentForwarding, false);
|
||||
});
|
||||
|
||||
test("channel reuse can borrow a normal SSH transport for sudo SFTP", () => {
|
||||
const base = { hostname: "sudo.example", username: "root", port: 22 };
|
||||
const normalShell = buildConnectionReuseEndpoint(base, { sftpSudo: false });
|
||||
const sudoSftp = buildConnectionReuseEndpoint(base, { sftpSudo: true });
|
||||
|
||||
assert.notEqual(buildEndpointKey(normalShell), buildEndpointKey(sudoSftp));
|
||||
assert.equal(endpointAllowsReuse(sudoSftp, normalShell, "channel"), true);
|
||||
assert.equal(endpointAllowsReuse(sudoSftp, normalShell, "shell"), false);
|
||||
});
|
||||
|
||||
test("endpointAllowsReuse: shell exact vs channel asymmetric for agentForwarding", () => {
|
||||
const base = { hostname: "a.example", username: "root", port: 22 };
|
||||
// Channel (default): request needs ForwardAgent, existing never enabled it -> reject.
|
||||
|
||||
@@ -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 };
|
||||
@@ -1528,6 +1528,18 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
|
||||
}, trace);
|
||||
}
|
||||
const writeResult = session.stream.write(outgoing);
|
||||
if (
|
||||
session.blockUntargetedCwdProbe
|
||||
&& !payload.automated
|
||||
&& payload.sensitive !== true
|
||||
&& !isTerminalReportSequence(data)
|
||||
&& /[\r\n]/.test(String(data || ""))
|
||||
) {
|
||||
// Arm recovery only after this generation's interactive stream really
|
||||
// accepted a submitted user command. Blocked transfers and failed or
|
||||
// superseded async input never reach this point.
|
||||
session.pendingCwdRecoveryAfterUserCommand = true;
|
||||
}
|
||||
if (shouldLogInterruptWrite) {
|
||||
logTerminalInterruptDebug("ssh-stream-write-done", {
|
||||
writeResult,
|
||||
@@ -1963,6 +1975,21 @@ function clearSessionPtyBuffer(event, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRevokeOpenedSessionOwnership(payload) {
|
||||
// Disconnect / reconnect tear down the transport but keep the tab and session
|
||||
// id. Those closes must not drop host_open ownership.
|
||||
return payload?.retainOwnership !== true;
|
||||
}
|
||||
|
||||
function reportOpenedSessionClosed(sessionId, payload) {
|
||||
if (!shouldRevokeOpenedSessionOwnership(payload)) return;
|
||||
try {
|
||||
reportOpenedSessionActivity?.({ sessionId, phase: "closed" });
|
||||
} catch {
|
||||
// Ownership cleanup must not interfere with session teardown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a session
|
||||
*/
|
||||
@@ -1970,6 +1997,9 @@ function closeSession(event, payload) {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
const {
|
||||
abortPendingBoot,
|
||||
forgetBootEpoch,
|
||||
hasNewerBootEpoch,
|
||||
hasPendingBootAfter,
|
||||
sessionMatchesBootEpoch,
|
||||
} = require("./sessionBootEpoch.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
@@ -1983,8 +2013,22 @@ function closeSession(event, payload) {
|
||||
if (session && !sessionMatchesBootEpoch(session, payload?.bootEpoch)) {
|
||||
return { skipped: true, reason: "boot-epoch-mismatch" };
|
||||
}
|
||||
if (!session) {
|
||||
// A direct-mode backend can remove a naturally exited session before the
|
||||
// renderer closes its tab. That later close is still authoritative for AI
|
||||
// ownership, unless it belongs to an older boot than a pending reconnect.
|
||||
if (
|
||||
hasPendingBootAfter(payload.sessionId, payload?.bootEpoch)
|
||||
|| hasNewerBootEpoch(payload.sessionId, payload?.bootEpoch)
|
||||
) {
|
||||
return { skipped: true, reason: "boot-epoch-mismatch" };
|
||||
}
|
||||
releaseAttachedSessionState(payload.sessionId);
|
||||
reportOpenedSessionClosed(payload.sessionId, payload);
|
||||
forgetBootEpoch(payload.sessionId, payload?.bootEpoch);
|
||||
return { closed: false, reason: "missing" };
|
||||
}
|
||||
releaseAttachedSessionState(payload.sessionId);
|
||||
if (!session) return { closed: false, reason: "missing" };
|
||||
terminalFlowPauseArbiter.clearSession(payload.sessionId);
|
||||
session.closed = true;
|
||||
fanoutSessionLifecycleEvent(
|
||||
@@ -2056,6 +2100,8 @@ function closeSession(event, payload) {
|
||||
}
|
||||
ptyProcessTree.unregisterPid(payload.sessionId);
|
||||
sessions.delete(payload.sessionId);
|
||||
forgetBootEpoch(payload.sessionId, payload?.bootEpoch);
|
||||
reportOpenedSessionClosed(payload.sessionId, payload);
|
||||
return { closed: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -29,3 +29,133 @@ test("renderer terminal input reports activity for the matching session", () =>
|
||||
{ sessionId: "session-1", phase: "touch" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("direct-mode close reports ownership cleanup after the backend already exited", () => {
|
||||
const activity = [];
|
||||
terminalBridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: {},
|
||||
reportOpenedSessionActivity: (event) => activity.push(event),
|
||||
});
|
||||
|
||||
const result = terminalBridge.closeSession({ sender: {} }, {
|
||||
sessionId: "already-exited",
|
||||
bootEpoch: 3,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { closed: false, reason: "missing" });
|
||||
assert.deepEqual(activity, [{ sessionId: "already-exited", phase: "closed" }]);
|
||||
});
|
||||
|
||||
test("direct-mode disconnect close keeps host_open ownership for reconnect", () => {
|
||||
const activity = [];
|
||||
const sessions = new Map([
|
||||
["session-keep", {
|
||||
proc: { kill() {} },
|
||||
webContentsId: 1,
|
||||
}],
|
||||
]);
|
||||
terminalBridge.init({
|
||||
sessions,
|
||||
electronModule: { webContents: { fromId: () => ({ isDestroyed: () => true, send() {} }) } },
|
||||
reportOpenedSessionActivity: (event) => activity.push(event),
|
||||
});
|
||||
|
||||
const result = terminalBridge.closeSession({ sender: {} }, {
|
||||
sessionId: "session-keep",
|
||||
retainOwnership: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { closed: true });
|
||||
assert.equal(sessions.has("session-keep"), false);
|
||||
assert.deepEqual(activity, []);
|
||||
});
|
||||
|
||||
test("direct-mode disconnect close of an already-exited session keeps ownership", () => {
|
||||
const activity = [];
|
||||
terminalBridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: {},
|
||||
reportOpenedSessionActivity: (event) => activity.push(event),
|
||||
});
|
||||
|
||||
const result = terminalBridge.closeSession({ sender: {} }, {
|
||||
sessionId: "already-exited",
|
||||
bootEpoch: 3,
|
||||
retainOwnership: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { closed: false, reason: "missing" });
|
||||
assert.deepEqual(activity, []);
|
||||
});
|
||||
|
||||
test("stale direct-mode close cannot clean up a newer same-id boot", () => {
|
||||
const activity = [];
|
||||
const { claimSessionSlot } = require("./sessionBootEpoch.cjs");
|
||||
const lifecycle = new Map();
|
||||
claimSessionSlot(lifecycle, "reconnected", {}, 4);
|
||||
lifecycle.delete("reconnected");
|
||||
terminalBridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: {},
|
||||
reportOpenedSessionActivity: (event) => activity.push(event),
|
||||
});
|
||||
|
||||
const stale = terminalBridge.closeSession({ sender: {} }, {
|
||||
sessionId: "reconnected",
|
||||
bootEpoch: 3,
|
||||
});
|
||||
assert.deepEqual(stale, { skipped: true, reason: "boot-epoch-mismatch" });
|
||||
assert.deepEqual(activity, []);
|
||||
|
||||
const current = terminalBridge.closeSession({ sender: {} }, {
|
||||
sessionId: "reconnected",
|
||||
bootEpoch: 4,
|
||||
});
|
||||
assert.deepEqual(current, { closed: false, reason: "missing" });
|
||||
assert.deepEqual(activity, [{ sessionId: "reconnected", phase: "closed" }]);
|
||||
});
|
||||
|
||||
test("only a submitted non-sensitive user command arms cwd recovery", () => {
|
||||
const stream = { write() { return true; } };
|
||||
const session = { stream, webContentsId: 1, blockUntargetedCwdProbe: true };
|
||||
const sessions = new Map([["session-1", session]]);
|
||||
terminalBridge.init({ sessions, electronModule: { webContents: { fromId: () => null } } });
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, {
|
||||
sessionId: "session-1", data: "pwd", automated: false,
|
||||
});
|
||||
assert.notEqual(session.pendingCwdRecoveryAfterUserCommand, true);
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, {
|
||||
sessionId: "session-1", data: "secret\r", sensitive: true,
|
||||
});
|
||||
assert.notEqual(session.pendingCwdRecoveryAfterUserCommand, true);
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, {
|
||||
sessionId: "session-1", data: "pwd\r", automated: false,
|
||||
});
|
||||
assert.equal(session.pendingCwdRecoveryAfterUserCommand, true);
|
||||
});
|
||||
|
||||
test("blocked transfer input and failed writes do not arm cwd recovery", () => {
|
||||
const blocked = {
|
||||
stream: { write() { throw new Error("must not write during transfer"); } },
|
||||
webContentsId: 1,
|
||||
blockUntargetedCwdProbe: true,
|
||||
zmodemSentry: { isActive: () => true, cancel() {} },
|
||||
};
|
||||
const failed = {
|
||||
stream: { write() { throw Object.assign(new Error("closed"), { code: "EPIPE" }); } },
|
||||
webContentsId: 1,
|
||||
blockUntargetedCwdProbe: true,
|
||||
};
|
||||
const sessions = new Map([["blocked", blocked], ["failed", failed]]);
|
||||
terminalBridge.init({ sessions, electronModule: { webContents: { fromId: () => null } } });
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, { sessionId: "blocked", data: "pwd\r" });
|
||||
terminalBridge.writeToSession({ sender: {} }, { sessionId: "failed", data: "pwd\r" });
|
||||
|
||||
assert.notEqual(blocked.pendingCwdRecoveryAfterUserCommand, true);
|
||||
assert.notEqual(failed.pendingCwdRecoveryAfterUserCommand, true);
|
||||
});
|
||||
@@ -1125,13 +1125,27 @@ function createTerminalWorkerManager(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function notifySessionClosed(sessionId, reason) {
|
||||
function notifySessionClosed(sessionId, reason, options = {}) {
|
||||
if (!sessionId) return;
|
||||
const explicit = options?.explicit === true;
|
||||
const event = explicit
|
||||
? { sessionId, reason, explicit: true }
|
||||
: { sessionId, reason };
|
||||
for (const listener of [...sessionClosedListeners]) {
|
||||
try { listener(Object.freeze({ sessionId, reason })); } catch {}
|
||||
try { listener(Object.freeze(event)); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function notifyRendererSessionClosed(sessionId, payload) {
|
||||
// Disconnect / reconnect keep the tab and session id, so they must not
|
||||
// look like an explicit tab close to host_open ownership.
|
||||
if (payload?.retainOwnership === true) {
|
||||
notifySessionClosed(sessionId, "closed");
|
||||
return;
|
||||
}
|
||||
notifySessionClosed(sessionId, "closed", { explicit: true });
|
||||
}
|
||||
|
||||
function drainOutputSession(sessionId, requestId) {
|
||||
if (!sessionId || !requestId || !outputPortReady.has(sessionId)) return false;
|
||||
const worker = ensureStarted();
|
||||
@@ -1473,7 +1487,9 @@ function createTerminalWorkerManager(options = {}) {
|
||||
clearAttachHome(sessionId);
|
||||
terminalOutputChannel?.closeSession?.(sessionId);
|
||||
suppressedPendingOutputSessions.add(sessionId);
|
||||
notifySessionClosed(sessionId, message.payload?.reason || "superseded");
|
||||
// Always label superseded exits as such — payload reason may be
|
||||
// "closed"/"error" and must not be mistaken for an authoritative end.
|
||||
notifySessionClosed(sessionId, "superseded");
|
||||
const targets = new Set([displayWebContentsId, homeWebContentsId]);
|
||||
if (onRendererEvent) {
|
||||
for (const webContentsId of targets) {
|
||||
@@ -1502,7 +1518,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
finishPendingSessionStart(originEntry);
|
||||
pending.delete(message.originRequestId);
|
||||
originEntry.reject(new Error("Terminal session was superseded by a newer start request"));
|
||||
notifySessionClosed(sessionId, message.payload?.reason);
|
||||
notifySessionClosed(sessionId, "superseded");
|
||||
return;
|
||||
}
|
||||
const displayWebContentsId =
|
||||
@@ -1752,6 +1768,11 @@ function createTerminalWorkerManager(options = {}) {
|
||||
&& payload?.sessionId
|
||||
&& closedSessions.has(payload.sessionId)
|
||||
&& !pendingSessionStartSequences.has(payload.sessionId)) {
|
||||
// Worker already tombstoned the session (clean exit / crash). A later
|
||||
// renderer close still has to notify host listeners as explicit so
|
||||
// auto-close can drop host_open ownership, unless this is a disconnect
|
||||
// that keeps the tab for reconnect.
|
||||
notifyRendererSessionClosed(payload.sessionId, payload);
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
const requestId = randomUUID();
|
||||
@@ -1813,7 +1834,9 @@ function createTerminalWorkerManager(options = {}) {
|
||||
finishPendingSessionStart(entry);
|
||||
entry?.reject(error);
|
||||
}
|
||||
if (notifyClosedAfterPost) notifySessionClosed(payload.sessionId, "closed");
|
||||
if (notifyClosedAfterPost) {
|
||||
notifyRendererSessionClosed(payload.sessionId, payload);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
@@ -1900,6 +1923,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
&& closedSessions.has(payload.sessionId)
|
||||
&& !pendingSessionStartSequences.has(payload.sessionId)) {
|
||||
closeOutputSession(payload.sessionId);
|
||||
notifyRendererSessionClosed(payload.sessionId, payload);
|
||||
return;
|
||||
}
|
||||
if (channel === "netcatty:close" && payload?.sessionId) {
|
||||
@@ -1921,7 +1945,9 @@ function createTerminalWorkerManager(options = {}) {
|
||||
retireWorkerAfterIpcFailure(worker, error);
|
||||
postError = error;
|
||||
}
|
||||
if (shouldNotifyClosed) notifySessionClosed(payload.sessionId, "closed");
|
||||
if (shouldNotifyClosed) {
|
||||
notifyRendererSessionClosed(payload.sessionId, payload);
|
||||
}
|
||||
if (postError) throw postError;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2162,16 +2162,104 @@ test("explicit close notifies host session lifecycle exactly once before the wor
|
||||
await start;
|
||||
|
||||
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
|
||||
child.emit("message", {
|
||||
kind: "renderer-event",
|
||||
webContentsId: 7,
|
||||
channel: "netcatty:exit",
|
||||
payload: { sessionId: "session-1", reason: "exited" },
|
||||
});
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
|
||||
});
|
||||
|
||||
test("retainOwnership close does not mark host_open cleanup as explicit", async () => {
|
||||
const child = new FakeChild();
|
||||
const closed = [];
|
||||
const manager = createTerminalWorkerManager({
|
||||
utilityProcess: { fork: () => child },
|
||||
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
||||
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
|
||||
workerScriptPath: "/worker.cjs",
|
||||
});
|
||||
manager.onSessionClosed((event) => closed.push(event));
|
||||
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
||||
child.emit("message", {
|
||||
kind: "response",
|
||||
requestId: child.messages[0].requestId,
|
||||
result: { sessionId: "session-1" },
|
||||
});
|
||||
await start;
|
||||
|
||||
manager.send("netcatty:close", {
|
||||
sessionId: "session-1",
|
||||
retainOwnership: true,
|
||||
}, { webContentsId: 7 });
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
|
||||
});
|
||||
|
||||
async function startWorkerSessionForLifecycle(manager, child) {
|
||||
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
||||
child.emit("message", {
|
||||
kind: "response",
|
||||
requestId: child.messages[0].requestId,
|
||||
result: { sessionId: "session-1" },
|
||||
});
|
||||
await start;
|
||||
}
|
||||
|
||||
function createLifecycleManager(child, closed) {
|
||||
const manager = createTerminalWorkerManager({
|
||||
utilityProcess: { fork: () => child },
|
||||
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
||||
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
|
||||
workerScriptPath: "/worker.cjs",
|
||||
});
|
||||
manager.onSessionClosed((event) => closed.push(event));
|
||||
return manager;
|
||||
}
|
||||
|
||||
test("fire-and-forget close after a worker exit still emits an explicit lifecycle event", async () => {
|
||||
const child = new FakeChild();
|
||||
const closed = [];
|
||||
const manager = createLifecycleManager(child, closed);
|
||||
await startWorkerSessionForLifecycle(manager, child);
|
||||
|
||||
child.emit("message", {
|
||||
kind: "renderer-event",
|
||||
webContentsId: 7,
|
||||
channel: "netcatty:exit",
|
||||
payload: { sessionId: "session-1", reason: "exited", exitCode: 0 },
|
||||
});
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "exited" }]);
|
||||
|
||||
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
|
||||
assert.deepEqual(closed, [
|
||||
{ sessionId: "session-1", reason: "exited" },
|
||||
{ sessionId: "session-1", reason: "closed", explicit: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("awaited close after a worker exit still emits an explicit lifecycle event", async () => {
|
||||
const child = new FakeChild();
|
||||
const closed = [];
|
||||
const manager = createLifecycleManager(child, closed);
|
||||
await startWorkerSessionForLifecycle(manager, child);
|
||||
|
||||
child.emit("message", {
|
||||
kind: "renderer-event",
|
||||
webContentsId: 7,
|
||||
channel: "netcatty:exit",
|
||||
payload: { sessionId: "session-1", reason: "exited", exitCode: 0 },
|
||||
});
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "exited" }]);
|
||||
|
||||
await manager.request("netcatty:close:await", { sessionId: "session-1" }, { webContentsId: 7 });
|
||||
assert.deepEqual(closed, [
|
||||
{ sessionId: "session-1", reason: "exited" },
|
||||
{ sessionId: "session-1", reason: "closed", explicit: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a duplicate exit from an old session generation cannot close a reconnected session", async () => {
|
||||
const child = new FakeChild();
|
||||
const closed = [];
|
||||
@@ -2357,7 +2445,7 @@ test("an old-generation exit after explicit close cannot cancel a pending reconn
|
||||
|
||||
await reconnect;
|
||||
assert.equal(manager.hasOpenSession("session-1"), true);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
|
||||
assert.deepEqual(routed, [{ sessionId: "session-1", data: "NEW-EARLY" }]);
|
||||
});
|
||||
|
||||
@@ -2404,7 +2492,7 @@ test("a closed pending start response records its generation before a later reco
|
||||
|
||||
await second;
|
||||
assert.equal(manager.hasOpenSession("session-1"), true);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
|
||||
});
|
||||
|
||||
test("a much older exit cannot cancel a reconnect after multiple generations", async () => {
|
||||
@@ -3092,7 +3180,10 @@ test("closing a crashed session is idempotent until a new worker reuses its id",
|
||||
}
|
||||
await close;
|
||||
assert.equal(children.length, 1);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "worker-exit" }]);
|
||||
assert.deepEqual(closed, [
|
||||
{ sessionId: "session-1", reason: "worker-exit" },
|
||||
{ sessionId: "session-1", reason: "closed", explicit: true },
|
||||
]);
|
||||
|
||||
const secondStart = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
||||
const secondChild = children[1];
|
||||
@@ -3105,6 +3196,7 @@ test("closing a crashed session is idempotent until a new worker reuses its id",
|
||||
secondChild.emit("exit", 1);
|
||||
assert.deepEqual(closed, [
|
||||
{ sessionId: "session-1", reason: "worker-exit" },
|
||||
{ sessionId: "session-1", reason: "closed", explicit: true },
|
||||
{ sessionId: "session-1", reason: "worker-exit" },
|
||||
]);
|
||||
});
|
||||
@@ -3615,7 +3707,7 @@ test("an internal same-id replacement exit retires only the old generation", asy
|
||||
data: "NEW-EARLY",
|
||||
webContentsId: 8,
|
||||
}]);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
|
||||
assert.equal(outputOpen, true);
|
||||
assert.equal(
|
||||
child.messages.some((message) => (
|
||||
@@ -3690,7 +3782,7 @@ test("a failed replacement is not reported closed again when the worker exits",
|
||||
await assert.rejects(replacement, /start failed/u);
|
||||
child.emit("exit", 1);
|
||||
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
|
||||
});
|
||||
|
||||
test("manager and runtime preserve a completed same-id replacement end to end", async () => {
|
||||
@@ -3770,7 +3862,7 @@ test("manager and runtime preserve a completed same-id replacement end to end",
|
||||
{ sessionId: "session-1", data: "OLD", webContentsId: 7 },
|
||||
{ sessionId: "session-1", data: "NEW-EARLY", webContentsId: 8 },
|
||||
]);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
|
||||
assert.equal(outputWebContentsId, 8);
|
||||
assert.equal(
|
||||
child.messages.some((message) => (
|
||||
@@ -4183,7 +4275,7 @@ test("a superseded start failure cannot cancel the queued same-id restart", asyn
|
||||
|
||||
await second;
|
||||
assert.equal(manager.hasOpenSession("session-1"), true);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "failed" }]);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
|
||||
});
|
||||
|
||||
test("a failed reconnect after worker exit keeps later close cleanup idempotent", async () => {
|
||||
@@ -4237,7 +4329,10 @@ test("a failed reconnect after worker exit keeps later close cleanup idempotent"
|
||||
|
||||
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
|
||||
assert.equal(children.length, 2);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "worker-exit" }]);
|
||||
assert.deepEqual(closed, [
|
||||
{ sessionId: "session-1", reason: "worker-exit" },
|
||||
{ sessionId: "session-1", reason: "closed", explicit: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a same-id reconnect that outputs before another worker exit closes its new lifecycle", async () => {
|
||||
@@ -4350,7 +4445,7 @@ test("closing a same-id reconnect before its response closes the new lifecycle o
|
||||
assert.equal(closeMessage.channel, "netcatty:close");
|
||||
assert.deepEqual(closed, [
|
||||
{ sessionId: "session-1", reason: "worker-exit" },
|
||||
{ sessionId: "session-1", reason: "closed" },
|
||||
{ sessionId: "session-1", reason: "closed", explicit: true },
|
||||
]);
|
||||
|
||||
secondChild.emit("message", {
|
||||
@@ -4362,7 +4457,7 @@ test("closing a same-id reconnect before its response closes the new lifecycle o
|
||||
secondChild.emit("message", { kind: "session-exit", sessionId: "session-1", exitCode: 0 });
|
||||
assert.deepEqual(closed, [
|
||||
{ sessionId: "session-1", reason: "worker-exit" },
|
||||
{ sessionId: "session-1", reason: "closed" },
|
||||
{ sessionId: "session-1", reason: "closed", explicit: true },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -4675,7 +4770,7 @@ test("an awaited close still notifies lifecycle cleanup when worker IPC is alrea
|
||||
);
|
||||
|
||||
await assert.rejects(close, /IPC closed/u);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
|
||||
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
|
||||
assert.equal(manager.hasOpenSession("session-1"), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -529,6 +529,7 @@ function createPreloadApi(ctx) {
|
||||
const payload = {
|
||||
sessionId,
|
||||
...(Number.isFinite(options?.bootEpoch) ? { bootEpoch: Number(options.bootEpoch) } : {}),
|
||||
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
|
||||
};
|
||||
const epochScoped = Number.isFinite(options?.bootEpoch);
|
||||
// Unscoped closes keep the historical sync listener teardown so reconnect
|
||||
@@ -811,10 +812,11 @@ function createPreloadApi(ctx) {
|
||||
const result = await ipcRenderer.invoke("netcatty:sftp:open", options);
|
||||
return result.sftpId;
|
||||
},
|
||||
openSftpForSession: async (sessionId, expectedEndpoint) => {
|
||||
openSftpForSession: async (sessionId, options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:sftp:openForSession", {
|
||||
...(options || {}),
|
||||
sessionId,
|
||||
expectedEndpoint,
|
||||
expectedEndpoint: options,
|
||||
});
|
||||
return result.sftpId;
|
||||
},
|
||||
@@ -1720,6 +1722,9 @@ function createPreloadApi(ctx) {
|
||||
aiMcpUpdateSessions: async (sessions, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:update-sessions", { sessions, chatSessionId });
|
||||
},
|
||||
aiMcpUpdateLiveSessions: async (sessions) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:update-live-sessions", { sessions });
|
||||
},
|
||||
aiMcpMergeSessions: async (sessions, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:merge-sessions", { sessions, chatSessionId });
|
||||
},
|
||||
|
||||
@@ -191,3 +191,31 @@ test("listLocalTree keeps the entries listener until the tree-end marker arrives
|
||||
assert.equal(batches[0][0].relativePath, "project/nested/deep.txt");
|
||||
assert.equal(removed, true);
|
||||
});
|
||||
|
||||
test("openSftpForSession keeps the SSH source session id when options include an SFTP session id", async () => {
|
||||
const invoked = [];
|
||||
const api = createPreloadApi({
|
||||
webUtils: {},
|
||||
ipcRenderer: {
|
||||
on() {},
|
||||
removeListener() {},
|
||||
async invoke(channel, payload) {
|
||||
invoked.push({ channel, payload });
|
||||
return { sftpId: "opened-sftp" };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sftpId = await api.openSftpForSession("ssh-session-1", {
|
||||
sessionId: "sftp-left-browse-session",
|
||||
hostname: "192.168.9.138",
|
||||
port: 22,
|
||||
username: "zlhrs",
|
||||
});
|
||||
|
||||
assert.equal(sftpId, "opened-sftp");
|
||||
assert.equal(invoked.length, 1);
|
||||
assert.equal(invoked[0].channel, "netcatty:sftp:openForSession");
|
||||
assert.equal(invoked[0].payload.sessionId, "ssh-session-1");
|
||||
assert.equal(invoked[0].payload.expectedEndpoint.sessionId, "sftp-left-browse-session");
|
||||
});
|
||||
@@ -141,3 +141,16 @@ test('every built-in terminal theme keeps side panel text readable', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Netcatty Light side panel tokens are light so color-scheme can match the composer', () => {
|
||||
const theme = TERMINAL_THEMES.find((entry) => entry.id === 'netcatty-light');
|
||||
assert.ok(theme);
|
||||
assert.equal(theme.type, 'light');
|
||||
const vars = buildTerminalSidePanelCssVars(theme);
|
||||
const backgroundLuminance = getHslTokenRelativeLuminance(vars['--background']);
|
||||
assert.notEqual(backgroundLuminance, null);
|
||||
assert.ok(
|
||||
(backgroundLuminance as number) > 0.5,
|
||||
'Netcatty Light --background must stay light for the AI composer surface',
|
||||
);
|
||||
});
|
||||
+3
-3
@@ -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=";
|
||||
};
|
||||
};
|
||||
}
|
||||
Vendored
+15
@@ -107,6 +107,21 @@ declare global {
|
||||
hostChain?: Array<{ hostId: string; label?: string; hostname?: string }>;
|
||||
activePortForwards?: Array<{ ruleId: string; label?: string; type?: string; localPort?: number; status?: string }>;
|
||||
}>, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||
/** Update the app-owned live session snapshot used by existing AI scopes. */
|
||||
aiMcpUpdateLiveSessions?(sessions: Array<{
|
||||
sessionId: string;
|
||||
hostId?: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
hostChain?: Array<{ hostId: string; label?: string; hostname?: string }>;
|
||||
activePortForwards?: Array<{ ruleId: string; label?: string; type?: string; localPort?: number; status?: string }>;
|
||||
}>): Promise<{ ok: boolean; count?: number; error?: string }>;
|
||||
/** Merge sessions into a chat scope without dropping existing entries. */
|
||||
aiMcpMergeSessions?(sessions: Array<{
|
||||
sessionId: string;
|
||||
|
||||
+4
@@ -117,6 +117,10 @@ export interface ScriptDialogRequest {
|
||||
}
|
||||
|
||||
export interface ScriptRunParams {
|
||||
/** Optional caller-generated id so a queued run can be cancelled before it starts. */
|
||||
runId?: string;
|
||||
/** Return after the run enters the backend queue instead of waiting for completion. */
|
||||
returnWhenQueued?: boolean;
|
||||
scriptId?: string;
|
||||
scriptLabel?: string;
|
||||
content: string;
|
||||
|
||||
+1
-1
@@ -363,7 +363,7 @@ declare global {
|
||||
respondTerminalOutputDrain?(requestId: string): void;
|
||||
notifyTerminalSessionDisplayReady?(sessionId: string): void;
|
||||
ackSessionFlow(sessionId: string, bytes: number): void;
|
||||
closeSession(sessionId: string, options?: { bootEpoch?: number }): void | Promise<void>;
|
||||
closeSession(sessionId: string, options?: { bootEpoch?: number; retainOwnership?: boolean }): void | Promise<void>;
|
||||
/** Move a live session's output port to this renderer (same PTY). */
|
||||
rebindTerminalSessionOutput?(sessionId: string, authorization: string): Promise<{
|
||||
success: boolean;
|
||||
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user