Files
Netcatty/domain/sshDeepLink.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

238 lines
6.9 KiB
TypeScript

import type { Host } from "./models";
export interface SshDeepLinkTarget {
rawUrl: string;
username?: string;
password?: string;
hostname: string;
port?: number;
}
export interface SshDeepLinkDraftOptions {
id: string;
now: number;
}
const DEFAULT_SSH_PORT = 22;
const URL_SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:/i;
const normalizeHostname = (value: string): string =>
value.trim().replace(/^\[(.*)\]$/, "$1").toLowerCase();
const decodeUrlComponent = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const getHostPort = (host: Host): number => host.port ?? DEFAULT_SSH_PORT;
const isPrimarySshHost = (host: Host): boolean =>
host.protocol === undefined || host.protocol === "ssh";
export const parseSshDeepLink = (rawUrl: string): SshDeepLinkTarget | null => {
if (typeof rawUrl !== "string") return null;
const trimmed = rawUrl.trim();
if (!trimmed) return null;
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return null;
}
if (parsed.protocol !== "ssh:") return null;
const hostname = normalizeHostname(parsed.hostname);
if (!hostname) return null;
const portText = parsed.port;
const port = portText ? Number(portText) : undefined;
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) {
return null;
}
const username = parsed.username
? decodeUrlComponent(parsed.username).trim()
: undefined;
const password = parsed.password
? decodeUrlComponent(parsed.password)
: undefined;
return {
rawUrl: trimmed,
...(username ? { username } : {}),
...(password ? { password } : {}),
hostname,
...(port ? { port } : {}),
};
};
export const shouldHandleSshDeepLink = (rawUrl: string, enabled: boolean): boolean =>
enabled && parseSshDeepLink(rawUrl) !== null;
export const findSshDeepLinkHost = (
hosts: Host[],
target: SshDeepLinkTarget,
): Host | null => {
const targetHost = normalizeHostname(target.hostname);
const targetPort = target.port ?? DEFAULT_SSH_PORT;
const candidates = hosts.filter((host) => {
if (!isPrimarySshHost(host)) return false;
if (normalizeHostname(host.hostname) !== targetHost) return false;
if (target.username && (host.username || "").trim() !== target.username) return false;
if (getHostPort(host) !== targetPort) return false;
return true;
});
return candidates.length === 1 ? candidates[0] : null;
};
export const buildSshDeepLinkConnectionHost = (host: Host): Host => ({
...host,
protocol: "ssh",
moshEnabled: false,
etEnabled: false,
});
export const buildSshDeepLinkOpenHost = (
hosts: Host[],
target: SshDeepLinkTarget,
options: SshDeepLinkDraftOptions,
): Host => buildSshDeepLinkConnectionHost(
findSshDeepLinkHost(hosts, target) ?? buildSshDeepLinkHostDraft(target, options),
);
export const buildSshDeepLinkEphemeralHost = (
target: SshDeepLinkTarget,
options: SshDeepLinkDraftOptions,
): Host => ({
...buildSshDeepLinkHostDraft(target, options),
...(target.password ? { password: target.password, authMethod: "password" as const } : {}),
savePassword: false,
ephemeral: true,
moshEnabled: false,
etEnabled: false,
});
/**
* Ephemeral host for a password deep link that uniquely matches a saved
* vault host: keep the saved host's non-credential settings (proxy, jump
* chain, charset, ...) but authenticate with exactly the URL credentials,
* so vault identities and key references never override the one-time
* password.
*
* Pass the group-resolved effective host (not the raw vault host): group
* defaults must already be materialized here, because this builder clears
* `group` so that later effective-host resolution cannot re-inherit group
* credentials (identity, key, password) over the URL password.
*/
export const buildSshDeepLinkEphemeralHostFromSaved = (
effectiveSavedHost: Host,
target: SshDeepLinkTarget,
options: SshDeepLinkDraftOptions,
): Host => ({
...effectiveSavedHost,
id: options.id,
createdAt: options.now,
...(target.username ? { username: target.username } : {}),
...(target.password ? { password: target.password, authMethod: "password" as const } : {}),
identityId: undefined,
identityFileId: undefined,
identityFilePaths: undefined,
savePassword: false,
group: "",
ephemeral: true,
protocol: "ssh",
moshEnabled: false,
etEnabled: false,
});
export const buildSshDeepLinkHostDraft = (
target: SshDeepLinkTarget,
options: SshDeepLinkDraftOptions,
): Host => ({
id: options.id,
label: target.username ? `${target.username}@${target.hostname}` : target.hostname,
hostname: target.hostname,
username: target.username || "",
...(target.port !== undefined ? { port: target.port } : {}),
group: "",
tags: [],
os: "linux",
protocol: "ssh",
createdAt: options.now,
});
const normalizeBareHostReference = (value: string): string | null => {
const decoded = decodeUrlComponent(value).trim().replace(/^\.\/+/, "").replace(/\/+$/, "");
if (!decoded || decoded.includes(" ") || decoded.startsWith("#") || decoded.startsWith("/")) return null;
if (URL_SCHEME_PATTERN.test(decoded)) return null;
return decoded;
};
const isDocumentRelativeLink = (value: string): boolean => {
const trimmed = value.trim();
return trimmed.startsWith("#")
|| trimmed.startsWith("/")
|| trimmed.startsWith("./")
|| trimmed.startsWith("../")
|| trimmed.includes("/")
|| trimmed.includes("?")
|| trimmed.includes("#");
};
const parseBareHostReference = (value: string): SshDeepLinkTarget | null => {
const reference = normalizeBareHostReference(value);
if (!reference) return null;
return parseSshDeepLink(`ssh://${reference}`);
};
const findHostByLabel = (hosts: Host[], label: string): Host | null => {
const needle = label.trim().toLowerCase();
if (!needle) return null;
const candidates = hosts.filter((host) =>
isPrimarySshHost(host) && (host.label || "").trim().toLowerCase() === needle,
);
return candidates.length === 1 ? candidates[0] : null;
};
export const buildSshNoteLinkOpenHost = (
hosts: Host[],
href: string,
label: string | undefined,
options: SshDeepLinkDraftOptions,
): Host | null => {
const normalizedHref = href.trim();
const deepLinkTarget = parseSshDeepLink(href);
if (deepLinkTarget) {
return buildSshDeepLinkOpenHost(hosts, deepLinkTarget, options);
}
if (URL_SCHEME_PATTERN.test(normalizedHref)) {
return null;
}
if (isDocumentRelativeLink(normalizedHref)) {
return null;
}
const references = [href, label]
.filter((value): value is string => Boolean(value?.trim()));
for (const reference of references) {
const target = parseBareHostReference(reference);
if (!target) continue;
const host = findSshDeepLinkHost(hosts, target);
if (host) return buildSshDeepLinkConnectionHost(host);
}
for (const reference of references) {
const host = findHostByLabel(hosts, reference);
if (host) return buildSshDeepLinkConnectionHost(host);
}
return null;
};