chore: lock the plugin boundary and remove 2.9.0 leftovers

This commit is contained in:
LukeGus committed 2026-09-25 07:24:46 -05:00
1 parent 6489b62ece
commit 2961297989
399 files changed
+3964 -5627

No files matched your search

@@ -37,7 +37,7 @@ export function listSshAuthTypeOwners(): Array<
return ownerSource();
}
export function findSshAuthTypeOwner(type: string): SshAuthTypeOwner | null {
function findSshAuthTypeOwner(type: string): SshAuthTypeOwner | null {
const owner = ownerSource().find((entry) => entry.type === type);
return owner
? { pluginId: owner.pluginId, pluginName: owner.pluginName }
@@ -91,6 +91,11 @@ export function listSshAuthProviders(): SshAuthProvider[] {
return [...providers.values()];
}
/** Whether a registered provider works for a host that is never saved. */
export function isQuickConnectAuthType(type: string): boolean {
return providers.get(type)?.quickConnect === true;
}
/** Types a stored credential may use. */
export function listCredentialTypes(): string[] {
return listSshAuthProviders()
@@ -15,6 +15,7 @@ import type {
SshAuthOutcome,
SshAuthProvider,
SshConnectHost,
SshConnectProfile,
SshConnectPurpose,
} from "./types.js";
@@ -48,8 +49,7 @@ const BASIC_ENV: Record<string, string> = {
LC_ALL: "en_US.UTF-8",
};
// The values each transport used before it moved onto the pipeline.
const PURPOSE_DEFAULTS: Record<SshConnectPurpose, PurposeDefaults> = {
const PROFILE_DEFAULTS: Record<SshConnectProfile, PurposeDefaults> = {
terminal: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 5,
@@ -58,116 +58,55 @@ const PURPOSE_DEFAULTS: Record<SshConnectPurpose, PurposeDefaults> = {
socketTimeout: 120000,
env: TERMINAL_ENV,
},
"file-manager": {
session: {
keepaliveIntervalMs: 60000,
keepaliveCountMax: 5,
hostKeepalive: true,
readyTimeout: 60000,
env: TERMINAL_ENV,
},
"file-transfer": {
stream: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 120,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
tmux: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
metrics: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: BASIC_ENV,
},
fleet: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: BASIC_ENV,
},
tunnel: {
forward: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: true,
readyTimeout: 60000,
env: null,
},
docker: {
keepaliveIntervalMs: 60000,
keepaliveCountMax: 5,
hostKeepalive: true,
readyTimeout: 60000,
env: TERMINAL_ENV,
},
"docker-console": {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 120,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
proxmox: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 20000,
env: null,
},
snippet: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: null,
},
"credential-deploy": {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: null,
},
"jump-host": {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
"remote-desktop": {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
plugin: {
background: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: BASIC_ENV,
},
jump: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
};
export function getPurposeDefaults(
purpose: SshConnectPurpose,
export function getProfileDefaults(
profile: SshConnectProfile = "background",
): PurposeDefaults {
return PURPOSE_DEFAULTS[purpose];
return PROFILE_DEFAULTS[profile];
}
export interface BuildConnectConfigOptions {
/** Acting user: host key trust, certificates and tokens are per user. */
userId: string;
purpose: SshConnectPurpose;
/** Keepalive and timeout defaults. Defaults to "background". */
profile?: SshConnectProfile;
client: Client;
/** Server-side host id when it differs from host.id (sync-id lookups). */
serverHostId?: number;
@@ -188,7 +127,7 @@ export interface BuiltConnectConfig {
const noopLog: SshAuthLog = () => {};
export function stripIpv6Brackets(ip: string): string {
function stripIpv6Brackets(ip: string): string {
return ip?.replace(/^\[|\]$/g, "") || ip;
}
@@ -202,10 +141,11 @@ export async function buildConnectConfig(
): Promise<BuiltConnectConfig> {
ensureCoreSshAuthProviders();
const defaults = PURPOSE_DEFAULTS[options.purpose];
const profile = options.profile ?? "background";
const defaults = PROFILE_DEFAULTS[profile];
const authType = host.authType || "none";
const serverHostId = options.serverHostId ?? host.id;
const isJumpHost = options.purpose === "jump-host";
const isJumpHost = profile === "jump";
const terminalConfig = (host.terminalConfig ?? undefined) as
Record<string, unknown> | undefined;
+11 -6
View File
@@ -130,19 +130,21 @@ async function prepareKey(
return { status: "ready" };
}
export const passwordProvider: SshAuthProvider = {
const passwordProvider: SshAuthProvider = {
type: "password",
pluginId: "core",
labelKey: "hosts.filterAuthPassword",
quickConnect: true,
credentialType: true,
requiresSecret: true,
prepare: preparePassword,
};
export const keyProvider: SshAuthProvider = {
const keyProvider: SshAuthProvider = {
type: "key",
pluginId: "core",
labelKey: "hosts.filterAuthKey",
quickConnect: true,
credentialType: true,
requiresSecret: true,
prepare: prepareKey,
@@ -153,10 +155,11 @@ export const keyProvider: SshAuthProvider = {
* this only runs when that expansion found something odd, like an unresolved
* reference. It behaves like whichever secret is present.
*/
export const credentialProvider: SshAuthProvider = {
const credentialProvider: SshAuthProvider = {
type: "credential",
pluginId: "core",
labelKey: "hosts.filterAuthCredential",
quickConnect: true,
requiresSecret: true,
prepare: async (config, host, env) => {
if (keyText(host.key).trim()) return prepareKey(config, host, env);
@@ -169,10 +172,11 @@ export const credentialProvider: SshAuthProvider = {
},
};
export const agentProvider: SshAuthProvider = {
const agentProvider: SshAuthProvider = {
type: "agent",
pluginId: "core",
labelKey: "hosts.filterAuthAgent",
quickConnect: true,
requiresSecret: true,
prepare: async (config, host, env) => {
const result = await applyAgentAuth(
@@ -187,10 +191,11 @@ export const agentProvider: SshAuthProvider = {
},
};
export const noneProvider: SshAuthProvider = {
const noneProvider: SshAuthProvider = {
type: "none",
pluginId: "core",
labelKey: "hosts.filterAuthNone",
quickConnect: true,
// Keyboard-interactive only: a background poll would fire a live prompt
// (a RADIUS or Duo push) with nobody there to answer it.
supportsBackground: false,
@@ -200,7 +205,7 @@ export const noneProvider: SshAuthProvider = {
},
};
export const BUILTIN_SSH_AUTH_PROVIDERS: readonly SshAuthProvider[] = [
const BUILTIN_SSH_AUTH_PROVIDERS: readonly SshAuthProvider[] = [
passwordProvider,
keyProvider,
credentialProvider,
+5 -1
View File
@@ -23,6 +23,7 @@ import type {
SshAuthLog,
SshAuthOutcome,
SshConnectHost,
SshConnectProfile,
SshConnectPurpose,
SshPromptChannel,
} from "./types.js";
@@ -41,7 +42,7 @@ export class SshConnectError extends Error {
}
}
export class SshHostNotFoundError extends Error {
class SshHostNotFoundError extends Error {
constructor(readonly hostId: number) {
super("Host not found or access denied");
this.name = "SshHostNotFoundError";
@@ -52,6 +53,8 @@ export interface ConnectHostOptions {
/** Acting user. Access and credentials are resolved for them. */
userId: string;
purpose: SshConnectPurpose;
/** Keepalive and timeout defaults. Defaults to "background". */
profile?: SshConnectProfile;
/** Ask a person for keyboard-interactive answers. Without it, auto-fill. */
prompt?: SshPromptChannel;
/** Replaces the keyboard-interactive handler entirely. */
@@ -100,6 +103,7 @@ export async function connectHost(
const { config, provider, outcome, env } = await buildConnectConfig(host, {
userId: options.userId,
purpose: options.purpose,
profile: options.profile,
client,
hostKeySocket: options.prompt?.hostKeySocket ?? null,
interactive: !!options.prompt,
@@ -11,8 +11,3 @@ export function ensureCoreSshAuthProviders(): void {
registered = true;
registerBuiltinSshAuthProviders();
}
/** Test helper, pairs with resetSshAuthRegistryForTests. */
export function resetCoreSshAuthProvidersForTests(): void {
registered = false;
}
@@ -23,7 +23,7 @@ const FORTI_TOKEN_PATTERN = /type\s+['"]?push['"]?/i;
// JumpCloud Protect / DUO: a menu choice, then an empty-answerable confirm.
// Checked before TOTP because "...or [2] TOTP:" would match that pattern.
export const PUSH_PROMPT_PATTERN =
const PUSH_PROMPT_PATTERN =
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
const TOTP_PATTERN =
@@ -36,7 +36,7 @@ export interface KeyboardInteractiveRound {
}
/** Stored password for every password prompt, empty for the rest. */
export function autoResponses(
function autoResponses(
prompts: KeyboardInteractivePrompt[],
password: string | null | undefined,
): string[] {
@@ -46,7 +46,7 @@ export function autoResponses(
}
/** Puts the user's answer at one index and auto-fills the rest. */
export function responsesWithAnswer(
function responsesWithAnswer(
prompts: KeyboardInteractivePrompt[],
answerIndex: number,
answer: string,
@@ -46,7 +46,7 @@ function findSchemeOwner(scheme: string): SecretResolverOwner | null {
: null;
}
export class SecretResolverMissingError extends Error {
class SecretResolverMissingError extends Error {
readonly code = "SECRET_RESOLVER_MISSING";
constructor(
readonly scheme: string,
+2 -2
View File
@@ -21,7 +21,7 @@ import type {
SshConnectHost,
} from "./types.js";
export function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
const chain = Array.isArray(host.socks5ProxyChain)
? (host.socks5ProxyChain as ProxyNode[])
: [];
@@ -36,7 +36,7 @@ export function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
};
}
export class SshTransportError extends Error {
class SshTransportError extends Error {
constructor(
message: string,
readonly stage: "cloudflare" | "jump-host" | "jump-forward" | "proxy",
+18 -19
View File
@@ -2,25 +2,19 @@ import type { Client, ConnectConfig } from "ssh2";
import type { WebSocket } from "ws";
/**
* What a connection is for. Picks keepalive and timeout defaults so every
* transport keeps the numbers it had before the pipeline existed.
* What a connection is for, as a free label ("terminal", "metrics"). Auth
* providers see it; core attaches no meaning to any value.
*/
export type SshConnectPurpose =
| "terminal"
| "file-manager"
| "file-transfer"
| "tmux"
| "metrics"
| "fleet"
| "tunnel"
| "docker"
| "docker-console"
| "proxmox"
| "snippet"
| "credential-deploy"
| "jump-host"
| "remote-desktop"
| "plugin";
export type SshConnectPurpose = string;
/**
* Keepalive, timeout and environment defaults for a kind of connection.
* terminal: an interactive shell. session: a long-lived browsing session.
* stream: a long transfer or console. forward: port forwarding. background:
* short exec work such as polling. jump: a hop in a jump chain (core only).
*/
export type SshConnectProfile =
"terminal" | "session" | "stream" | "forward" | "background" | "jump";
/** The resolved host fields the pipeline reads. */
export interface SshConnectHost {
@@ -169,12 +163,17 @@ export interface SshAuthProvider {
* true; false for types that need a person or a fresh browser sign-in.
*/
supportsBackground?: boolean;
/**
* Works for a host that was never saved, so Quick Connect offers it.
* Default false.
*/
quickConnect?: boolean;
/** Overrides applied to the base config before prepare runs. */
connectOptions?: (
host: SshConnectHost,
purpose: SshConnectPurpose,
) => Partial<MutableConnectConfig>;
/** The interaction name this provider's outcomes use, e.g. "opkssh". */
/** The interaction name this provider's outcomes use, e.g. "browser-signin". */
interaction?: string;
/**
* Starts the browser step behind an `interaction-required` outcome, for
-15
View File
@@ -1,15 +0,0 @@
import type { ConnectionStage, LogEntry } from "../../types/connection-log.js";
export function createConnectionLog(
type: "info" | "success" | "warning" | "error",
stage: ConnectionStage,
message: string,
details?: Record<string, unknown>,
): Omit<LogEntry, "id" | "timestamp"> {
return {
type,
stage,
message,
details,
};
}
+1 -4
View File
@@ -32,10 +32,7 @@ export async function deleteOwnedHost(
);
if (!host) return null;
// file manager recent/pinned/shortcuts, transfer_recent, command history
// and session recordings cascade on the host's refHost() foreign key, as
// the file-manager, ssh-terminal and session-recording plugins' adopted
// tables.
// Plugin rows tied to a host cascade on their refHost() foreign keys.
await createCurrentSshCredentialUsageRepository().deleteByHostId(hostId);
await createCurrentRecentActivityRepository().deleteByHostId(hostId);
await createCurrentRbacAccessRepository().deleteHostAccessForHost(hostId);
+2 -10
View File
@@ -1,7 +1,4 @@
import {
getSecretResolver,
requireSecretResolver,
} from "./connect/secret-resolver-registry.js";
import { requireSecretResolver } from "./connect/secret-resolver-registry.js";
/**
* Expands "<scheme>://..." references ("op://vault/item/field" for 1Password
@@ -17,7 +14,7 @@ import {
* cached briefly in memory; a rotated secret shows up within CACHE_TTL_MS.
*/
export const SECRET_FIELDS = [
const SECRET_FIELDS = [
"password",
"key",
"keyPassword",
@@ -91,8 +88,3 @@ export async function resolveExternalSecretRefs(
host[field] = await resolveSecretReference(userId, value, deps);
}
}
/** Whether any plugin currently resolves this reference's scheme. */
export function hasSecretResolverFor(reference: string): boolean {
return !!getSecretResolver(referenceScheme(reference));
}
-27
View File
@@ -15,7 +15,6 @@ import {
expandOidcUsername,
} from "./credential-username.js";
import type { SSHHost } from "../../types/index.js";
import type { HostAction } from "../utils/permission-manager.js";
const sshLogger = logger;
@@ -316,29 +315,3 @@ async function resolveRecipientSshAuth(
return null;
}
/**
* Check if a user has access to a host (owner or shared access).
*/
export async function checkHostAccess(
hostId: number,
userId: string,
hostUserId: string,
requiredPermission: HostAction = "connect",
): Promise<boolean> {
if (userId === hostUserId) return true;
try {
const { PermissionManager } =
await import("../utils/permission-manager.js");
const permissionManager = PermissionManager.getInstance();
const accessInfo = await permissionManager.canAccessHost(
userId,
hostId,
requiredPermission,
);
return accessInfo.hasAccess;
} catch {
return false;
}
}
+2 -2
View File
@@ -47,7 +47,7 @@ async function resolveJumpHost(
}
}
export class JumpHostChainError extends Error {
class JumpHostChainError extends Error {
constructor(
message: string,
readonly hopIndex: number,
@@ -161,7 +161,7 @@ export async function createJumpHostChain(
// every auth type, not just password, key and agent.
const built = await buildConnectConfig(
jumpHostConfig as unknown as SshConnectHost,
{ userId, purpose: "jump-host", client: jumpClient },
{ userId, purpose: "jump-host", profile: "jump", client: jumpClient },
);
if (built.outcome.status !== "ready") {
clearTimeout(timeout);
+1 -1
View File
@@ -10,7 +10,7 @@
import { getServiceImplementation } from "../plugins/service-registry.js";
export const LIVE_SESSIONS_SERVICE = "sessions.live";
const LIVE_SESSIONS_SERVICE = "sessions.live";
const SSH_PROVIDER = "ssh";
export interface LiveSessionInfo {
-45
View File
@@ -1,45 +0,0 @@
import type { Client } from "ssh2";
import type { SSHHost } from "../../types/index.js";
import {
connectHost,
getConnectionPoolKey,
SshConnectError,
} from "./connect/connect-host.js";
import { buildConnectConfig } from "./connect/build-connect-config.js";
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
/**
* Non-interactive SSH connections for background work (fleets, automations,
* AI tools). A thin wrapper over the connect pipeline, kept so the pool keys
* and call sites stay the same.
*/
export function getFleetPoolKey(host: SSHHost): string {
return getConnectionPoolKey("fleet", host as unknown as SshConnectHost);
}
export async function buildFleetSshConfig(
host: SSHHost,
client?: Client,
): Promise<MutableConnectConfig> {
const { Client: SshClient } = await import("ssh2");
const built = await buildConnectConfig(host as unknown as SshConnectHost, {
userId: host.userId || "",
purpose: "fleet",
client: client ?? new SshClient(),
});
if (built.outcome.status !== "ready") {
throw new SshConnectError(built.outcome);
}
return built.config;
}
export function createFleetSshFactory(host: SSHHost): () => Promise<Client> {
return async () => {
const connection = await connectHost(host as unknown as SshConnectHost, {
userId: host.userId || "",
purpose: "fleet",
});
return connection.client;
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
import dns from "dns/promises";
import net from "net";
export const SSH_DNS_RETRY_DELAYS_MS = [250, 750, 1500];
const SSH_DNS_RETRY_DELAYS_MS = [250, 750, 1500];
type Lookup = typeof dns.lookup;
type Sleep = (ms: number) => Promise<void>;
-151
View File
@@ -1,151 +0,0 @@
export type TransferPlatform = "windows" | "unix";
/** OpenSSH SFTP on Windows commonly uses `/C:/...` or `C:/...` style paths. */
export function isWindowsSftpPath(path: string): boolean {
const normalized = path.replace(/\\/g, "/");
return /^[A-Za-z]:\//.test(normalized) || /^\/[A-Za-z]:\//.test(normalized);
}
export function inferPlatformFromPath(path: string): TransferPlatform | null {
if (isWindowsSftpPath(path)) return "windows";
if (path.startsWith("/") && !/^\/[A-Za-z]:/.test(path.replace(/\\/g, "/"))) {
return "unix";
}
return null;
}
export function normalizeSftpPath(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
if (!normalized) return path.startsWith("/") ? "/" : ".";
return normalized.replace(/([^:])\/+/g, "$1/");
}
export function basename(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
const idx = normalized.lastIndexOf("/");
if (idx < 0) return normalized;
return normalized.substring(idx + 1) || normalized;
}
export function dirname(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
if (!normalized) return normalized;
const idx = normalized.lastIndexOf("/");
if (idx < 0) return normalized;
const parent = normalized.substring(0, idx);
if (!parent) return "/";
if (/^\/[A-Za-z]:$/.test(parent) || /^[A-Za-z]:$/.test(parent)) {
return parent;
}
return parent || "/";
}
export function joinPath(base: string, ...parts: string[]): string {
let result = base.replace(/\\/g, "/");
for (const part of parts) {
if (!part) continue;
const segment = part.replace(/\\/g, "/").replace(/^\/+/, "");
if (!segment) continue;
if (result.endsWith("/")) {
result += segment;
} else {
result = `${result}/${segment}`;
}
}
return normalizeSftpPath(result);
}
export interface PathSegments {
/** Drive root prefix, e.g. `/C:` or `C:`; empty for Unix absolute paths. */
root: string;
/** Path segments below the root (may be empty). */
segments: string[];
}
export function splitPathSegments(path: string): PathSegments {
const normalized = normalizeSftpPath(path);
const posixDrive = normalized.match(/^(\/[A-Za-z]:)(?:\/(.*))?$/);
if (posixDrive) {
return {
root: posixDrive[1],
segments: (posixDrive[2] ?? "").split("/").filter(Boolean),
};
}
const drive = normalized.match(/^([A-Za-z]:)(?:\/(.*))?$/);
if (drive) {
return {
root: drive[1],
segments: (drive[2] ?? "").split("/").filter(Boolean),
};
}
if (normalized.startsWith("/")) {
return {
root: "/",
segments: normalized.split("/").filter(Boolean),
};
}
return {
root: "",
segments: normalized.split("/").filter(Boolean),
};
}
export function buildPathFromSegments(
root: string,
segments: string[],
count: number,
): string {
const slice = segments.slice(0, count);
if (/^\/[A-Za-z]:$/.test(root)) {
return slice.length ? `${root}/${slice.join("/")}` : root;
}
if (/^[A-Za-z]:$/.test(root)) {
return slice.length ? `${root}/${slice.join("/")}` : root;
}
if (root === "/") {
return slice.length ? `/${slice.join("/")}` : "/";
}
if (root === "") {
return slice.join("/");
}
return slice.length ? `${root}/${slice.join("/")}` : root;
}
export function pathsOverlap(source: string, dest: string): boolean {
const norm = (p: string) => {
const n = normalizeSftpPath(p).toLowerCase();
return n || (isWindowsSftpPath(p) ? p : "/");
};
const s = norm(source);
const d = norm(dest);
const sep = "/";
return s === d || s.startsWith(`${d}${sep}`) || d.startsWith(`${s}${sep}`);
}
export function getWorkingDir(paths: string[]): string {
if (paths.length === 0) return "/";
const parents = paths.map((p) => dirname(p));
const first = parents[0];
if (parents.every((p) => p === first)) {
return first;
}
return first;
}
/** Convert an SFTP path to a local filesystem path when Termix runs on the dest host. */
export function sftpPathToLocalPath(sftpPath: string): string {
const normalized = sftpPath.replace(/\\/g, "/");
if (/^\/[A-Za-z]:\//.test(normalized)) {
return normalized.slice(1).replace(/\//g, "\\");
}
if (/^[A-Za-z]:\//.test(normalized)) {
return normalized.replace(/\//g, "\\");
}
return sftpPath;
}