feat: ship the ssh terminal as a first-party in-process plugin

Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
This commit is contained in:
LukeGus committed 2026-09-21 08:28:50 -05:00
1 parent e040b12045
commit c94a453f1e
43 files changed
+2426 -38

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
# SSH Terminal
The interactive SSH terminal, packaged as a first-party plugin.
## Why this plugin runs in-process
Every other plugin runs in a `worker_threads` worker and reaches the server
through a structured-clone `postMessage` boundary. That boundary is what makes
the capability gate and the audit trail mean anything: a worker plugin cannot
hold a db handle, an ssh2 `Client` or a live socket, so every privileged action
has to go through the broker.
This plugin cannot work that way. It _is_ the SSH transport rather than a
consumer of one:
- it owns a `WebSocketServer` on port 30002,
- it holds long-lived `ssh2.Client` objects and PTY streams,
- it needs plaintext host credentials to connect at all.
None of those can cross a structured-clone boundary, and `ctx.hosts` returns a
13-field allowlist that deliberately excludes `password`, `key` and
`keyPassword`. So `ssh-terminal` runs on the main thread instead, under the
`process:transport-owner` tier.
Membership in that tier is a hardcoded allowlist in
`src/backend/plugins/first-party.ts`. A manifest cannot ask for it: declaring
`process:transport-owner` without being on the list is a validation error. The
tier has no capability gate and no audit interception, which is only defensible
because nothing installable can enter it.
## What lives where
The terminal's implementation stays in `src/backend/hosts/terminal/`. Only the
lifecycle lives here. Two reasons it was not copied in:
- `session-manager.ts` has six consumers outside the terminal (terminal routes,
open-tabs, user-image-storage, collab, and session-sharing twice). It is
shared infrastructure, not plugin-private code.
- host key verification (`host-key-verifier.ts`), jump host chaining
(`jump-host-chain.ts`) and host resolution (`host-resolver.ts`) each have
around eight consumers already. They keep working by _not_ moving.
The SSH connection pool also stays in core. The terminal never used it: it
calls `new Client()` directly. The pool's consumers are metrics, tmux, tunnel,
fleet routes, AI tools, automations and `ctx.ssh` itself, so moving it here
would have made all of them depend on the terminal being enabled.
Only `SSHAuthManager` is terminal-exclusive.
## WebSocket: still port 30002, not /plugin-api/
The terminal WS was deliberately left on its own port and path.
`/plugin-api` has no auth middleware, so routing terminal auth through it would
be a regression. Beyond that, `/ssh/websocket/` is mapped in both nginx configs,
three frontend call sites build that URL (`Terminal.tsx`, `SharedSessionView`,
`CollabRoomTab`), Electron's `buildOriginWsUrl` pins `localPort: 30002`, and the
8-attempt reconnect plus detach/reattach buffering all depend on the current
path.
The plugin owns the port's lifecycle instead: enabling it calls
`startTerminalServer()`, disabling it calls `stopTerminalServer()`, which closes
live sessions and frees the port.
## Disabling
Disabling behaves like any other plugin. No confirmation dialog, no special
casing. The terminal tab stops being offered from the host list, command
palette and quick connect, the port stops listening, and every other feature
(metrics, docker, tunnels, file manager) keeps working off its own connections.
+83
View File
@@ -0,0 +1,83 @@
/**
* SSH Terminal - first-party, in-process plugin.
*
* This plugin runs on the main thread rather than in a worker, because it IS
* the SSH transport rather than a consumer of one: it owns a WebSocket server
* and long-lived ssh2 clients, and neither a live socket nor an ssh2 Client can
* cross the structured-clone postMessage boundary a worker plugin talks over.
* See src/backend/plugins/first-party.ts for why that tier is a hardcoded
* allowlist and not something a manifest can ask for.
*
* The terminal's implementation stays in core (src/backend/hosts/terminal/)
* rather than being copied in here. Two reasons:
* - session-manager has six consumers outside the terminal (terminal routes,
* open-tabs, user-image-storage, collab, and session-sharing twice), so it
* is shared infrastructure, not plugin-private code.
* - the terminal needs plaintext host credentials to connect, which ctx.hosts
* deliberately never returns (PluginHostView is a 13-field allowlist that
* excludes password, key and passphrase).
*
* What this plugin owns is the lifecycle: enabling it starts the WS server,
* disabling it closes live sessions and frees the port.
*/
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Resolves the compiled core terminal module.
*
* The plugin ships beside dist/backend, so in a built server the module is at
* dist/backend/backend/hosts/terminal/index.js. Under vitest and dev the
* plugin is loaded from the repo instead, where the source .ts is the thing
* that exists and tsx is already handling it.
*/
async function loadTerminalModule() {
const here = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const candidates = [
// Built: plugins/ssh-terminal/backend -> dist/backend/backend/...
path.resolve(here, "../../../backend/backend/hosts/terminal/index.js"),
// Repo layout, compiled output present.
path.resolve(here, "../../../dist/backend/backend/hosts/terminal/index.js"),
// Repo layout, running from source under tsx.
path.resolve(here, "../../../src/backend/hosts/terminal/index.ts"),
];
for (const candidate of candidates) {
try {
require.resolve(candidate);
} catch {
continue;
}
return import(new URL(`file://${candidate.replace(/\\/g, "/")}`).href);
}
throw new Error(
`ssh-terminal could not locate the terminal module. Looked in:\n ${candidates.join("\n ")}`,
);
}
let terminal = null;
export async function activate(ctx) {
terminal = await loadTerminalModule();
await terminal.startTerminalServer();
// Published so collab and session-sharing can reach live sessions without
// importing a plugin. Revoked automatically when this plugin is disabled.
ctx.registry.provide("terminal.sessions", terminal.sessionManager ?? null);
ctx.log.info(
`SSH terminal listening on ${terminal.TERMINAL_WS_PORT ?? 30002}`,
);
}
export async function deactivate() {
if (!terminal) return;
await terminal.stopTerminalServer();
terminal = null;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Frontend half of the ssh-terminal plugin.
*
* Registers the existing Terminal component through the extension seams that
* already exist in the shell (registerRailItem / registerTabComponent). The
* component itself is unchanged and still lives in src/ui/features/terminal -
* this only controls whether the shell knows about it.
*
* register() is called when the plugin is enabled, unregister() when it is
* disabled, so the tab and rail entry appear and disappear with the plugin.
*/
export const id = "ssh-terminal";
export const tabId = "terminal";
export async function register({
registerRailItem,
registerTabComponent,
icons,
}) {
registerTabComponent(tabId, () =>
import("@/features/terminal/Terminal").then((m) => ({
default: m.Terminal,
})),
);
registerRailItem({
id: tabId,
icon: icons.SquareTerminal,
labelKey: "nav.terminal",
kind: "tab",
});
}
export async function unregister({
unregisterRailItem,
unregisterTabComponent,
}) {
unregisterRailItem(tabId);
unregisterTabComponent(tabId);
}
+30
View File
@@ -0,0 +1,30 @@
{
"id": "ssh-terminal",
"name": "SSH Terminal",
"version": "1.0.0",
"description": "Interactive SSH terminal sessions, with jump hosts, host key verification and session sharing.",
"author": { "name": "Termix" },
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Terminal",
"icon": "SquareTerminal",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["process:transport-owner"],
"contributes": {
"tabs": [
{
"id": "terminal",
"titleKey": "nav.terminal",
"icon": "SquareTerminal",
"openFrom": ["rail", "host-context-menu", "palette"]
}
]
},
"sidecars": []
}