Files
Netcatty/domain/sessionRestore.ts
T
陈大猫 c6baebd514 Support password in ssh:// deep links with ephemeral connect (#1963)
* Support password in ssh deep links with ephemeral connect

ssh://user:password@host URLs now connect directly using the URL
credentials without saving the host to the vault, covering bastion
one-time password flows. Password-less links keep the existing
match-or-prefill behavior.

Fixes #1890

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep ephemeral deep-link hosts out of vault update paths

Terminal-layer host updates (e.g. the SFTP follow-CWD toggle) map over
the merged terminal host list, which could persist an ephemeral host
and its plaintext password into the vault. Route terminal-layer host
updates through a splitter that applies ephemeral entries to in-memory
state only and forwards just the vault hosts to updateHosts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Exclude ephemeral deep-link sessions from session restore

Sessions backed by an in-memory ephemeral host cannot survive a
relaunch (the one-time password only lives in memory), so persisting
them would leak the ephemeral host metadata into restore storage and
restore a tab that can never reconnect. Mark ephemeral hosts and their
sessions, and filter them out of the session-restore payload.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Treat ephemeral hosts as unsaved in appearance classification

The terminal theme/font panel and pane appearance resolution decided
"saved vault host" via hostMap membership. Ephemeral deep-link hosts
are now in that map, so theme/font changes were routed to the
persisted-host updater and silently dropped. Classify via
isSavedVaultHost (ephemeral flag aware) so those changes fall back to
the session/global setting path as before.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reuse saved host settings for password deep links and keep ephemeral marker on clones

Password deep links now run saved-host matching first: a uniquely
matched vault host contributes its non-credential settings (proxy,
jump chain, charset, group, ...) to the ephemeral host while the URL
credentials override vault identities and key references. Session
clones (split/copy/copy-to-new-window) now carry the ephemeralHost
marker so cloned tabs stay excluded from session restore.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Block group credential inheritance on password deep-link ephemeral hosts

buildSshDeepLinkEphemeralHostFromSaved now takes the group-resolved
effective host and clears group/identityFilePaths in addition to
identity references, so a later resolveEffectiveHost pass cannot
re-inherit group default credentials (identity, key, password) over
the one-time URL password. Non-credential group defaults (proxy,
jump chain, charset, ...) are preserved via the effective host.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Route ephemeral-host font zoom through the per-session font-size path

Ctrl+zoom on a non-workspace ephemeral deep-link session hit the
saved-host update path, where the vault updater silently dropped the
change (ephemeral ids are not in the persisted hosts array). Zoom and
the theme panel font-size slider/reset now store the size on the
session (rendered via applySessionFontSizeToHost) for ephemeral
hosts, matching the workspace-pane behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix ephemeral session font zoom routing

* Prevent ephemeral deep-link sudo autofill

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 22:56:32 +08:00

406 lines
16 KiB
TypeScript

import type { SerialConfig, TerminalSession, Workspace, WorkspaceNode } from "./models";
export const SESSION_RESTORE_VERSION = 1 as const;
export type RestoredTerminalSession = {
id: string;
hostId: string;
hostLabel: string;
hostname: string;
username: string;
status: "disconnected";
workspaceId?: string;
protocol?: TerminalSession["protocol"];
port?: number;
moshEnabled?: boolean;
etEnabled?: boolean;
shellType?: TerminalSession["shellType"];
charset?: string;
serialConfig?: SerialConfig;
localShell?: string;
localShellArgs?: string[];
localShellName?: string;
localShellIcon?: string;
localStartDir?: string;
fontSize?: number;
fontSizeOverride?: boolean;
customName?: string;
lastCwd?: string;
restoreState: "restored-disconnected";
};
export type SessionRestorePayload = {
version: typeof SESSION_RESTORE_VERSION;
savedAt: number;
activeTabId: string;
tabOrder: string[];
sessions: RestoredTerminalSession[];
workspaces: Workspace[];
};
export type BuildSessionRestorePayloadInput = {
sessions: TerminalSession[];
workspaces: Workspace[];
tabOrder: string[];
activeTabId: string;
now?: number;
};
const isRecord = (value: unknown): value is Record<string, unknown> => (
Boolean(value) && typeof value === "object"
);
const uniqueStrings = (values: readonly string[]): string[] => {
const seen = new Set<string>();
const next: string[] = [];
for (const value of values) {
if (!value || seen.has(value)) continue;
seen.add(value);
next.push(value);
}
return next;
};
const readString = (record: Record<string, unknown>, key: string): string | undefined => {
const value = record[key];
return typeof value === "string" && value.length > 0 ? value : undefined;
};
const readStringAllowEmpty = (record: Record<string, unknown>, key: string): string | undefined => {
const value = record[key];
return typeof value === "string" ? value : undefined;
};
const readBoolean = (record: Record<string, unknown>, key: string): boolean | undefined => {
const value = record[key];
return typeof value === "boolean" ? value : undefined;
};
const readNumber = (record: Record<string, unknown>, key: string): number | undefined => {
const value = record[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
};
const isOneOf = <T extends string | number>(
value: unknown,
allowed: readonly T[],
): value is T => allowed.includes(value as T);
const sanitizeProtocol = (value: unknown): TerminalSession["protocol"] | undefined => (
isOneOf(value, ["ssh", "telnet", "local", "serial"] as const) ? value : undefined
);
const sanitizeShellType = (value: unknown): TerminalSession["shellType"] | undefined => (
isOneOf(value, ["posix", "fish", "powershell", "cmd", "unknown"] as const) ? value : undefined
);
const sanitizeSerialConfig = (value: unknown): SerialConfig | undefined => {
if (!isRecord(value)) return undefined;
const path = readString(value, "path");
const baudRate = readNumber(value, "baudRate");
if (!path || baudRate === undefined) return undefined;
return {
path,
baudRate,
...(isOneOf(value.dataBits, [5, 6, 7, 8] as const) ? { dataBits: value.dataBits } : {}),
...(isOneOf(value.stopBits, [1, 1.5, 2] as const) ? { stopBits: value.stopBits } : {}),
...(isOneOf(value.parity, ["none", "even", "odd", "mark", "space"] as const) ? { parity: value.parity } : {}),
...(isOneOf(value.flowControl, ["none", "xon/xoff", "rts/cts"] as const) ? { flowControl: value.flowControl } : {}),
...(readBoolean(value, "localEcho") !== undefined ? { localEcho: readBoolean(value, "localEcho") } : {}),
...(readBoolean(value, "lineMode") !== undefined ? { lineMode: readBoolean(value, "lineMode") } : {}),
};
};
const restoreSession = (session: TerminalSession): RestoredTerminalSession => {
const serialConfig = sanitizeSerialConfig(session.serialConfig);
const protocol = sanitizeProtocol(session.protocol);
const shellType = sanitizeShellType(session.shellType);
return {
id: session.id,
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
...(session.workspaceId ? { workspaceId: session.workspaceId } : {}),
...(protocol ? { protocol } : {}),
...(session.port !== undefined ? { port: session.port } : {}),
...(session.moshEnabled !== undefined ? { moshEnabled: session.moshEnabled } : {}),
...(session.etEnabled !== undefined ? { etEnabled: session.etEnabled } : {}),
...(shellType ? { shellType } : {}),
...(session.charset ? { charset: session.charset } : {}),
...(serialConfig ? { serialConfig } : {}),
...(session.localShell ? { localShell: session.localShell } : {}),
...(session.localShellArgs ? { localShellArgs: [...session.localShellArgs] } : {}),
...(session.localShellName ? { localShellName: session.localShellName } : {}),
...(session.localShellIcon ? { localShellIcon: session.localShellIcon } : {}),
...(session.localStartDir ? { localStartDir: session.localStartDir } : {}),
...(session.fontSize !== undefined ? { fontSize: session.fontSize } : {}),
...(session.fontSizeOverride !== undefined ? { fontSizeOverride: session.fontSizeOverride } : {}),
...(session.customName ? { customName: session.customName } : {}),
...(session.lastCwd ? { lastCwd: session.lastCwd } : {}),
status: "disconnected",
restoreState: "restored-disconnected",
};
};
const restoreSessionFromUnknown = (value: unknown): RestoredTerminalSession | null => {
if (!isRecord(value)) return null;
const id = readString(value, "id");
const hostId = readString(value, "hostId");
const hostLabel = readString(value, "hostLabel");
const hostname = readString(value, "hostname");
const username = readStringAllowEmpty(value, "username");
if (!id || !hostId || !hostLabel || !hostname || username === undefined) return null;
const serialConfig = sanitizeSerialConfig(value.serialConfig);
const protocol = sanitizeProtocol(value.protocol);
const shellType = sanitizeShellType(value.shellType);
return {
id,
hostId,
hostLabel,
hostname,
username,
...(readString(value, "workspaceId") ? { workspaceId: readString(value, "workspaceId") } : {}),
...(protocol ? { protocol } : {}),
...(readNumber(value, "port") !== undefined ? { port: readNumber(value, "port") } : {}),
...(readBoolean(value, "moshEnabled") !== undefined ? { moshEnabled: readBoolean(value, "moshEnabled") } : {}),
...(readBoolean(value, "etEnabled") !== undefined ? { etEnabled: readBoolean(value, "etEnabled") } : {}),
...(shellType ? { shellType } : {}),
...(readString(value, "charset") ? { charset: readString(value, "charset") } : {}),
...(serialConfig ? { serialConfig } : {}),
...(readString(value, "localShell") ? { localShell: readString(value, "localShell") } : {}),
...(Array.isArray(value.localShellArgs) ? { localShellArgs: value.localShellArgs.filter((arg): arg is string => typeof arg === "string") } : {}),
...(readString(value, "localShellName") ? { localShellName: readString(value, "localShellName") } : {}),
...(readString(value, "localShellIcon") ? { localShellIcon: readString(value, "localShellIcon") } : {}),
...(readString(value, "localStartDir") ? { localStartDir: readString(value, "localStartDir") } : {}),
...(readNumber(value, "fontSize") !== undefined ? { fontSize: readNumber(value, "fontSize") } : {}),
...(readBoolean(value, "fontSizeOverride") !== undefined ? { fontSizeOverride: readBoolean(value, "fontSizeOverride") } : {}),
...(readString(value, "customName") ? { customName: readString(value, "customName") } : {}),
...(readString(value, "lastCwd") ? { lastCwd: readString(value, "lastCwd") } : {}),
status: "disconnected",
restoreState: "restored-disconnected",
};
};
export const isRestoredDisconnectedSession = (
session: Pick<TerminalSession, "status"> & { restoreState?: string },
): boolean => session.status === "disconnected" && session.restoreState === "restored-disconnected";
type RestoreCwdSession = Pick<TerminalSession, "status"> & {
restoreState?: string;
protocol?: TerminalSession["protocol"];
shellType?: TerminalSession["shellType"];
lastCwd?: string;
moshEnabled?: boolean;
etEnabled?: boolean;
};
const isRestoreCwdPathEligible = (cwd: string | undefined): cwd is string => {
if (!cwd) return false;
const trimmed = cwd.trim();
if (!trimmed) return false;
if (/^[A-Za-z]:[\\/]/.test(trimmed)) return false;
return trimmed.startsWith("/") || trimmed === "~" || trimmed.startsWith("~/");
};
export function shouldAttemptRestoreCwd({
enabled,
session,
isNetworkDevice,
}: {
enabled: boolean;
session: RestoreCwdSession;
isNetworkDevice: boolean;
}): boolean {
if (!enabled || isNetworkDevice) return false;
if (!isRestoredDisconnectedSession(session)) return false;
if (!isRestoreCwdPathEligible(session.lastCwd)) return false;
if (session.moshEnabled || session.etEnabled) return false;
const protocol = session.protocol ?? "ssh";
if (protocol === "local" && (session.shellType === "powershell" || session.shellType === "cmd")) {
return false;
}
return protocol === "ssh" || protocol === "local" || protocol === undefined;
}
export function quoteRestoreCwdForShell(cwd: string): string {
return `'${cwd.replace(/'/g, "'\\''")}'`;
}
function quoteRestoreCwdArgument(cwd: string): string {
if (cwd === "~") return "~";
if (cwd.startsWith("~/")) {
const suffix = cwd.slice(2);
return suffix ? `~/${quoteRestoreCwdForShell(suffix)}` : "~";
}
return quoteRestoreCwdForShell(cwd);
}
export function resolveRestoreCwdIntent(options: {
enabled: boolean;
session: RestoreCwdSession;
isNetworkDevice: boolean;
}): { cwd: string; command: string } | null {
if (!shouldAttemptRestoreCwd(options)) return null;
const cwd = options.session.lastCwd!.trim();
return {
cwd,
command: `cd -- ${quoteRestoreCwdArgument(cwd)}`,
};
}
const pruneNode = (node: unknown, validSessionIds: ReadonlySet<string>): WorkspaceNode | null => {
if (!isRecord(node)) return null;
if (node.type === "pane") {
if (typeof node.id !== "string" || typeof node.sessionId !== "string") return null;
return validSessionIds.has(node.sessionId)
? { id: node.id, type: "pane", sessionId: node.sessionId }
: null;
}
if (node.type !== "split" || typeof node.id !== "string" || (node.direction !== "horizontal" && node.direction !== "vertical") || !Array.isArray(node.children)) {
return null;
}
const restoredChildren = node.children
.map((child, originalIndex) => ({
child: pruneNode(child, validSessionIds),
originalIndex,
}))
.filter((entry): entry is { child: WorkspaceNode; originalIndex: number } => entry.child !== null);
const children = restoredChildren.map((entry) => entry.child);
if (children.length === 0) return null;
if (children.length === 1) return children[0];
const rawSizes = Array.isArray(node.sizes) ? node.sizes : undefined;
const canReuseSizes = rawSizes?.length === node.children.length
&& rawSizes.every((size) => typeof size === "number" && Number.isFinite(size) && size > 0);
const nextSizes = canReuseSizes
? restoredChildren.map(({ originalIndex }) => rawSizes[originalIndex])
: children.map(() => 1 / children.length);
const total = nextSizes.reduce((sum, size) => sum + size, 0);
return {
id: node.id,
type: "split",
direction: node.direction,
children,
sizes: total > 0 ? nextSizes.map((size) => size / total) : children.map(() => 1 / children.length),
};
};
const collectNodeSessionIds = (node: WorkspaceNode): string[] => {
if (node.type === "pane") return [node.sessionId];
return node.children.flatMap(collectNodeSessionIds);
};
const restoreWorkspace = (workspace: Workspace, root: WorkspaceNode, sessionIds: readonly string[]): Workspace => {
const sessionIdSet = new Set(sessionIds);
return {
id: workspace.id,
title: workspace.title,
root,
...(workspace.viewMode === "focus" || workspace.viewMode === "split" ? { viewMode: workspace.viewMode } : {}),
focusedSessionId: workspace.focusedSessionId && sessionIdSet.has(workspace.focusedSessionId)
? workspace.focusedSessionId
: sessionIds[0],
focusSessionOrder: uniqueStrings(workspace.focusSessionOrder ?? []).filter((id) => sessionIdSet.has(id)),
...(workspace.snippetId ? { snippetId: workspace.snippetId } : {}),
};
};
export function resolveRestoredActiveTabId(
activeTabId: string,
tabOrder: readonly string[],
sessions: readonly Pick<TerminalSession, "id" | "workspaceId">[],
workspaces: readonly Pick<Workspace, "id">[],
): string {
const valid = new Set([
"vault",
...sessions.filter((session) => !session.workspaceId).map((session) => session.id),
...workspaces.map((workspace) => workspace.id),
]);
if (valid.has(activeTabId)) return activeTabId;
const fallback = tabOrder.find((id) => valid.has(id));
return fallback ?? "vault";
}
const isWorkspaceRecord = (value: unknown): value is Workspace & Record<string, unknown> => (
isRecord(value) && typeof value.id === "string" && Boolean(value.id) && "root" in value
);
export function sanitizeSessionRestorePayload(payload: unknown): SessionRestorePayload {
const record = isRecord(payload) ? payload : {};
const rawSessions = Array.isArray(record.sessions) ? record.sessions : [];
const rawWorkspaces = Array.isArray(record.workspaces) ? record.workspaces : [];
const sessions = uniqueStrings(
rawSessions
.map((session) => isRecord(session) ? readString(session, "id") ?? "" : "")
)
.map((id) => rawSessions.find((session) => isRecord(session) && session.id === id))
.map(restoreSessionFromUnknown)
.filter((session): session is RestoredTerminalSession => session !== null);
const workspaces: Workspace[] = [];
const workspaceRootSessionIds = new Map<string, Set<string>>();
for (const workspace of rawWorkspaces) {
if (!isWorkspaceRecord(workspace)) continue;
const workspaceSessionIds = new Set(
sessions
.filter((session) => session.workspaceId === workspace.id)
.map((session) => session.id),
);
const root = pruneNode(workspace.root, workspaceSessionIds);
if (!root) continue;
const sessionIds = collectNodeSessionIds(root);
workspaceRootSessionIds.set(workspace.id, new Set(sessionIds));
workspaces.push(restoreWorkspace(workspace, root, sessionIds));
}
const validWorkspaceIds = new Set(workspaces.map((workspace) => workspace.id));
const sanitizedSessions = sessions.filter((session) => (
!session.workspaceId
|| (
validWorkspaceIds.has(session.workspaceId)
&& (workspaceRootSessionIds.get(session.workspaceId)?.has(session.id) ?? false)
)
));
const validTabIds = new Set([
...sanitizedSessions.filter((session) => !session.workspaceId).map((session) => session.id),
...workspaces.map((workspace) => workspace.id),
]);
const tabOrder = uniqueStrings(Array.isArray(record.tabOrder) ? record.tabOrder.filter((id): id is string => typeof id === "string") : [])
.filter((id) => validTabIds.has(id));
const activeTabId = resolveRestoredActiveTabId(
typeof record.activeTabId === "string" ? record.activeTabId : "vault",
tabOrder,
sanitizedSessions,
workspaces,
);
return {
version: SESSION_RESTORE_VERSION,
savedAt: Number.isFinite(record.savedAt) ? record.savedAt as number : Date.now(),
activeTabId,
tabOrder,
sessions: sanitizedSessions,
workspaces,
};
}
export function buildSessionRestorePayload(input: BuildSessionRestorePayloadInput): SessionRestorePayload {
return sanitizeSessionRestorePayload({
version: SESSION_RESTORE_VERSION,
savedAt: input.now ?? Date.now(),
activeTabId: input.activeTabId,
tabOrder: input.tabOrder,
// Ephemeral-host sessions (password deep links) cannot be restored: their
// in-memory credentials do not survive a relaunch, and persisting them
// would leak the supposedly ephemeral host metadata into restore storage.
sessions: input.sessions.filter((session) => !session.ephemeralHost).map(restoreSession),
workspaces: input.workspaces,
});
}