Files
NetMesh/domain/sessionRestore.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

492 lines
20 KiB
TypeScript

import type { PluginConnectionConfig, SerialConfig, TerminalSession, Workspace, WorkspaceNode } from "./models";
import { isPluginHostProtocol, sanitizePluginConnection } from "./pluginConnection";
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"];
pluginConnection?: PluginConnectionConfig;
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;
/**
* 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";
};
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 => {
if (isOneOf(value, ["ssh", "telnet", "mosh", "et", "local", "serial"] as const)) return value;
return typeof value === "string" && isPluginHostProtocol(value) ? 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") } : {}),
// A missing value identifies a session saved before serial-specific
// Backspace snapshots existed. Keep it missing so a saved-host session can
// still pick up its legacy host or group Ctrl+H setting. New sessions
// always persist an explicit "default" or "ctrl-h" value.
...(isOneOf(value.backspaceBehavior, ["default", "ctrl-h"] as const)
? { backspaceBehavior: value.backspaceBehavior }
: {}),
};
};
const restoreSession = (session: TerminalSession): RestoredTerminalSession => {
const serialConfig = sanitizeSerialConfig(session.serialConfig);
const protocol = sanitizeProtocol(session.protocol);
const pluginConnection = sanitizePluginConnection(session.pluginConnection, 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 } : {}),
...(pluginConnection ? { pluginConnection } : {}),
...(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 } : {}),
...(session.requireFreshConnection ? { requireFreshConnection: true } : {}),
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 pluginConnection = sanitizePluginConnection(value.pluginConnection, protocol);
const shellType = sanitizeShellType(value.shellType);
return {
id,
hostId,
hostLabel,
hostname,
username,
...(readString(value, "workspaceId") ? { workspaceId: readString(value, "workspaceId") } : {}),
...(protocol ? { protocol } : {}),
...(pluginConnection ? { pluginConnection } : {}),
...(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") } : {}),
// 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",
};
};
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;
requireFreshConnection?: 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) 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 });
}
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);
}
/** True when a path contains C0/DEL bytes that interactive readline would act on. */
function pathHasInteractivePtyControlBytes(path: string): boolean {
for (let i = 0; i < path.length; i++) {
const code = path.charCodeAt(i);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
/** Build a user-initiated `cd` for an absolute/home path (SFTP -> terminal). */
export function resolveInteractiveTerminalCdIntent(
cwd: string | null | undefined,
): { cwd: string; command: string } | null {
// Eligibility uses a normalized view; keep the exact path for quoting so
// trailing whitespace in POSIX names is preserved.
if (!isRestoreCwdPathEligible(cwd)) return null;
// Shell quoting does not protect tab/ESC/Ctrl-U while readline processes keystrokes.
if (pathHasInteractivePtyControlBytes(cwd)) return null;
return { cwd, command: `cd -- ${quoteRestoreCwdArgument(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)}`,
};
}
export function isCwdInjectionEligible({
session,
isNetworkDevice,
}: {
session: RestoreCwdSession & { cwd?: string };
isNetworkDevice: boolean;
}): boolean {
if (isNetworkDevice) return false;
if (!isRestoreCwdPathEligible(session.cwd)) 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 resolveInheritedCwdIntent(options: {
session: Pick<TerminalSession, "protocol" | "shellType" | "moshEnabled" | "etEnabled"> & { cwd?: string };
isNetworkDevice: boolean;
}): { cwd: string; command: string } | null {
if (!isCwdInjectionEligible({
session: {
status: "connecting",
protocol: options.session.protocol,
shellType: options.session.shellType,
moshEnabled: options.session.moshEnabled,
etEnabled: options.session.etEnabled,
cwd: options.session.cwd,
},
isNetworkDevice: options.isNetworkDevice,
})) {
return null;
}
const cwd = options.session.cwd!.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 } : {}),
// Preserve explicit-title state across restore; the boolean guard keeps a
// deliberate `autoTitle: false` (a user-named workspace) from being dropped.
...(typeof workspace.autoTitle === "boolean" ? { autoTitle: workspace.autoTitle } : {}),
...(workspace.generatedTitle === true ? { generatedTitle: true } : {}),
};
};
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.
// Silent MCP sessions are likewise excluded — they exist for the duration
// of an AI task, not as a user-intended workspace to bring back on launch.
sessions: input.sessions.filter((session) => !session.ephemeralHost && !session.hiddenFromTabs).map(restoreSession),
workspaces: input.workspaces,
});
}