[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
155
application/state/sftp/ensureRemoteSftpSession.ts
Normal file
155
application/state/sftp/ensureRemoteSftpSession.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { Host } from "../../../domain/models";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { isSessionError } from "./errors";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
export interface SftpSessionProbeBridge {
|
||||
realpathSftp?: (sftpId: string, path: string) => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the SFTP protocol itself instead of using remote home discovery.
|
||||
* Restricted/chroot servers can deny SSH exec or expose '/' as their virtual
|
||||
* root while the SFTP session remains fully usable.
|
||||
*/
|
||||
export async function probeSftpSession(
|
||||
bridge: SftpSessionProbeBridge | null | undefined,
|
||||
sftpId: string,
|
||||
): Promise<boolean> {
|
||||
if (!bridge?.realpathSftp) return true;
|
||||
await bridge.realpathSftp(sftpId, ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface EnsureRemoteSftpSessionParams {
|
||||
side: "left" | "right";
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
sftpSessionsRef: MutableRefObject<Map<string, string>>;
|
||||
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
connect: (
|
||||
side: "left" | "right",
|
||||
host: Host | "local",
|
||||
options?: { initialPath?: string; ignoreSharedCache?: boolean; tabId?: string; sourceSessionId?: string },
|
||||
) => Promise<void>;
|
||||
/** Preferred already-authenticated SSH session for this reconnect. */
|
||||
sourceSessionId?: string;
|
||||
/** Resolve an SSH session after the reconnect host has been resolved. */
|
||||
resolveSourceSessionId?: (hostId: string, host: Host) => string | undefined;
|
||||
/**
|
||||
* Per-tab connect-time host (includes session hostname/port/user overrides).
|
||||
* Prefer this over the vault entry so upload reconnects keep the same endpoint.
|
||||
*/
|
||||
resolveConnectedHost?: (tabId: string) => Host | "local" | null | undefined;
|
||||
/** Resolve vault host by id when per-tab connect-time host is unavailable. */
|
||||
resolveHostById?: (hostId: string) => Host | null | undefined;
|
||||
probeSession?: (sftpId: string) => Promise<boolean>;
|
||||
/** Remove connection metadata and close the mapped backend session. */
|
||||
releaseConnection: (connectionId: string) => Promise<void>;
|
||||
forceReconnect?: boolean;
|
||||
/** Stable tab identity — reconnect replaces connection ids, not tab ids. */
|
||||
tabId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a live remote SFTP session id for the active pane, reconnecting the
|
||||
* host when the mapping is missing or the backend session is gone.
|
||||
*/
|
||||
export async function ensureRemoteSftpSession(
|
||||
params: EnsureRemoteSftpSessionParams,
|
||||
): Promise<string> {
|
||||
const {
|
||||
side,
|
||||
getActivePane,
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
connect,
|
||||
resolveConnectedHost,
|
||||
resolveHostById,
|
||||
probeSession,
|
||||
releaseConnection,
|
||||
forceReconnect = false,
|
||||
tabId,
|
||||
sourceSessionId,
|
||||
resolveSourceSessionId,
|
||||
} = params;
|
||||
|
||||
const resolveHost = (): Host => {
|
||||
const pane = getActivePane(side);
|
||||
const hostId = pane?.connection && !pane.connection.isLocal ? pane.connection.hostId : undefined;
|
||||
const resolvedTabId = tabId ?? pane?.id;
|
||||
// Prefer the full Host captured when this tab connected (session-time
|
||||
// hostname/port/username overrides). Vault lookup by hostId alone would
|
||||
// reconnect the base endpoint and can open the wrong server before the
|
||||
// upload endpoint assertion aborts.
|
||||
if (resolvedTabId && resolveConnectedHost) {
|
||||
const fromTab = resolveConnectedHost(resolvedTabId);
|
||||
if (fromTab && fromTab !== "local") return fromTab;
|
||||
}
|
||||
// Vault next — never prefer side-wide lastConnectedHost over vault when
|
||||
// another tab on this side may hold different overrides for the same hostId.
|
||||
if (hostId && resolveHostById) {
|
||||
const fromVault = resolveHostById(hostId);
|
||||
if (fromVault) return fromVault;
|
||||
}
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && lastHost !== "local" && (!hostId || lastHost.id === hostId)) {
|
||||
return lastHost;
|
||||
}
|
||||
// Pane connection only stores hostId/label — inventing root@label:22 would
|
||||
// open the wrong endpoint. Fail clearly so the caller can reconnect via
|
||||
// vault host metadata instead of a synthetic identity.
|
||||
if (pane?.connection && !pane.connection.isLocal) {
|
||||
throw new Error(
|
||||
`Cannot reconnect SFTP for "${pane.connection.hostLabel}": host credentials are unavailable. Reopen the host from the vault.`,
|
||||
);
|
||||
}
|
||||
throw new Error("No remote host available to reconnect");
|
||||
};
|
||||
|
||||
const readMappedId = (): string | undefined => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection || pane.connection.isLocal) {
|
||||
throw new Error("No remote SFTP connection on this pane");
|
||||
}
|
||||
return sftpSessionsRef.current.get(pane.connection.id);
|
||||
};
|
||||
|
||||
if (!forceReconnect) {
|
||||
const existing = readMappedId();
|
||||
if (existing) {
|
||||
if (!probeSession) return existing;
|
||||
try {
|
||||
const ok = await probeSession(existing);
|
||||
if (ok) return existing;
|
||||
} catch (error) {
|
||||
if (!isSessionError(error)) throw error;
|
||||
}
|
||||
const pane = getActivePane(side);
|
||||
if (pane?.connection) {
|
||||
await releaseConnection(pane.connection.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pane = getActivePane(side);
|
||||
if (pane?.connection) {
|
||||
await releaseConnection(pane.connection.id);
|
||||
}
|
||||
}
|
||||
|
||||
const paneBefore = getActivePane(side);
|
||||
const resumePath = paneBefore?.connection?.currentPath;
|
||||
const host = resolveHost();
|
||||
const resolvedSourceSessionId = sourceSessionId ?? resolveSourceSessionId?.(host.id, host);
|
||||
await connect(side, host, {
|
||||
initialPath: resumePath,
|
||||
ignoreSharedCache: true,
|
||||
...(tabId ? { tabId } : {}),
|
||||
...(resolvedSourceSessionId ? { sourceSessionId: resolvedSourceSessionId } : {}),
|
||||
});
|
||||
|
||||
const sftpId = readMappedId();
|
||||
if (!sftpId) {
|
||||
throw new Error("SFTP session not found after reconnect");
|
||||
}
|
||||
return sftpId;
|
||||
}
|
||||
Reference in New Issue
Block a user