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

@@ -1,20 +1,50 @@
import {
hostSettingsFromTerminalConfig,
terminalConfigFromHostSettings,
} from "../shared/terminal-settings.js";
const SWITCH_KEYS = [
"enableTerminal",
"enableTerminalToolbar",
"enableCommandHistory",
] as const;
/**
* Registered as ctx.registry.provide("ssh-terminal.hostImportNormalizer", ...)
* so a Termix-JSON host import keeps its terminal switches, which used to be
* host columns and are this plugin's host settings now.
* host columns, and its terminal look and behavior, which a 2.8 or early 2.9
* export carried in terminalConfig. An export that already carries this
* plugin's pluginSettings has had them written by core, so terminalConfig is
* only read without them.
*/
export function hostImportNormalizer(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
const keys = [
"enableTerminal",
"enableTerminalToolbar",
"enableCommandHistory",
] as const;
if (keys.every((key) => raw[key] === undefined)) return null;
const values: Record<string, unknown> = {};
for (const key of keys) {
for (const key of SWITCH_KEYS) {
if (raw[key] !== undefined) values[key] = raw[key] !== false;
}
return values;
const carried = (raw.pluginSettings as Record<string, unknown> | undefined)?.[
"ssh-terminal"
];
const hasCarried =
!!carried && typeof carried === "object" && Object.keys(carried).length > 0;
if (!hasCarried) {
Object.assign(values, hostSettingsFromTerminalConfig(raw.terminalConfig));
}
return Object.keys(values).length > 0 ? values : null;
}
/**
* Registered as "ssh-terminal.hostPayloadLegacy": the terminal keys of the
* 2.8 terminalConfig, for clients that still read it (Termix-Mobile). Core
* merges them into the terminalConfig it sends, never over a key it set.
* Remove once the mobile app reads pluginSettings["ssh-terminal"].
*/
export function hostPayloadLegacy(
values: Record<string, unknown>,
): Record<string, unknown> {
return { terminalConfig: terminalConfigFromHostSettings(values) };
}
+2 -1
View File
@@ -1,7 +1,7 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createTerminalLogger } from "./helpers.js";
import { hostImportNormalizer } from "./host-import.js";
import { hostImportNormalizer, hostPayloadLegacy } from "./host-import.js";
import { createHistoryRepository } from "./history-repository.js";
import { registerTerminalRoutes } from "./routes.js";
import {
@@ -171,6 +171,7 @@ export async function activate(ctx: PluginContext) {
"ssh-terminal.hostImportNormalizer",
hostImportNormalizer,
);
ctx.registry.provide("ssh-terminal.hostPayloadLegacy", hostPayloadLegacy);
ctx.log.info("SSH terminal mounted at /plugin-ws/ssh-terminal/terminal");
}
@@ -13,6 +13,7 @@ import type { TerminalSessionManager } from "./session-manager.js";
import {
ADMIN_KEYS,
HOST_KEYS,
MOVABLE_USER_KEYS,
readClientSettings,
readImageStorageSettings,
} from "./settings.js";
@@ -524,6 +525,99 @@ export function registerTerminalRoutes(
}
});
/**
* @openapi
* /plugin-api/ssh-terminal/user-settings:
* put:
* summary: Save some of the caller's terminal settings
* description: Writes the caller's local echo, link click and command autocomplete settings. The terminal uses it once, to move values a browser kept before 2.9.0.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* localEcho:
* type: string
* linkClickBehavior:
* type: string
* commandAutocomplete:
* type: boolean
* responses:
* 200:
* description: Saved.
* 400:
* description: A value the setting does not accept.
*/
router.put("/user-settings", json, async (req, res) => {
const userId = ctx.currentActor();
if (!userId) return res.status(401).json({ error: "Sign in first" });
const body = (req.body ?? {}) as Record<string, unknown>;
try {
for (const key of MOVABLE_USER_KEYS) {
if (body[key] !== undefined) {
await ctx.settings.setUser(userId, key, body[key]);
}
}
res.json({ success: true });
} catch (err) {
res.status(400).json({ error: getErrorMessage(err, "Invalid setting") });
}
});
/**
* @openapi
* /plugin-api/ssh-terminal/hosts/{hostId}/auto-tmux:
* put:
* summary: Turn auto tmux on or off for a host
* description: Sets the host's autoTmux terminal setting. Used by the "enable Auto-Tmux" action shown when a persisted session expires. Needs edit access to the host.
* tags:
* - Terminal
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* responses:
* 200:
* description: Saved.
* 400:
* description: Invalid host id or value.
* 403:
* description: No edit access to the host.
*/
router.put("/hosts/:hostId/auto-tmux", json, async (req, res) => {
const hostId = Number(req.params.hostId);
const enabled = (req.body ?? {}).enabled;
if (!Number.isInteger(hostId) || typeof enabled !== "boolean") {
return res.status(400).json({ error: "Invalid host or value" });
}
try {
const access = await ctx.hosts.checkAccess(hostId, "edit");
if (!access.hasAccess) {
return res.status(403).json({ error: "Access denied to host" });
}
await ctx.settings.setHost(hostId, HOST_KEYS.autoTmux, enabled);
res.json({ success: true, autoTmux: enabled });
} catch (err) {
log.error("Failed to update auto tmux", err);
res.status(500).json({ error: "Failed to update auto tmux" });
}
});
/**
* @openapi
* /plugin-api/ssh-terminal/command-history:
@@ -9,6 +9,14 @@ import {
type TerminalImageStorageSettings,
} from "./images/image-storage-settings.js";
import { DEFAULT_TIMEOUT_MINUTES } from "./session-manager.js";
import {
DEFAULT_APPEARANCE,
NEW_HOST_ADMIN_KEYS,
pickTerminalValues,
readUserSettings,
type NewHostTerminalDefaults,
type TerminalUserSettings,
} from "../shared/terminal-settings.js";
/**
* Admin setting keys, as declared in manifest.json. The image storage fields
@@ -37,8 +45,16 @@ export const HOST_KEYS = {
terminalToolbarShowStatus: "terminalToolbarShowStatus",
terminalToolbarFade: "terminalToolbarFade",
enableCommandHistory: "enableCommandHistory",
autoTmux: "autoTmux",
} as const;
/** User settings a browser kept in localStorage before 2.9.0. */
export const MOVABLE_USER_KEYS = [
"localEcho",
"linkClickBehavior",
"commandAutocomplete",
] as const;
const IMAGE_FIELD_BY_LEGACY_KEY: Record<string, string> = {
[TERMINAL_IMAGE_STORAGE_KEYS.mode]: ADMIN_KEYS.imageStorageMode,
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: ADMIN_KEYS.imageLocalDir,
@@ -68,6 +84,10 @@ export interface ClientSettings {
sessionPersistence: boolean;
commandHistoryEnabled: boolean;
touchInput: TouchInputSettings;
/** What a new host's terminal starts with. */
newHostDefaults: NewHostTerminalDefaults;
/** The caller's own terminal settings. */
user: TerminalUserSettings;
}
/** What every signed-in user's terminal needs, whether or not they are an admin. */
@@ -84,6 +104,8 @@ export async function readClientSettings(
touch = null;
}
}
const actor = ctx.currentActor();
const user = actor ? await ctx.settings.getAll("user", actor) : {};
return {
sessionTimeoutMinutes:
Number.isFinite(timeout) && timeout > 0
@@ -92,5 +114,26 @@ export async function readClientSettings(
sessionPersistence: all[ADMIN_KEYS.sessionPersistence] !== false,
commandHistoryEnabled: all[ADMIN_KEYS.commandHistoryEnabled] !== false,
touchInput: normalizeTouchInputSettings(touch),
newHostDefaults: readNewHostDefaults(all),
user: readUserSettings(user),
};
}
/** The admin's new-host terminal defaults, typed, with the built-in ones under them. */
export function readNewHostDefaults(
admin: Record<string, unknown>,
): NewHostTerminalDefaults {
const byHostKey: Record<string, unknown> = {};
for (const [adminKey, hostKey] of Object.entries(NEW_HOST_ADMIN_KEYS)) {
byHostKey[hostKey] = admin[adminKey];
}
return {
fontSize: DEFAULT_APPEARANCE.fontSize,
fontFamily: DEFAULT_APPEARANCE.fontFamily,
theme: DEFAULT_APPEARANCE.theme,
cursorStyle: DEFAULT_APPEARANCE.cursorStyle,
cursorBlink: DEFAULT_APPEARANCE.cursorBlink,
autoTmux: false,
...pickTerminalValues(byHostKey, Object.values(NEW_HOST_ADMIN_KEYS)),
} as NewHostTerminalDefaults;
}
@@ -8,6 +8,7 @@ import ssh2Pkg, {
} from "ssh2";
import {
PluginSshInteractionError,
type HostSshOptions,
type PluginContext,
type PluginSshHost,
type PluginSshPrepared,
@@ -32,6 +33,7 @@ import {
type TerminalLogger,
} from "./helpers.js";
import { SSHAuthManager } from "./keyboard-prompt.js";
import { HOST_KEYS } from "./settings.js";
import {
isMessageAllowedForParticipant,
type TerminalSessionManager,
@@ -76,11 +78,8 @@ interface ConnectToHostData {
protocol?: "tcp" | "udp";
delay?: number;
}>;
terminalConfig?: {
keepaliveInterval?: number;
keepaliveCountMax?: number;
[key: string]: unknown;
};
/** Keepalive, legacy algorithms, agent and environment options. */
sshOptions?: HostSshOptions | null;
/** When true, ignore key material and force password auth (fallback path). */
passwordFallbackOnly?: boolean;
};
@@ -1434,7 +1433,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
socks5Password?: string;
socks5ProxyChain?: unknown;
portKnockSequence?: ConnectToHostData["hostConfig"]["portKnockSequence"];
terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"];
sshOptions?: HostSshOptions | null;
})
| null = null;
@@ -1520,8 +1519,8 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
hostConfig.socks5ProxyChain = resolvedHostData.socks5ProxyChain;
}
if (!hostConfig.terminalConfig && resolvedHostData.terminalConfig) {
hostConfig.terminalConfig = resolvedHostData.terminalConfig;
if (!hostConfig.sshOptions && resolvedHostData.sshOptions) {
hostConfig.sshOptions = resolvedHostData.sshOptions;
}
if (
@@ -1549,6 +1548,17 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
const serverHostId = resolveServerHostId(id, resolvedHostData);
let autoTmuxEnabled = false;
if (serverHostId != null) {
try {
autoTmuxEnabled =
(await ctx.settings.getHost(serverHostId, HOST_KEYS.autoTmux)) ===
true;
} catch {
autoTmuxEnabled = false;
}
}
// Resolve credentials server-side when frontend doesn't provide them
let resolvedCredentials = {
username,
@@ -2115,7 +2125,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
}
});
const autoTmux = hostConfig.terminalConfig?.autoTmux === true;
const autoTmux = autoTmuxEnabled;
// Helper to run initialPath/executeCommand after the shell
// (or tmux session) is ready
@@ -2692,7 +2702,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
keyType: resolvedCredentials.keyType,
certPublicKey: resolvedCredentials.certPublicKey,
forceKeyboardInteractive: hostConfig.forceKeyboardInteractive,
terminalConfig: hostConfig.terminalConfig,
sshOptions: hostConfig.sshOptions,
jumpHosts: hostConfig.jumpHosts,
useSocks5: hostConfig.useSocks5,
socks5Host: hostConfig.socks5Host,
@@ -2758,7 +2768,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
return;
}
if (hostConfig.terminalConfig?.agentForwarding) {
if (hostConfig.sshOptions?.agentForwarding) {
if (connectConfig.privateKey) {
try {
const parsed = ssh2Utils.parseKey(