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
176 lines
6.5 KiB
TypeScript
176 lines
6.5 KiB
TypeScript
import type { Host, TerminalSession } from "./models";
|
|
import { isPluginHostProtocol } from "./pluginConnection";
|
|
|
|
export type SftpConnectedHostEntry = {
|
|
host: Host;
|
|
sessionId: string;
|
|
status: "connected";
|
|
};
|
|
|
|
/** Fields the SFTP Connected picker cares about from a terminal session. */
|
|
export type SftpPickerSessionFields = Pick<
|
|
TerminalSession,
|
|
| "id"
|
|
| "hostId"
|
|
| "protocol"
|
|
| "status"
|
|
| "moshEnabled"
|
|
| "etEnabled"
|
|
| "hostname"
|
|
| "username"
|
|
| "port"
|
|
>;
|
|
|
|
/**
|
|
* Sessions that can actually reuse a live terminal SSH connection for SFTP.
|
|
* Connecting sessions and Mosh/ET transports have no reusable ssh2 shell conn.
|
|
*/
|
|
const isReusableSftpSourceSession = (session: SftpPickerSessionFields): boolean => {
|
|
if (session.id.startsWith("sftp-")) return false;
|
|
if (session.status !== "connected") return false;
|
|
if (session.moshEnabled || session.etEnabled) return false;
|
|
const protocol = session.protocol;
|
|
if (protocol === "serial" || protocol === "local" || protocol === "telnet"
|
|
|| isPluginHostProtocol(protocol)) return false;
|
|
// Missing protocol defaults to SSH (same as host picker filtering).
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Overlay the live session endpoint onto the vault host so SFTP connect +
|
|
* sourceSessionId reuse matches findReusableSession's endpoint check.
|
|
* Vault hosts can be edited after the terminal connected; using the edited
|
|
* host would reject reuse and open a fresh connection to a different target.
|
|
*/
|
|
const hostForLiveSession = (host: Host, session: SftpPickerSessionFields): Host => ({
|
|
...host,
|
|
hostname: session.hostname,
|
|
username: session.username,
|
|
// Prefer the session port; when undefined the live SSH default is 22.
|
|
// Do not fall back to vault host.port — it may have been edited after connect.
|
|
port: session.port ?? 22,
|
|
});
|
|
|
|
/** True when two hosts target the same SSH endpoint (hostname/user/port). */
|
|
export const sftpHostEndpointsEqual = (
|
|
a: Pick<Host, "hostname" | "username" | "port">,
|
|
b: Pick<Host, "hostname" | "username" | "port">,
|
|
): boolean =>
|
|
a.hostname === b.hostname
|
|
&& a.username === b.username
|
|
&& (a.port ?? 22) === (b.port ?? 22);
|
|
|
|
/**
|
|
* Compare only picker-relevant session fields so title/cwd/font churn does not
|
|
* invalidate side-panel memoization.
|
|
*/
|
|
export const sftpPickerSessionsEqual = (
|
|
prev: ReadonlyArray<SftpPickerSessionFields> | null | undefined,
|
|
next: ReadonlyArray<SftpPickerSessionFields> | null | undefined,
|
|
): boolean => {
|
|
if (prev === next) return true;
|
|
if (!prev || !next) return false;
|
|
if (prev.length !== next.length) return false;
|
|
|
|
// Compare positionally: listSftpConnectedHosts keeps the later same-host
|
|
// session, so a reorder with identical members must invalidate memoization.
|
|
for (let i = 0; i < prev.length; i += 1) {
|
|
const session = prev[i];
|
|
const other = next[i];
|
|
if (!session || !other) return false;
|
|
if (
|
|
session.id !== other.id
|
|
|| session.hostId !== other.hostId
|
|
|| session.protocol !== other.protocol
|
|
|| session.status !== other.status
|
|
|| Boolean(session.moshEnabled) !== Boolean(other.moshEnabled)
|
|
|| Boolean(session.etEnabled) !== Boolean(other.etEnabled)
|
|
|| session.hostname !== other.hostname
|
|
|| session.username !== other.username
|
|
|| (session.port ?? 22) !== (other.port ?? 22)
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Build the "currently connected" host list for the SFTP host picker.
|
|
* One entry per hostId — keeps the most recently listed SSH terminal session.
|
|
*
|
|
* Includes hosts with sftpSudo: they still belong under "Connected" when a
|
|
* terminal is open, and SFTP can borrow the already-authenticated SSH transport
|
|
* before silently falling back to a fresh connection.
|
|
*/
|
|
export const listSftpConnectedHosts = (
|
|
sessions: ReadonlyArray<SftpPickerSessionFields>,
|
|
hostsById: ReadonlyMap<string, Host>,
|
|
): SftpConnectedHostEntry[] => {
|
|
const bestByHostId = new Map<string, SftpConnectedHostEntry>();
|
|
|
|
for (const session of sessions) {
|
|
if (!isReusableSftpSourceSession(session)) continue;
|
|
const host = hostsById.get(session.hostId);
|
|
if (!host) continue;
|
|
if (host.protocol === "serial" || isPluginHostProtocol(host.protocol)) continue;
|
|
// Use session transport flags only. Vault hosts may still have mosh/et
|
|
// defaults while the live terminal was opened as plain SSH (e.g. ssh://).
|
|
// Do not filter sftpSudo here — picker display only; reuse is stripped later.
|
|
|
|
// Later sessions overwrite earlier ones for the same hostId.
|
|
bestByHostId.set(host.id, {
|
|
host: hostForLiveSession(host, session),
|
|
sessionId: session.id,
|
|
status: "connected",
|
|
});
|
|
}
|
|
|
|
return [...bestByHostId.values()].sort((a, b) =>
|
|
a.host.label.localeCompare(b.host.label),
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Return the live terminal session id that SFTP should try before fresh auth.
|
|
* The main process validates that the hinted session really matches the target.
|
|
*/
|
|
export const sftpSourceSessionIdForHost = (
|
|
host: Pick<Host, "sftpSudo"> | null | undefined,
|
|
sourceSessionId: string | undefined,
|
|
): string | undefined => {
|
|
if (!sourceSessionId) return undefined;
|
|
return sourceSessionId;
|
|
};
|
|
|
|
/**
|
|
* Resolve a terminal session id for transfer-pool opens that can reuse the
|
|
* live SSH transport. Unlike the picker list, this keeps every reusable
|
|
* session so same-hostId tabs with different live endpoints can still match.
|
|
* This renderer-side result is only a source hint: proxy, jump, credential,
|
|
* host-key and keepalive fingerprints are intentionally retained by the main
|
|
* process. openSftpForSession reselects and validates the live session against
|
|
* that complete identity before it borrows a transport.
|
|
*
|
|
* @returns session id, or undefined when no reusable source exists
|
|
*/
|
|
export const resolveSftpTransferSourceSessionId = (
|
|
sessions: ReadonlyArray<SftpPickerSessionFields>,
|
|
hostsById: ReadonlyMap<string, Host>,
|
|
hostId: string,
|
|
host?: Pick<Host, "hostname" | "username" | "port" | "sftpSudo" | "protocol">,
|
|
): string | undefined => {
|
|
let lastMatch: string | undefined;
|
|
for (const session of sessions) {
|
|
if (session.hostId !== hostId) continue;
|
|
if (!isReusableSftpSourceSession(session)) continue;
|
|
const vaultHost = hostsById.get(session.hostId);
|
|
if (!vaultHost) continue;
|
|
if (vaultHost.protocol === "serial" || isPluginHostProtocol(vaultHost.protocol)) continue;
|
|
const liveHost = hostForLiveSession(vaultHost, session);
|
|
if (host && !sftpHostEndpointsEqual(liveHost, host)) continue;
|
|
lastMatch = session.id;
|
|
}
|
|
return lastMatch;
|
|
};
|