feat: move terminal settings, look and ssh tools into ssh-terminal plugin

This commit is contained in:
LukeGus committed 2026-09-29 02:38:37 -05:00
1 parent ffa872ebd3
commit d9bba10612
242 files changed
+25071 -7207

No files matched your search

@@ -22,7 +22,7 @@ import type {
interface PurposeDefaults {
keepaliveIntervalMs: number;
keepaliveCountMax: number;
/** Let terminalConfig.keepalive* override the defaults. */
/** Let the host's sshOptions.keepalive* override the defaults. */
hostKeepalive: boolean;
readyTimeout: number;
/** Socket idle timeout, when the transport set one. */
@@ -146,13 +146,12 @@ export async function buildConnectConfig(
const authType = host.authType || "none";
const serverHostId = options.serverHostId ?? host.id;
const isJumpHost = profile === "jump";
const terminalConfig = (host.terminalConfig ?? undefined) as
Record<string, unknown> | undefined;
const sshOptions = host.sshOptions ?? undefined;
const keepalive = defaults.hostKeepalive
? resolveSshKeepalive(
terminalConfig?.keepaliveInterval as number | undefined,
terminalConfig?.keepaliveCountMax as number | undefined,
sshOptions?.keepaliveInterval,
sshOptions?.keepaliveCountMax,
defaults.keepaliveIntervalMs,
defaults.keepaliveCountMax,
)
@@ -184,9 +183,7 @@ export async function buildConnectConfig(
isJumpHost,
preloaded,
),
algorithms: buildSSHAlgorithms(
terminalConfig?.allowLegacyAlgorithms !== false,
),
algorithms: buildSSHAlgorithms(sshOptions?.allowLegacyAlgorithms !== false),
};
if (defaults.env) config.env = { ...defaults.env };
if (defaults.socketTimeout) config.timeout = defaults.socketTimeout;
@@ -179,10 +179,7 @@ const agentProvider: SshAuthProvider = {
quickConnect: true,
requiresSecret: true,
prepare: async (config, host, env) => {
const result = await applyAgentAuth(
config,
host.terminalConfig as Record<string, unknown> | undefined,
);
const result = await applyAgentAuth(config, host.sshOptions);
if ("error" in result) {
return { status: "error", code: "failed", message: result.error };
}
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Client, ConnectConfig } from "ssh2";
import type { WebSocket } from "ws";
import type { HostSshOptions } from "../ssh-options.js";
/**
* What a connection is for, as a free label ("terminal", "metrics"). Auth
@@ -35,7 +36,7 @@ export interface SshConnectHost {
*/
pluginSettings?: Record<string, Record<string, unknown>> | null;
forceKeyboardInteractive?: boolean | null;
terminalConfig?: Record<string, unknown> | null;
sshOptions?: HostSshOptions | null;
jumpHosts?: Array<{ hostId: number }> | null;
useSocks5?: boolean | null;
socks5Host?: string | null;
+15 -5
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../utils/error-message.js";
import { parseSshOptions } from "./ssh-options.js";
import { findUsableCredential } from "./usable-credential.js";
import { resolveExternalSecretRefs } from "./external-secrets.js";
import {
@@ -116,16 +117,25 @@ export async function resolveHostById(
}
}
if (
!ownerEquivalent &&
host.terminalConfig &&
typeof host.terminalConfig === "object" &&
!Array.isArray(host.terminalConfig)
) {
host.terminalConfig = {
...(host.terminalConfig as Record<string, unknown>),
sudoPassword: null,
};
// 2.8 editors kept the sudo password inside terminal_config. It is only
// ever handed out as sudoPassword, and only to the owner.
const { sudoPassword: legacySudo, ...rest } = host.terminalConfig as Record<
string,
unknown
>;
if (ownerEquivalent && !host.sudoPassword && legacySudo) {
host.sudoPassword = legacySudo;
}
host.terminalConfig = rest;
}
// A row the boot copy has not reached yet still has them in terminal_config.
host.sshOptions = parseSshOptions(
host.sshOptions != null ? host.sshOptions : host.terminalConfig,
);
if (typeof host.socks5ProxyChain === "string" && host.socks5ProxyChain) {
try {
host.socks5ProxyChain = JSON.parse(host.socks5ProxyChain as string);
+114
View File
@@ -0,0 +1,114 @@
/**
* A host's SSH connection options (`ssh_data.ssh_options`).
*
* These lived in `terminal_config` next to the terminal's look until 2.9.0.
* The connect pipeline reads them for every SSH connection, not only the
* terminal's, so they are core's and have a column of their own.
*/
import type { HostSshOptions } from "@termix/plugin-sdk/backend";
export type { HostSshOptions };
export const SSH_OPTION_KEYS = [
"keepaliveInterval",
"keepaliveCountMax",
"allowLegacyAlgorithms",
"agentSocketPath",
"agentIdentity",
"agentForwarding",
"environmentVariables",
] as const satisfies readonly (keyof HostSshOptions)[];
function asObject(value: unknown): Record<string, unknown> | null {
let parsed = value;
if (typeof parsed === "string") {
if (!parsed) return null;
try {
parsed = JSON.parse(parsed);
// Some rows were stringified twice.
if (typeof parsed === "string") parsed = JSON.parse(parsed);
} catch {
return null;
}
}
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
}
function finiteNumber(value: unknown): number | undefined {
if (value === null || value === undefined || value === "") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function optionalString(value: unknown): string | null | undefined {
if (value === null) return null;
return typeof value === "string" ? value : undefined;
}
/**
* The SSH options in a value (a JSON string or an object), typed and with
* anything else dropped. A terminal_config value works too, which is how the
* 2.8 keys are read.
*/
export function parseSshOptions(value: unknown): HostSshOptions {
const source = asObject(value);
if (!source) return {};
const options: HostSshOptions = {};
const keepaliveInterval = finiteNumber(source.keepaliveInterval);
if (keepaliveInterval !== undefined)
options.keepaliveInterval = keepaliveInterval;
const keepaliveCountMax = finiteNumber(source.keepaliveCountMax);
if (keepaliveCountMax !== undefined)
options.keepaliveCountMax = keepaliveCountMax;
if (typeof source.allowLegacyAlgorithms === "boolean") {
options.allowLegacyAlgorithms = source.allowLegacyAlgorithms;
}
const agentSocketPath = optionalString(source.agentSocketPath);
if (agentSocketPath !== undefined) options.agentSocketPath = agentSocketPath;
const agentIdentity = optionalString(source.agentIdentity);
if (agentIdentity !== undefined) options.agentIdentity = agentIdentity;
if (typeof source.agentForwarding === "boolean") {
options.agentForwarding = source.agentForwarding;
}
if (Array.isArray(source.environmentVariables)) {
options.environmentVariables = source.environmentVariables
.filter(
(entry): entry is { key: unknown; value: unknown } =>
!!entry && typeof entry === "object",
)
.map((entry) => ({
key: String(entry.key ?? ""),
value: String(entry.value ?? ""),
}));
}
return options;
}
/** Whether a value carries any SSH option at all. */
export function hasSshOptions(value: unknown): boolean {
return Object.keys(parseSshOptions(value)).length > 0;
}
/**
* The column value to store for a host write: the payload's own sshOptions,
* or, from a client that still sends the 2.8 shape, the options inside its
* terminalConfig. Undefined when the payload carries neither, so an update
* leaves the column alone.
*/
export function sshOptionsForWrite(payload: {
sshOptions?: unknown;
terminalConfig?: unknown;
}): string | null | undefined {
if (payload.sshOptions !== undefined) {
if (payload.sshOptions === null) return null;
return JSON.stringify(parseSshOptions(payload.sshOptions));
}
if (hasSshOptions(payload.terminalConfig)) {
return JSON.stringify(parseSshOptions(payload.terminalConfig));
}
return undefined;
}
+7 -9
View File
@@ -10,6 +10,8 @@ import ssh2Pkg, {
type SigningRequestOptions,
} from "ssh2";
import type { HostSshOptions } from "./ssh-options.js";
type KnownPublicKey = KnownPublicKeys[number];
const { BaseAgent } = ssh2Pkg;
@@ -28,11 +30,9 @@ type PortKnockingOptions = {
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function resolveAgentSocket(
terminalConfig: Record<string, unknown> | undefined,
sshOptions: HostSshOptions | null | undefined,
): Promise<{ socketPath: string } | { error: string }> {
const explicit = (
terminalConfig?.agentSocketPath as string | undefined
)?.trim();
const explicit = sshOptions?.agentSocketPath?.trim();
const resolved = explicit || process.env.SSH_AUTH_SOCK;
if (!resolved) {
@@ -135,17 +135,15 @@ function parseAgentIdentityBlob(agentIdentity: string): Buffer | null {
export async function applyAgentAuth(
connectConfig: Record<string, unknown>,
terminalConfig: Record<string, unknown> | undefined,
sshOptions: HostSshOptions | null | undefined,
): Promise<{ socketPath: string } | { error: string }> {
const result = await resolveAgentSocket(terminalConfig);
const result = await resolveAgentSocket(sshOptions);
if ("error" in result) return result;
const { createAgent } = ssh2Pkg;
const agent = createAgent(result.socketPath);
const agentIdentity = (
terminalConfig?.agentIdentity as string | undefined
)?.trim();
const agentIdentity = sshOptions?.agentIdentity?.trim();
if (agentIdentity) {
const publicKeyBlob = parseAgentIdentityBlob(agentIdentity);
if (!publicKeyBlob) {