mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 02:42:31 +00:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef364b69fd | ||
|
|
4beccc3d61 | ||
|
|
33b3beaea0 | ||
|
|
e65af2fd7b | ||
|
|
5d63a9476c | ||
|
|
58c72ace32 | ||
|
|
75cbda7edc | ||
|
|
6f6d8dc36d | ||
|
|
d0f819efb0 | ||
|
|
380e96eae5 | ||
|
|
02ce011934 | ||
|
|
8e45763e4c | ||
|
|
0d92c5d738 | ||
|
|
6bf4268e8e | ||
|
|
f02b645af7 | ||
|
|
eb3cd06278 | ||
|
|
7ed0b32489 | ||
|
|
2fe46cbf6f | ||
|
|
53ebbec58c | ||
|
|
253ce57d84 | ||
|
|
9beb7d3108 | ||
|
|
525a78e83e | ||
|
|
0e94950a5c | ||
|
|
a2ea0f6464 | ||
|
|
42b174abb3 | ||
|
|
e2ad0f526e | ||
|
|
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -759,7 +759,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
: -1}
|
||||
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onDragStart={(e) => {
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
const action = resolveHostActivateAction({
|
||||
behavior: normalizedHostClickBehavior,
|
||||
|
||||
+114
-19
@@ -225,8 +225,16 @@ import {
|
||||
} from "./terminal/terminalRuntimeMount";
|
||||
import type { CreateXTermRuntimeContext } from "./terminal/runtime/createXTermRuntime";
|
||||
import { TerminalView } from "./terminal/TerminalView";
|
||||
import {
|
||||
cancelConnectAutomationBatch,
|
||||
createConnectAutomationBatch,
|
||||
trackConnectAutomationStop,
|
||||
type ConnectAutomationBatch,
|
||||
} from "./terminal/connectAutomationBatch";
|
||||
import {
|
||||
getInitialTerminalStatus,
|
||||
resolveTerminalVaultInitialized,
|
||||
shouldResetConnectAutomationOnReconnect,
|
||||
shouldSuppressHostStartupCommandOnReconnect,
|
||||
shouldStartTerminalBackend,
|
||||
} from "./terminal/restoredSessionGate";
|
||||
@@ -270,6 +278,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
workspaceId,
|
||||
restoreState,
|
||||
vaultInitializedOverride,
|
||||
pendingInitialCwd,
|
||||
shellType,
|
||||
lastCwd,
|
||||
@@ -359,10 +368,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, []);
|
||||
// Reactive vault-ready flag so restored panes re-arm boot after hydration
|
||||
// (module isVaultInitialized() alone is not a React dependency).
|
||||
const vaultInitialized = useVaultSnapshotField("isVaultInitialized");
|
||||
const sharedVaultInitialized = useVaultSnapshotField("isVaultInitialized");
|
||||
const vaultInitialized = resolveTerminalVaultInitialized(
|
||||
sharedVaultInitialized,
|
||||
vaultInitializedOverride,
|
||||
);
|
||||
const connectScriptsConsumedRef = useRef(false);
|
||||
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
|
||||
const connectScriptsInFlightRef = useRef(false);
|
||||
const connectScriptsBatchRef = useRef<ConnectAutomationBatch | null>(null);
|
||||
const pendingScriptRunIdRef = useRef<string | null>(null);
|
||||
const pendingScriptHandledRef = useRef<Snippet | null>(null);
|
||||
const pendingScriptRef = useRef(pendingScript);
|
||||
@@ -2511,9 +2525,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!shouldEvaluateConnect && !hasPendingWork) return;
|
||||
if (connectScriptsInFlightRef.current) return;
|
||||
|
||||
const batch = createConnectAutomationBatch();
|
||||
connectScriptsBatchRef.current = batch;
|
||||
connectScriptsInFlightRef.current = true;
|
||||
const batchStillActive = () => (
|
||||
!batch.controller.signal.aborted && connectScriptsBatchRef.current === batch
|
||||
);
|
||||
|
||||
// Defer until xterm has rendered login output and the main-process output tap
|
||||
// has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer).
|
||||
let timerFired = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
timerFired = true;
|
||||
if (!batchStillActive()) return;
|
||||
const runPending = Boolean(pendingOne);
|
||||
const connectQueueNow = connectScriptsConsumedRef.current
|
||||
? []
|
||||
@@ -2542,6 +2566,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
})) {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
}
|
||||
if (connectScriptsBatchRef.current === batch) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2550,11 +2578,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
|
||||
connectScriptsInFlightRef.current = true;
|
||||
|
||||
void runConnectScriptsSequential({
|
||||
scripts: scriptsToRun,
|
||||
sessionId,
|
||||
signal: batch.controller.signal,
|
||||
onCancelableRunChange: (stopCurrentRun) => (
|
||||
trackConnectAutomationStop(batch, stopCurrentRun)
|
||||
),
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -2562,6 +2592,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
username: host.username,
|
||||
},
|
||||
onScriptComplete: (snippet) => {
|
||||
if (!batchStillActive()) return;
|
||||
if (snippet.id && connectIdsInBatch.has(snippet.id)) {
|
||||
connectScriptsCompletedIdsRef.current.add(snippet.id);
|
||||
}
|
||||
@@ -2575,6 +2606,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (!batchStillActive()) return;
|
||||
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
|
||||
const doneAfterRun = resolvedAfterRun.length === 0
|
||||
|| resolvedAfterRun.every(
|
||||
@@ -2585,6 +2617,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
if (!batchStillActive()) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
|
||||
connectScriptsConsumedRef.current = true;
|
||||
@@ -2600,6 +2633,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
await runConnectScriptsSequential({
|
||||
scripts: [pendingScriptToMark],
|
||||
sessionId,
|
||||
signal: batch.controller.signal,
|
||||
onCancelableRunChange: (stopCurrentRun) => (
|
||||
trackConnectAutomationStop(batch, stopCurrentRun)
|
||||
),
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -2607,6 +2644,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
username: host.username,
|
||||
},
|
||||
onScriptComplete: (snippet) => {
|
||||
if (!batchStillActive()) return;
|
||||
if (snippet.id) {
|
||||
pendingScriptRunIdRef.current = snippet.id;
|
||||
} else {
|
||||
@@ -2615,16 +2653,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
},
|
||||
});
|
||||
} catch (pendingErr) {
|
||||
if (!batchStillActive()) return;
|
||||
const pendingMessage = pendingErr instanceof Error ? pendingErr.message : String(pendingErr);
|
||||
toast.error(pendingMessage.includes('Observer mode') ? t('scripts.observer.blocked') : pendingMessage);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
connectScriptsInFlightRef.current = false;
|
||||
if (
|
||||
!batch.controller.signal.aborted
|
||||
&& connectScriptsBatchRef.current === batch
|
||||
) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
});
|
||||
}, 400);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
if (!timerFired && connectScriptsBatchRef.current === batch) {
|
||||
batch.controller.abort();
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
} else if (statusRef.current !== "connected") {
|
||||
void cancelConnectAutomationBatch(batch).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [effectiveTerminalProtocol, host, isPendingScriptAlreadyHandled, moshShellReady, pendingScript, pendingScriptId, scriptSessionName, sessionId, snippets, status, t]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3352,20 +3406,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
if (!termRef.current) return;
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
prepareRestoredReconnect();
|
||||
// A clone's first connection can fail (auth/host-key/transport) before the
|
||||
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
|
||||
// intent for non-restored sessions, so re-arm it here; the callback no-ops
|
||||
// once the cwd was consumed or when there is no pending inherited cwd.
|
||||
prepareInitialCwdIntent();
|
||||
} else {
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
|
||||
}
|
||||
// Claim the retry before awaiting close. A close/cancel/unmount during the
|
||||
// awaited backend cleanup invalidates this token and stops the continuation.
|
||||
// Claim the retry before awaiting either script cancellation or backend
|
||||
// cleanup. A second reconnect/cancel invalidates this continuation.
|
||||
const retryToken = Symbol("retry");
|
||||
retryTokenRef.current = retryToken;
|
||||
reconnectPreparationTokenRef.current = retryToken;
|
||||
@@ -3376,6 +3418,59 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const connectAutomationBatch = connectScriptsBatchRef.current;
|
||||
if (connectAutomationBatch) {
|
||||
try {
|
||||
await cancelConnectAutomationBatch(connectAutomationBatch);
|
||||
} catch (error) {
|
||||
finishReconnectPreparation();
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message);
|
||||
if (mode === "auto" && retryTokenStillCurrent()) {
|
||||
// Return to disconnected so the existing auto-reconnect loop can
|
||||
// schedule another attempt, including another exact stop request.
|
||||
updateStatus("disconnected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!retryTokenStillCurrent()) {
|
||||
finishReconnectPreparation();
|
||||
return;
|
||||
}
|
||||
if (connectScriptsBatchRef.current === connectAutomationBatch) {
|
||||
connectScriptsBatchRef.current = null;
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
// Manual reconnect skips the disconnected status transition that normally
|
||||
// clears these refs, so onConnect scripts would otherwise stay consumed.
|
||||
if (shouldResetConnectAutomationOnReconnect(
|
||||
restoreState === "restored-disconnected" ? "restored" : "manual",
|
||||
)) {
|
||||
// The prior batch is now stopped, so its callbacks cannot mutate these
|
||||
// newly allocated guards for the fresh manual connection.
|
||||
connectScriptsConsumedRef.current = false;
|
||||
connectScriptsCompletedIdsRef.current = new Set();
|
||||
connectScriptsInFlightRef.current = false;
|
||||
}
|
||||
prepareRestoredReconnect();
|
||||
// A clone's first connection can fail (auth/host-key/transport) before the
|
||||
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
|
||||
// intent for non-restored sessions, so re-arm it here; the callback no-ops
|
||||
// once the cwd was consumed or when there is no pending inherited cwd.
|
||||
prepareInitialCwdIntent();
|
||||
} else {
|
||||
// An automatic reconnect replaces the transport but remains the same user
|
||||
// session. Stop old automation above, then preserve the existing policy of
|
||||
// not starting onConnect scripts again on the replacement transport.
|
||||
connectScriptsConsumedRef.current = true;
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupSession();
|
||||
} catch (error) {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -884,6 +884,10 @@ function createTransport({
|
||||
idleSince: null,
|
||||
idleDeadlineAt: null,
|
||||
createdAt: nowFn(),
|
||||
pendingShellReconnectRisk: null,
|
||||
closedShellPids: new Set(),
|
||||
closedShellPidUnknown: false,
|
||||
shellCloseGeneration: 0,
|
||||
meta: meta || null,
|
||||
endedReason: null,
|
||||
_poolOnConnectionClose: null,
|
||||
@@ -963,6 +967,9 @@ function transferConnectionRef(fromHolder, toHolder) {
|
||||
if (!lease) return false;
|
||||
|
||||
lease.holder = toHolder;
|
||||
if (lease.kind === LEASE_KINDS.shell && toHolder?.stream) {
|
||||
lease.meta = { ...(lease.meta || {}), activeShellChannel: true };
|
||||
}
|
||||
leasesById.set(leaseId, { transport, holder: toHolder });
|
||||
|
||||
fromHolder.connRef = null;
|
||||
@@ -1014,10 +1021,35 @@ function returnTransport(leaseIdOrHolder) {
|
||||
}
|
||||
|
||||
const { transport } = entry;
|
||||
const releasedLease = transport.leases.get(leaseId);
|
||||
leasesById.delete(leaseId);
|
||||
transport.leases.delete(leaseId);
|
||||
transport.count = transport.leases.size;
|
||||
|
||||
if (
|
||||
releasedLease?.kind === LEASE_KINDS.shell
|
||||
&& releasedLease.meta?.activeShellChannel === true
|
||||
) {
|
||||
transport.shellCloseGeneration = (transport.shellCloseGeneration || 0) + 1;
|
||||
const releasedPid = String(releasedLease.holder?.shellPid || "");
|
||||
if (/^\d+$/.test(releasedPid)) transport.closedShellPids.add(releasedPid);
|
||||
else transport.closedShellPidUnknown = true;
|
||||
const hasActiveShell = [...transport.leases.values()].some(
|
||||
(lease) => lease.kind === LEASE_KINDS.shell && lease.meta?.activeShellChannel === true,
|
||||
);
|
||||
if (!hasActiveShell) {
|
||||
// A locally closed shell channel can outlive its lease briefly on the
|
||||
// server. Remember that provenance even when SFTP/forward leases keep the
|
||||
// transport live, so the next shell does not guess cwd from the old PID.
|
||||
transport.pendingShellReconnectRisk = {
|
||||
oldShellPids: [...transport.closedShellPids],
|
||||
hasUnknownOldShell: transport.closedShellPidUnknown,
|
||||
};
|
||||
transport.closedShellPids.clear();
|
||||
transport.closedShellPidUnknown = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.holder && typeof entry.holder === "object") {
|
||||
if (entry.holder.connRef === transport) entry.holder.connRef = null;
|
||||
entry.holder._sshTransportLeaseId = null;
|
||||
@@ -1044,6 +1076,13 @@ function returnTransport(leaseIdOrHolder) {
|
||||
};
|
||||
}
|
||||
|
||||
function consumePendingShellReconnectRisk(transport) {
|
||||
if (!transport?.pendingShellReconnectRisk) return false;
|
||||
const risk = transport.pendingShellReconnectRisk;
|
||||
transport.pendingShellReconnectRisk = null;
|
||||
return risk;
|
||||
}
|
||||
|
||||
function discardTransport(transportOrId, reason = "discard") {
|
||||
const transport = typeof transportOrId === "string"
|
||||
? transportsById.get(transportOrId)
|
||||
@@ -1239,7 +1278,11 @@ function createConnectionRef(session, conn, chainConnections) {
|
||||
holder: session,
|
||||
// Unique per connection generation: same sessionId can reconnect while an
|
||||
// old lease is still draining (same-session reconnect path).
|
||||
meta: { source: "createConnectionRef", sessionId: session?.id || null },
|
||||
meta: {
|
||||
source: "createConnectionRef",
|
||||
sessionId: session?.id || null,
|
||||
activeShellChannel: true,
|
||||
},
|
||||
});
|
||||
|
||||
return transport;
|
||||
@@ -1268,7 +1311,11 @@ function acquireConnectionRef(session, connRef) {
|
||||
holder: session,
|
||||
// Always allocate a unique lease id — stable session/sftp ids can collide
|
||||
// across reconnect generations while old leases drain.
|
||||
meta: { source: "acquireConnectionRef", holderId: session?.id || null },
|
||||
meta: {
|
||||
source: "acquireConnectionRef",
|
||||
holderId: session?.id || null,
|
||||
activeShellChannel: kind === LEASE_KINDS.shell && Boolean(session?.stream),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1401,5 +1448,6 @@ module.exports = {
|
||||
acquireConnectionRef,
|
||||
releaseConnectionRef,
|
||||
transferConnectionRef,
|
||||
consumePendingShellReconnectRisk,
|
||||
findReusableSession,
|
||||
};
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
acquireConnectionRef,
|
||||
releaseConnectionRef,
|
||||
transferConnectionRef,
|
||||
consumePendingShellReconnectRisk,
|
||||
findReusableSession,
|
||||
createTransport,
|
||||
borrowTransport,
|
||||
@@ -335,6 +336,20 @@ test("releaseConnectionRef on a session without a descriptor is a safe no-op", (
|
||||
assert.equal(releaseConnectionRef(undefined), false);
|
||||
});
|
||||
|
||||
test("last shell close marks reconnect risk while an SFTP lease keeps the transport live", () => {
|
||||
const owner = { id: "shell", stream: {} };
|
||||
const sftp = { id: "sftp", __sshLeaseKind: "sftp" };
|
||||
const transport = createConnectionRef(owner, makeConn(), []);
|
||||
acquireConnectionRef(sftp, transport);
|
||||
|
||||
assert.equal(releaseConnectionRef(owner), false);
|
||||
assert.deepEqual(consumePendingShellReconnectRisk(transport), {
|
||||
oldShellPids: [],
|
||||
hasUnknownOldShell: true,
|
||||
});
|
||||
assert.equal(consumePendingShellReconnectRisk(transport), false);
|
||||
});
|
||||
|
||||
test("single-channel connection parks on release when TTL is 0", () => {
|
||||
const conn = makeConn();
|
||||
const chain = [makeChainConn()];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,3 +29,47 @@ test("renderer terminal input reports activity for the matching session", () =>
|
||||
{ sessionId: "session-1", phase: "touch" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("only a submitted non-sensitive user command arms cwd recovery", () => {
|
||||
const stream = { write() { return true; } };
|
||||
const session = { stream, webContentsId: 1, blockUntargetedCwdProbe: true };
|
||||
const sessions = new Map([["session-1", session]]);
|
||||
terminalBridge.init({ sessions, electronModule: { webContents: { fromId: () => null } } });
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, {
|
||||
sessionId: "session-1", data: "pwd", automated: false,
|
||||
});
|
||||
assert.notEqual(session.pendingCwdRecoveryAfterUserCommand, true);
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, {
|
||||
sessionId: "session-1", data: "secret\r", sensitive: true,
|
||||
});
|
||||
assert.notEqual(session.pendingCwdRecoveryAfterUserCommand, true);
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, {
|
||||
sessionId: "session-1", data: "pwd\r", automated: false,
|
||||
});
|
||||
assert.equal(session.pendingCwdRecoveryAfterUserCommand, true);
|
||||
});
|
||||
|
||||
test("blocked transfer input and failed writes do not arm cwd recovery", () => {
|
||||
const blocked = {
|
||||
stream: { write() { throw new Error("must not write during transfer"); } },
|
||||
webContentsId: 1,
|
||||
blockUntargetedCwdProbe: true,
|
||||
zmodemSentry: { isActive: () => true, cancel() {} },
|
||||
};
|
||||
const failed = {
|
||||
stream: { write() { throw Object.assign(new Error("closed"), { code: "EPIPE" }); } },
|
||||
webContentsId: 1,
|
||||
blockUntargetedCwdProbe: true,
|
||||
};
|
||||
const sessions = new Map([["blocked", blocked], ["failed", failed]]);
|
||||
terminalBridge.init({ sessions, electronModule: { webContents: { fromId: () => null } } });
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, { sessionId: "blocked", data: "pwd\r" });
|
||||
terminalBridge.writeToSession({ sender: {} }, { sessionId: "failed", data: "pwd\r" });
|
||||
|
||||
assert.notEqual(blocked.pendingCwdRecoveryAfterUserCommand, true);
|
||||
assert.notEqual(failed.pendingCwdRecoveryAfterUserCommand, true);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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=";
|
||||
};
|
||||
};
|
||||
}
|
||||
+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;
|
||||
|
||||
Reference in new issue
Block a user