Compare commits

...
34 changed files with 335 additions and 11 deletions

No files matched your search

+45 -2
View File
@@ -1,8 +1,10 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, splitSessionWithCurrentShellImpl } from "./AppHandlers";
import { copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, splitSessionWithCurrentShellImpl } from "./AppHandlers";
import { createCopiedTerminalSessionClone } from "../state/terminalConnectionReuse";
import type { TerminalSession } from "../../domain/models";
type CloneOpts = { localShellType?: string; inheritedCwd?: string };
type CloneOpts = { localShellType?: string; inheritedCwd?: string; reuseConnection?: boolean };
type Calls = {
copy?: { id: string; opts: CloneOpts };
split?: { id: string; dir: string; opts: CloneOpts };
@@ -55,6 +57,47 @@ test("live tracked cwd is preferred over the probe", async () => {
assert.equal(calls.probed, false, "must not probe when live cwd is known");
});
for (const protocol of ["ssh", undefined] as const) {
for (const liveCwd of ["/srv/old-target", undefined]) {
test(`duplicate SSH session does not capture or inject the old target directory (${protocol}, ${liveCwd})`, async () => {
const source: TerminalSession = {
id: "src", hostId: "bastion", hostLabel: "Bastion", hostname: "bastion.test",
username: "alice", protocol, status: "connected", lastCwd: "/saved/old-target",
};
let cwdReads = 0;
let bridgeReads = 0;
const { getCtx, calls } = ctxFactory({
sessions: [source],
getSessionRestoreCwd: () => { cwdReads += 1; return liveCwd; },
netcattyBridge: { get: () => { bridgeReads += 1; return {}; } },
});
await duplicateSessionWithCurrentShellImpl(getCtx, "src");
assert.equal(calls.copy?.id, "src");
assert.equal(calls.copy?.opts.reuseConnection, false);
assert.equal(calls.copy?.opts.inheritedCwd, undefined);
assert.equal(cwdReads, 0, "fresh remote login must not read the previous target's directory");
assert.equal(bridgeReads, 0, "fresh remote login must not probe the previous target");
const clone = createCopiedTerminalSessionClone(source, {
id: "duplicate",
inheritedCwd: calls.copy?.opts.inheritedCwd,
reuseConnection: calls.copy?.opts.reuseConnection,
});
assert.equal(clone.requireFreshConnection, true);
assert.equal(clone.pendingInitialCwd, undefined);
});
}
}
test("duplicate local session retains the current working directory", async () => {
const { getCtx, calls } = ctxFactory({
sessions: [{ id: "src", protocol: "local", status: "connected", localStartDir: "/home/alice" }],
getSessionRestoreCwd: () => "/home/alice/project",
});
await duplicateSessionWithCurrentShellImpl(getCtx, "src");
assert.equal(calls.copy?.opts.inheritedCwd, "/home/alice/project");
assert.equal(calls.probed, false);
});
test("network device (by deviceType) is never probed", async () => {
const { getCtx, calls } = ctxFactory({
hostById: new Map([["h1", { id: "h1", deviceType: "network" }]]),
+23 -3
View File
@@ -551,17 +551,37 @@ export async function splitSessionWithCurrentShellImpl(getCtx: AppContextGetter,
});
}
export async function copySessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
const { classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
export async function copySessionWithCurrentShellImpl(
getCtx: AppContextGetter,
sessionId: string,
options?: { reuseConnection?: boolean },
) {
const { classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, sessions, terminalSettings } = getCtx();
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
const inheritedCwd = await captureCtxInheritedCwd(getCtx, sessionId);
// A fresh remote login may stop at a bastion's host-selection prompt. Do
// not probe the old target or send its directory command to the new login.
// Local duplicates still open in the source terminal's current directory.
const inheritCwd = options?.reuseConnection !== false
|| sessions?.find((session: { id: string }) => session.id === sessionId)?.protocol === "local";
const inheritedCwd = inheritCwd ? await captureCtxInheritedCwd(getCtx, sessionId) : undefined;
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
return copySession(sessionId, {
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, userAgent),
inheritedCwd,
reuseConnection: options?.reuseConnection,
});
}
/**
* "Duplicate session": clone the tab but open a brand-new connection instead of
* multiplexing over the source's established SSH channel (fresh auth; with a
* bastion/jump host this restarts the bastion login so the user can pick a new
* target host).
*/
export async function duplicateSessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
return copySessionWithCurrentShellImpl(getCtx, sessionId, { reuseConnection: false });
}
export async function copyWorkspaceWithCurrentShellImpl(getCtx: AppContextGetter, workspaceId: string) {
const { classifyLocalShellType, collectSessionIds, copyWorkspace, discoveredShells, resolveShellSetting, terminalSettings, workspaces } = getCtx();
const workspace = workspaces.find((w: { id: string }) => w.id === workspaceId);
+5 -1
View File
@@ -90,7 +90,7 @@ import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
type OpenSessionInNewWindowPayload = {
title?: string;
@@ -959,6 +959,8 @@ export function AppSideEffects() {
const copySessionWithCurrentShell = useCallback((sessionId: string) => { return copySessionWithCurrentShellImpl(() => ({ classifyLocalShellType, copySession, discoveredShells, getSessionRestoreCwd, hostById, terminalHosts, netcattyBridge, resolveShellSetting, sessionId, sessions, terminalSettings }), sessionId); }, [copySession, terminalSettings, discoveredShells, sessions, getSessionRestoreCwd, hostById, terminalHosts]);
const duplicateSessionWithCurrentShell = useCallback((sessionId: string) => { return duplicateSessionWithCurrentShellImpl(() => ({ classifyLocalShellType, copySession, discoveredShells, getSessionRestoreCwd, hostById, terminalHosts, netcattyBridge, resolveShellSetting, sessionId, sessions, terminalSettings }), sessionId); }, [copySession, terminalSettings, discoveredShells, sessions, getSessionRestoreCwd, hostById, terminalHosts]);
const copyWorkspaceWithCurrentShell = useCallback((workspaceId: string) => { return copyWorkspaceWithCurrentShellImpl(() => ({ classifyLocalShellType, collectSessionIds, copyWorkspace, discoveredShells, getSessionRestoreCwd, hostById, terminalHosts, netcattyBridge, resolveShellSetting, sessions, terminalSettings, workspaces }), workspaceId); }, [copyWorkspace, terminalSettings, discoveredShells, sessions, workspaces, getSessionRestoreCwd, hostById, terminalHosts]);
const copySessionToNewWindowWithCurrentShell = useCallback((sessionId: string) => { return copySessionToNewWindowWithCurrentShellImpl(() => ({ classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast }), sessionId); }, [sessions, terminalSettings, discoveredShells, t]);
@@ -1871,6 +1873,7 @@ export function AppSideEffects() {
// Terminal glue
closeTabsBatch,
copySessionWithCurrentShell,
duplicateSessionWithCurrentShell,
copyWorkspaceWithCurrentShell,
copySessionToNewWindowWithCurrentShell,
createWorkspaceFromTargets: createWorkspaceFromEffectiveTargets,
@@ -1946,6 +1949,7 @@ export function AppSideEffects() {
unmanageSource,
closeTabsBatch,
copySessionWithCurrentShell,
duplicateSessionWithCurrentShell,
copyWorkspaceWithCurrentShell,
copySessionToNewWindowWithCurrentShell,
createWorkspaceFromEffectiveTargets,
+3 -1
View File
@@ -260,7 +260,7 @@ function AppViewInner({ domains }: AppViewProps) {
const {
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
followAppTerminalTheme,
@@ -503,6 +503,7 @@ function AppViewInner({ domains }: AppViewProps) {
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySessionWithCurrentShell}
onDuplicateSession={duplicateSessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onEditHost={handleEditHostFromOverlay}
onRenameWorkspace={startWorkspaceRename}
@@ -724,6 +725,7 @@ function AppViewInner({ domains }: AppViewProps) {
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onReorderTabs={reorderWorkTabs}
onCopySession={copySessionWithCurrentShell}
onDuplicateSession={duplicateSessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onSplitSession={splitSessionWithCurrentShell}
onConnectToHost={handleConnectToHost}
+1
View File
@@ -180,6 +180,7 @@ export function TerminalHost() {
copySessionWithCurrentShell: handlers.copySessionWithCurrentShell,
copyWorkspaceWithCurrentShell: handlers.copyWorkspaceWithCurrentShell,
copySessionToNewWindowWithCurrentShell: handlers.copySessionToNewWindowWithCurrentShell,
duplicateSessionWithCurrentShell: handlers.duplicateSessionWithCurrentShell,
closeWorkspace: sessionActions?.closeWorkspace,
createWorkspaceFromSessions: sessionActions?.createWorkspaceFromSessions,
createWorkspaceFromTargets: handlers.createWorkspaceFromTargets,
+1
View File
@@ -630,6 +630,7 @@ export const enTerminalMessages: Messages = {
'tabs.logPrefix': 'Log:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copy Tab',
'tabs.duplicateSession': 'Duplicate Session',
'tabs.copyTabToNewWindow': 'Copy Tab to New Window',
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
'tabs.closeOthers': 'Close Others',
+1
View File
@@ -630,6 +630,7 @@ export const esTerminalMessages: Messages = {
'tabs.logPrefix': 'Registro:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copiar pestaña',
'tabs.duplicateSession': 'Duplicar sesión',
'tabs.copyTabToNewWindow': 'Copiar pestaña a una nueva ventana',
'tabs.copyTabToNewWindowFailed': 'No se pudo abrir la pestaña en una nueva ventana',
'tabs.closeOthers': 'Cerrar las demás',
+1
View File
@@ -645,6 +645,7 @@ export const ruTerminalMessages: Messages = {
'tabs.logPrefix': 'Журнал:',
'tabs.logLocal': 'Локальный',
'tabs.copyTab': 'Копировать вкладку',
'tabs.duplicateSession': 'Дублировать сессию',
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
'tabs.closeOthers': 'Закрыть остальные',
@@ -718,6 +718,7 @@ export const zhCNTerminalMessages: Messages = {
'tabs.logPrefix': '日志:',
'tabs.logLocal': '本地',
'tabs.copyTab': '复制标签页',
'tabs.duplicateSession': '复制会话',
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
'tabs.closeOthers': '关闭其他标签',
@@ -718,6 +718,7 @@ export const zhTWTerminalMessages: Messages = {
'tabs.logPrefix': '日誌:',
'tabs.logLocal': '本機',
'tabs.copyTab': '複製分頁',
'tabs.duplicateSession': '複製工作階段',
'tabs.copyTabToNewWindow': '複製分頁到新視窗',
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟分頁',
'tabs.closeOthers': '關閉其他分頁',
@@ -85,6 +85,50 @@ test("copy session clones reuse SSH sources and preserve serial config", () => {
assert.deepEqual(copied.serialConfig, { path: "/dev/tty.usbserial", baudRate: 115200 });
});
test("copy session clones open a fresh connection when reuseConnection is false", () => {
const fresh = createCopiedTerminalSessionClone(session(), { id: "copy-fresh", reuseConnection: false });
assert.equal(fresh.reuseConnectionFromSessionId, undefined);
// The explicit fresh flag must survive: without it the bridge treats the
// clone as eligible for general endpoint reuse and can still borrow the
// source's live authenticated transport.
assert.equal(fresh.requireFreshConnection, true);
// Local sources never had reuse to drop — behavior is unchanged.
assert.equal(
createCopiedTerminalSessionClone(session({ protocol: "local" }), { id: "copy-local-fresh", reuseConnection: false }).reuseConnectionFromSessionId,
undefined,
);
});
test("copy and split clones default to no fresh-connection requirement", () => {
assert.equal(createCopiedTerminalSessionClone(session(), { id: "copy-default" }).requireFreshConnection, undefined);
assert.equal(createSplitTerminalSessionClone(session(), { id: "split-default" }).requireFreshConnection, undefined);
assert.equal(
createCopiedTerminalSessionClone(session(), { id: "copy-reuse", reuseConnection: true }).requireFreshConnection,
undefined,
);
});
test("fresh SSH clones discard inherited directories even when a caller supplies one", () => {
for (const protocol of ["ssh", undefined] as const) {
const clone = createCopiedTerminalSessionClone(session({ protocol }), {
id: "fresh-no-cwd", reuseConnection: false, inheritedCwd: "/srv/old-target",
});
assert.equal(clone.pendingInitialCwd, undefined);
assert.equal(clone.requireFreshConnection, true);
}
const localClone = createCopiedTerminalSessionClone(session({ protocol: "local" }), {
id: "local-cwd", reuseConnection: false, inheritedCwd: "/home/alice/project",
});
assert.equal(localClone.localStartDir, "/home/alice/project");
});
test("copy session clones default to connection reuse when reuseConnection is unset", () => {
assert.equal(
createCopiedTerminalSessionClone(session(), { id: "copy-default" }).reuseConnectionFromSessionId,
"session-1",
);
});
test("split and copy session clones preserve local start directory", () => {
const source = session({
protocol: "local",
+17 -2
View File
@@ -18,6 +18,12 @@ type CloneSessionOptions = {
localShellType?: TerminalSession["shellType"];
workspaceId?: string;
inheritedCwd?: string;
/**
* When false, the clone never reuses the source's established SSH channel and
* instead opens a brand-new connection (fresh auth; e.g. a new bastion login).
* Defaults to true (reuse when possible).
*/
reuseConnection?: boolean;
};
function getClonedShellType(
@@ -36,7 +42,8 @@ function createTerminalSessionClone(
// pendingInitialCwd for telnet/serial/mosh/et would be dead, never-cleared
// state (those protocols don't track cwd, so it's never consumed or cleared).
const injectsInheritedCwd =
(session.protocol === "ssh" || session.protocol === undefined)
options.reuseConnection !== false
&& (session.protocol === "ssh" || session.protocol === undefined)
&& !session.moshEnabled
&& !session.etEnabled;
const clonedSession: TerminalSession = {
@@ -64,7 +71,15 @@ function createTerminalSessionClone(
fontSizeOverride: session.fontSizeOverride,
...(session.ephemeralHost ? { ephemeralHost: true } : {}),
...(injectsInheritedCwd && options.inheritedCwd ? { pendingInitialCwd: options.inheritedCwd } : {}),
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
// Clearing `reuseConnectionFromSessionId` only disables source-specific
// reuse; without an explicit fresh-connection flag the bridge would still
// be eligible for general endpoint/idle transport reuse (e.g. borrowing
// the source's live transport). Flag the clone so the starter sends
// `reuseTransport: false` and the session truly dials fresh.
...(options.reuseConnection === false ? { requireFreshConnection: true } : {}),
reuseConnectionFromSessionId: options.reuseConnection === false
? undefined
: (canReuseTerminalConnection(session) ? session.id : undefined),
};
if (options.workspaceId) {
+3
View File
@@ -1075,6 +1075,8 @@ export const useSessionState = ({
const copySession = useCallback((sessionId: string, options?: {
localShellType?: TerminalSession['shellType'];
inheritedCwd?: string;
/** When false, open a brand-new connection instead of reusing the source's SSH channel. */
reuseConnection?: boolean;
}) => {
// Pre-allocate the new id outside the updater so StrictMode's
// double-invocation of the functional updater doesn't mint two ids.
@@ -1090,6 +1092,7 @@ export const useSessionState = ({
id: newSessionId,
localShellType: options?.localShellType,
inheritedCwd: options?.inheritedCwd,
reuseConnection: options?.reuseConnection,
});
// Schedule the activeTab + tabOrder updates only when creation
+4
View File
@@ -297,6 +297,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
pendingScriptId,
pendingScript,
reuseConnectionFromSessionId,
requireFreshConnection = false,
attachExistingSession = false,
attachAuthorization,
onAttachClosePreparationChange,
@@ -1456,6 +1457,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
protocol: host.protocol,
shellType,
lastCwd,
requireFreshConnection,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
},
@@ -1467,6 +1469,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
host.protocol,
isNetworkDevice,
lastCwd,
requireFreshConnection,
restoreState,
restoreTerminalCwd,
shellType,
@@ -2340,6 +2343,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
resolvedChainHosts,
sessionId,
reuseConnectionFromSessionIdRef: reuseConnectionSourceRef,
requireFreshConnection,
reuseConnectionSourceAttemptedRef,
setConnectionReuseAttemptSourceId,
shouldUseFreshSshConnection: () => {
+2
View File
@@ -234,6 +234,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onReorderWorkspaceSessions,
onReorderTabs,
onCopySession,
onDuplicateSession,
onCopySessionToNewWindow,
onSplitSession,
onConnectToHost,
@@ -2279,6 +2280,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onReorderWorkspaceSessions,
onReorderTabs,
onCopySession,
onDuplicateSession,
onCopySessionToNewWindow,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
+4
View File
@@ -137,6 +137,7 @@ interface TopTabsProps {
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
onRenameSession: (sessionId: string) => void;
onCopySession: (sessionId: string) => void;
onDuplicateSession?: (sessionId: string) => void;
onCopySessionToNewWindow: (sessionId: string) => void;
onEditHost?: (host: Host) => void;
onRenameWorkspace: (workspaceId: string) => void;
@@ -188,6 +189,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onCloseSession,
onRenameSession,
onCopySession,
onDuplicateSession,
onCopySessionToNewWindow,
onEditHost,
onRenameWorkspace,
@@ -885,6 +887,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onCloseSession={onCloseSession}
onRenameSession={onRenameSession}
onCopySession={onCopySession}
onDuplicateSession={onDuplicateSession}
onCopySessionToNewWindow={onCopySessionToNewWindow}
onEditHost={onEditHost}
renderBulkCloseItems={renderBulkCloseItems}
@@ -1275,6 +1278,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
prev.draggingSessionId === next.draggingSessionId &&
prev.isMacClient === next.isMacClient &&
prev.onCopySession === next.onCopySession &&
prev.onDuplicateSession === next.onDuplicateSession &&
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
prev.onEditHost === next.onEditHost &&
prev.onAppendHostToWorkspace === next.onAppendHostToWorkspace &&
@@ -1,6 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import { resolveRestoreCwdIntent } from "../../domain/sessionRestore.ts";
import {
getInitialTerminalStatus,
@@ -11,6 +13,34 @@ import {
} from "./restoredSessionGate.ts";
import { setVaultInitialized } from "../../application/state/vaultInitStore.ts";
test("Terminal restore preparation honors fresh login while preserving ordinary and local cwd restore", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const start = source.indexOf("const prepareRestoredReconnect = useCallback");
const end = source.indexOf("// Set only once the inherited", start);
assert.ok(start >= 0 && end > start);
for (const protocol of ["ssh", undefined, "local"] as const) {
for (const requireFreshConnection of [false, true]) {
const restoreCwdIntentRef: { current: { command: string } | null } = { current: null };
runInNewContext(`${source.slice(start, end)}\nprepareRestoredReconnect();`, {
useCallback: (fn: () => void) => fn,
shouldSuppressHostStartupCommandOnReconnect,
resolveRestoreCwdIntent,
suppressHostStartupCommandRef: { current: false },
restoreCwdIntentRef,
restoreState: "restored-disconnected",
restoreTerminalCwd: true,
host: { protocol },
shellType: "posix",
lastCwd: "/srv/old-target",
isNetworkDevice: false,
requireFreshConnection,
});
if (requireFreshConnection && protocol !== "local") assert.equal(restoreCwdIntentRef.current, null);
else assert.equal(restoreCwdIntentRef.current?.command, "cd -- '/srv/old-target'");
}
}
});
test("restored disconnected sessions initialize as connecting", () => {
assert.equal(
getInitialTerminalStatus(),
@@ -487,6 +487,47 @@ test("startSSH requests a fresh transport for ordinary opens with connection aut
assert.deepEqual(reuseAttempts, ["source-session", undefined, undefined]);
});
test("startSSH requests a fresh transport for Duplicate Session clones", async () => {
const captured: Record<string, unknown>[] = [];
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
captured.push(options);
return `ssh-session-${captured.length}`;
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
// Without the marker an ordinary open stays eligible for general endpoint
// reuse; with it, every attempt (including reconnects) dials fresh instead
// of borrowing the source's live authenticated transport.
await createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
terminalBackend,
}) as never).startSSH(createTermStub() as never);
await createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
requireFreshConnection: true,
terminalBackend,
}) as never).startSSH(createTermStub() as never);
const duplicateStarters = createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
requireFreshConnection: true,
terminalBackend,
}) as never);
await duplicateStarters.startSSH(createTermStub() as never);
await duplicateStarters.startSSH(createTermStub() as never);
assert.equal(captured[0].reuseTransport, undefined);
assert.equal(captured[0].sourceSessionId, undefined);
assert.equal(captured[1].reuseTransport, false);
assert.equal(captured[2].reuseTransport, false);
});
test("startSSH commits an empty automation snapshot only after the backend session succeeds", async () => {
let resolveStart: ((sessionId: string) => void) | undefined;
let commitCount = 0;
@@ -706,8 +706,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
sourceSessionId,
// Connect-time automation must see the complete login sequence. An
// explicit Copy/Split keeps its source-session reuse contract, while
// an ordinary open bypasses endpoint/idle transport reuse.
reuseTransport: !sourceSessionId && (requiresFreshSshConnection || isFallbackAfterSourceReuse)
// an ordinary open bypasses endpoint/idle transport reuse. Duplicate
// Session clones must never borrow the source's live transport
// either, so they send an explicit `reuseTransport: false`.
reuseTransport: !sourceSessionId
&& (requiresFreshSshConnection || isFallbackAfterSourceReuse || ctx.requireFreshConnection === true)
? false
: undefined,
skipShellPidDiscovery: ctx.isNetworkDevice === true,
@@ -150,6 +150,10 @@ export type TerminalSessionStartersContext = {
// One-shot source session intent for Copy/Split. Consumed by the first SSH
// attempt so later reconnects do not skip the initial login sequence.
reuseConnectionFromSessionIdRef?: MutableRefObject<string | undefined>;
// Duplicate Session clones carry this marker for their whole lifetime: every
// SSH attempt must send `reuseTransport: false` so the bridge never borrows
// the source's live or any other pooled transport.
requireFreshConnection?: boolean;
// Persists across renderer auth retries after the one-shot source intent is
// consumed. Cleared only after a backend session starts successfully.
reuseConnectionSourceAttemptedRef?: MutableRefObject<boolean>;
+3
View File
@@ -152,6 +152,9 @@ export interface TerminalProps {
// source session whose authenticated connection should be reused for a new
// shell channel — skipping a second MFA prompt (issue #1204).
reuseConnectionFromSessionId?: string;
// Duplicate Session marker: never borrow a live/parked pooled transport —
// always dial a fresh connection (fresh auth).
requireFreshConnection?: boolean;
/**
* Attach to an already-running backend session (same PTY) instead of starting
* a new one. Used by the AI silent-session observe popup. Must not close the
+1
View File
@@ -47,6 +47,7 @@ export const terminalPropsAreEqual = (
&& prev.pendingScriptId === next.pendingScriptId
&& prev.pendingScript === next.pendingScript
&& prev.reuseConnectionFromSessionId === next.reuseConnectionFromSessionId
&& prev.requireFreshConnection === next.requireFreshConnection
&& prev.attachExistingSession === next.attachExistingSession
&& prev.serialConfig === next.serialConfig
&& prev.hotkeyScheme === next.hotkeyScheme
@@ -35,6 +35,7 @@ interface TerminalFocusSidebarProps {
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
onCloseSession: (sessionId: string) => void;
onCopySession?: (sessionId: string) => void;
onDuplicateSession?: (sessionId: string) => void;
onCopySessionToNewWindow?: (sessionId: string) => void;
onDetachSessionFromWorkspace?: (sessionId: string) => void;
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
@@ -69,6 +70,7 @@ type WorkspaceFocusSessionRowProps = {
onCancelRename: () => void;
onCloseSession: (sessionId: string) => void;
onCopySession?: (sessionId: string) => void;
onDuplicateSession?: (sessionId: string) => void;
onCopySessionToNewWindow?: (sessionId: string) => void;
onDetachSessionFromWorkspace?: (sessionId: string) => void;
isDragging: boolean;
@@ -95,6 +97,7 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
onCancelRename,
onCloseSession,
onCopySession,
onDuplicateSession,
onCopySessionToNewWindow,
onDetachSessionFromWorkspace,
isDragging,
@@ -218,6 +221,7 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
sessionId={session.id}
onCloseSession={onCloseSession}
onCopySession={onCopySession}
onDuplicateSession={onDuplicateSession}
onCopySessionToNewWindow={onCopySessionToNewWindow}
onDetachSession={onDetachSessionFromWorkspace}
onReconnectSession={terminalReconnectRegistry.request}
@@ -243,6 +247,7 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
&& prev.onCancelRename === next.onCancelRename
&& prev.onCloseSession === next.onCloseSession
&& prev.onCopySession === next.onCopySession
&& prev.onDuplicateSession === next.onDuplicateSession
&& prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow
&& prev.onDetachSessionFromWorkspace === next.onDetachSessionFromWorkspace
&& prev.onDragStart === next.onDragStart
@@ -262,6 +267,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
onAppendHostToWorkspace,
onCloseSession,
onCopySession,
onDuplicateSession,
onCopySessionToNewWindow,
onDetachSessionFromWorkspace,
onSetWorkspaceFocusedSession,
@@ -652,6 +658,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
onCancelRename={handleLocalCancelRename}
onCloseSession={onCloseSession}
onCopySession={onCopySession}
onDuplicateSession={onDuplicateSession}
onCopySessionToNewWindow={onCopySessionToNewWindow}
onDetachSessionFromWorkspace={onDetachSessionFromWorkspace}
isDragging={focusSidebarDragSessionId === session.id}
@@ -685,6 +692,7 @@ function terminalFocusSidebarPropsEqual(
if (prev.onSubmitSessionRename !== next.onSubmitSessionRename) return false;
if (prev.onCloseSession !== next.onCloseSession) return false;
if (prev.onCopySession !== next.onCopySession) return false;
if (prev.onDuplicateSession !== next.onDuplicateSession) return false;
if (prev.onCopySessionToNewWindow !== next.onCopySessionToNewWindow) return false;
if (prev.onDetachSessionFromWorkspace !== next.onDetachSessionFromWorkspace) return false;
if (prev.resolvedPreviewTheme !== next.resolvedPreviewTheme) return false;
@@ -18,6 +18,7 @@ function TerminalLayerFocusSidebarSectionInner({ ctx }: { ctx: FocusSidebarConte
onAppendHostToWorkspace={ctx.onAppendHostToWorkspace}
onCloseSession={ctx.handleCloseSession}
onCopySession={ctx.onCopySession}
onDuplicateSession={ctx.onDuplicateSession}
onCopySessionToNewWindow={ctx.onCopySessionToNewWindow}
onDetachSessionFromWorkspace={ctx.onRemoveSessionFromWorkspace}
onSetWorkspaceFocusedSession={ctx.onSetWorkspaceFocusedSession}
@@ -694,6 +694,7 @@ export interface TerminalLayerProps {
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
onCopySession?: (sessionId: string) => void;
onDuplicateSession?: (sessionId: string) => void;
onCopySessionToNewWindow?: (sessionId: string) => void;
onRemoveSessionFromWorkspace?: (
sessionId: string,
@@ -1567,6 +1568,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
pendingScriptId={session.pendingScriptId}
pendingScript={session.pendingScript}
reuseConnectionFromSessionId={session.reuseConnectionFromSessionId}
requireFreshConnection={session.requireFreshConnection}
serialConfig={session.serialConfig}
hotkeyScheme={hotkeyScheme}
disableTerminalFontZoom={disableTerminalFontZoom}
@@ -540,6 +540,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
onReorderWorkspaceSessions: s.onReorderWorkspaceSessions,
onReorderTabs: s.onReorderTabs,
onCopySession: s.onCopySession,
onDuplicateSession: s.onDuplicateSession,
onCopySessionToNewWindow: s.onCopySessionToNewWindow,
onUpdateSessionRestoreCwd: s.onUpdateSessionRestoreCwd,
onUpdateSessionDynamicTitle: s.onUpdateSessionDynamicTitle,
@@ -498,6 +498,7 @@ export function terminalLayerFocusSidebarPropsEqual(prev: Ctx, next: Ctx): boole
&& eq(prev, next, 'onAppendHostToWorkspace')
&& eq(prev, next, 'handleCloseSession')
&& eq(prev, next, 'onCopySession')
&& eq(prev, next, 'onDuplicateSession')
&& eq(prev, next, 'onCopySessionToNewWindow')
&& eq(prev, next, 'onRemoveSessionFromWorkspace')
&& eq(prev, next, 'onSetWorkspaceFocusedSession')
@@ -20,3 +20,9 @@ test('session tab menu exposes optional edit-host when a vault host is present',
assert.match(source, /editHost && onEditHost/);
assert.match(source, /terminal\.layer\.hostTree\.editHost/);
});
test('session tab menu offers duplicate-session alongside copy-tab', () => {
assert.match(source, /onDuplicateSession\?: \(sessionId: string\) => void/);
assert.match(source, /onDuplicateSession &&/);
assert.match(source, /tabs\.duplicateSession/);
});
@@ -10,6 +10,8 @@ interface SessionTabContextMenuContentProps {
sessionId: string;
onCloseSession: (sessionId: string) => void;
onCopySession?: (sessionId: string) => void;
/** Duplicate the session with a brand-new connection (fresh auth, e.g. a new bastion login). */
onDuplicateSession?: (sessionId: string) => void;
onCopySessionToNewWindow?: (sessionId: string) => void;
onDetachSession?: (sessionId: string) => void;
onReconnectSession: (sessionId: string) => void;
@@ -27,6 +29,7 @@ export function SessionTabContextMenuContent({
sessionId,
onCloseSession,
onCopySession,
onDuplicateSession,
onCopySessionToNewWindow,
onDetachSession,
onReconnectSession,
@@ -59,6 +62,11 @@ export function SessionTabContextMenuContent({
{t('tabs.copyTab')}
</ContextMenuItem>
)}
{onDuplicateSession && (
<ContextMenuItem onClick={() => onDuplicateSession(sessionId)}>
{t('tabs.duplicateSession')}
</ContextMenuItem>
)}
{onCopySessionToNewWindow && (
<ContextMenuItem onClick={() => onCopySessionToNewWindow(sessionId)}>
{t('tabs.copyTabToNewWindow')}
+3
View File
@@ -708,6 +708,7 @@ interface SessionTopTabProps {
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
onRenameSession: (sessionId: string) => void;
onCopySession: (sessionId: string) => void;
onDuplicateSession?: (sessionId: string) => void;
onCopySessionToNewWindow: (sessionId: string) => void;
onEditHost?: (host: Host) => void;
renderBulkCloseItems: RenderBulkCloseItems;
@@ -733,6 +734,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
onCloseSession,
onRenameSession,
onCopySession,
onDuplicateSession,
onCopySessionToNewWindow,
onEditHost,
renderBulkCloseItems,
@@ -868,6 +870,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
sessionId={session.id}
onCloseSession={onCloseSession}
onCopySession={onCopySession}
onDuplicateSession={onDuplicateSession}
onCopySessionToNewWindow={onCopySessionToNewWindow}
onReconnectSession={terminalReconnectRegistry.request}
sessionStatus={session.status}
+5
View File
@@ -627,6 +627,11 @@ export interface TerminalSession {
// falls back to a fresh connection — so this also applies on reconnect: a
// reconnect reuses the source again if still connected, else dials fresh.
reuseConnectionFromSessionId?: string;
// Marker for "Duplicate Session" clones: never multiplex onto any live,
// parked, or in-flight pooled transport (including the source's own
// connection) — always dial a brand-new connection with fresh auth. The
// starter turns this into `reuseTransport: false` on every attempt.
requireFreshConnection?: boolean;
// Per-pane font size override (workspace splits only; not persisted to vault hosts).
fontSize?: number;
fontSizeOverride?: boolean;
+45
View File
@@ -41,6 +41,36 @@ test("buildSessionRestorePayload stores restored sessions as disconnected and dr
assert.equal(payload.sessions[0].restoreState, "restored-disconnected");
});
test("restore keeps the duplicate-session fresh-connection marker and sanitizes it on load", () => {
const payload = buildSessionRestorePayload({
sessions: [{ ...session("s1"), requireFreshConnection: true }],
workspaces: [],
tabOrder: ["s1"],
activeTabId: "s1",
now: 123,
});
assert.equal(payload.sessions[0].requireFreshConnection, true);
const restored = sanitizeSessionRestorePayload({
version: 1,
savedAt: 123,
activeTabId: "s1",
tabOrder: ["s1"],
sessions: [payload.sessions[0] as unknown as Record<string, unknown>],
});
assert.equal(restored.sessions[0].requireFreshConnection, true);
const corrupted = sanitizeSessionRestorePayload({
version: 1,
savedAt: 123,
activeTabId: "s1",
tabOrder: ["s1"],
sessions: [{ ...payload.sessions[0], requireFreshConnection: "yes" } as unknown as Record<string, unknown>],
});
assert.equal(corrupted.sessions[0].requireFreshConnection, undefined);
});
test("buildSessionRestorePayload excludes ephemeral-host sessions and their tabs", () => {
const payload = buildSessionRestorePayload({
sessions: [
@@ -641,6 +671,21 @@ test("shouldAttemptRestoreCwd allows restored local and unix ssh sessions only w
}), false);
});
test("restored fresh SSH duplicates retain metadata without injecting the previous target directory", () => {
for (const protocol of ["ssh", undefined, "local"] as const) {
const payload = buildSessionRestorePayload({
sessions: [{ ...session("s1"), protocol, requireFreshConnection: true, lastCwd: "/srv/old-target" }],
workspaces: [], tabOrder: ["s1"], activeTabId: "s1",
});
const restored = sanitizeSessionRestorePayload(JSON.parse(JSON.stringify(payload))).sessions[0];
assert.equal(restored.requireFreshConnection, true);
assert.equal(restored.lastCwd, "/srv/old-target");
const intent = resolveRestoreCwdIntent({ enabled: true, session: restored, isNetworkDevice: false });
if (protocol === "local") assert.equal(intent?.command, "cd -- '/srv/old-target'");
else assert.equal(intent, null, "a fresh bastion login must await target selection");
}
});
test("shouldAttemptRestoreCwd skips ineligible protocols and network devices", () => {
const base = { ...session("s1"), status: "disconnected" as const, restoreState: "restored-disconnected" as const, lastCwd: "/srv/app" };
+13
View File
@@ -28,6 +28,11 @@ export type RestoredTerminalSession = {
fontSizeOverride?: boolean;
customName?: string;
lastCwd?: string;
/**
* Duplicate-session marker: the restored clone must dial a fresh connection
* (fresh auth) instead of borrowing a pooled transport for this endpoint.
*/
requireFreshConnection?: boolean;
restoreState: "restored-disconnected";
};
@@ -151,6 +156,7 @@ const restoreSession = (session: TerminalSession): RestoredTerminalSession => {
...(session.fontSizeOverride !== undefined ? { fontSizeOverride: session.fontSizeOverride } : {}),
...(session.customName ? { customName: session.customName } : {}),
...(session.lastCwd ? { lastCwd: session.lastCwd } : {}),
...(session.requireFreshConnection ? { requireFreshConnection: true } : {}),
status: "disconnected",
restoreState: "restored-disconnected",
};
@@ -193,6 +199,9 @@ const restoreSessionFromUnknown = (value: unknown): RestoredTerminalSession | nu
...(readBoolean(value, "fontSizeOverride") !== undefined ? { fontSizeOverride: readBoolean(value, "fontSizeOverride") } : {}),
...(readString(value, "customName") ? { customName: readString(value, "customName") } : {}),
...(readString(value, "lastCwd") ? { lastCwd: readString(value, "lastCwd") } : {}),
// Sanitized to a literal `true`: only an active duplicate-session marker is
// persisted, so a corrupted value (or `false`/absent) must not enable it.
...(readBoolean(value, "requireFreshConnection") === true ? { requireFreshConnection: true } : {}),
status: "disconnected",
restoreState: "restored-disconnected",
};
@@ -209,6 +218,7 @@ type RestoreCwdSession = Pick<TerminalSession, "status"> & {
lastCwd?: string;
moshEnabled?: boolean;
etEnabled?: boolean;
requireFreshConnection?: boolean;
};
const isRestoreCwdPathEligible = (cwd: string | undefined): cwd is string => {
@@ -230,6 +240,9 @@ export function shouldAttemptRestoreCwd({
}): boolean {
if (!enabled) return false;
if (!isRestoredDisconnectedSession(session)) return false;
// Restored duplicates still perform a fresh remote login, which may stop
// at a bastion selector rather than the previous target's shell.
if (session.requireFreshConnection === true && session.protocol !== "local") return false;
return isCwdInjectionEligible({ session: { ...session, cwd: session.lastCwd }, isNetworkDevice });
}
+2
View File
@@ -41,6 +41,7 @@ export type TerminalPaneSessionFields = Pick<
| 'pendingScriptId'
| 'pendingScript'
| 'reuseConnectionFromSessionId'
| 'requireFreshConnection'
| 'autoOpenSidePanel'
>;
@@ -79,6 +80,7 @@ function paneFieldEqual(
&& a.pendingScriptId === b.pendingScriptId
&& a.pendingScript === b.pendingScript
&& a.reuseConnectionFromSessionId === b.reuseConnectionFromSessionId
&& Boolean(a.requireFreshConnection) === Boolean(b.requireFreshConnection)
&& a.autoOpenSidePanel === b.autoOpenSidePanel;
}