feat: move the terminal fully into the ssh-terminal plugin (B9)

This commit is contained in:
LukeGus committed 2026-09-23 21:41:22 -05:00
1 parent 535126fd1b
commit 79d77678c9
309 files changed
+41789 -14958

No files matched your search

+131 -5
View File
@@ -292,6 +292,16 @@ export interface PluginWebSocketConnection {
readonly request: unknown;
/** The ws WebSocket. Binary frames and backpressure work as usual. */
readonly socket: unknown;
/** The client address, behind a trusted proxy when one is configured. */
readonly clientIp: string;
/** The public origin the request came in on, for browser redirects. */
readonly requestOrigin: string;
/**
* Whether the user's data key is still unlocked. False for a guest and
* once a session expires, so a long-lived socket can refuse work it can no
* longer decrypt for.
*/
isDataUnlocked: () => boolean;
}
export type PluginWebSocketHandler = (
@@ -305,6 +315,12 @@ export interface PluginWebSocketOptions {
* guest or a token-in-query protocol needs.
*/
public?: boolean;
/**
* With `public`, still resolves a token when the client sent one, so one
* route can serve signed-in users and a guest arriving with a token of its
* own. The handler gets the user id, or an empty one for a guest.
*/
optionalAuth?: boolean;
}
/**
@@ -551,6 +567,22 @@ export interface PluginHosts {
listUsers: () => Promise<PluginShareableUser[]>;
/** Non-system roles the caller may pick as a share target. */
listRoles: () => Promise<PluginShareableRole[]>;
/**
* Marks a live session on a host, for the online indicator. Counted with
* every other feature's sessions; call the returned function once when the
* session ends. Needs hosts:read.
*/
trackSession: (hostId: number) => () => void;
/**
* Adds a recent activity entry for the acting user, rate limited and
* refused for a host they cannot reach, like the dashboard's own route.
* Needs hosts:read.
*/
recordActivity: (
hostId: number,
type: string,
hostName: string,
) => Promise<void>;
}
/**
@@ -579,7 +611,8 @@ export type PluginSshPurpose =
| "remote-desktop"
| "tunnel"
| "file-manager"
| "file-transfer";
| "file-transfer"
| "terminal";
export type PluginSshAuthOutcome =
| { status: "ready" }
@@ -717,6 +750,18 @@ export interface PluginSsh {
/** The pool key withConnection would use, for clearing it. */
poolKey: (pool: string, host: PluginSshHost) => string;
/**
* The host as the acting user may connect to it: RBAC, owner-key
* decryption, shared-host overrides and external secret references
* resolved, secrets included. By sync id when given, since a numeric id
* only names a host on the database that issued it. Null when the host is
* not on this server or the user cannot reach it.
*/
resolveHost: (
hostId: number,
options?: { syncId?: string | null },
) => Promise<PluginSshHost | null>;
/**
* Lower level: fills an ssh2 config for a client the plugin drives itself,
* for transports with their own prompt flow. Never connects.
@@ -729,18 +774,49 @@ export interface PluginSsh {
/** Server-side host id when it differs from host.id. */
serverHostId?: number;
log?: (level: "info" | "warning" | "error", message: string) => void;
/** A person is at the keyboard, so providers may prompt. */
interactive?: boolean;
/**
* The ws socket the host key verifier asks on when a key is new or has
* changed. Without one a changed key is refused.
*/
hostKeySocket?: unknown;
},
) => Promise<{
config: Record<string, unknown>;
outcome: PluginSshAuthOutcome;
}>;
) => Promise<PluginSshPrepared>;
/** Lower level: port knocking, jump hosts or proxy; sets config.sock. */
openTransport: (
host: PluginSshHost,
config: Record<string, unknown>,
options?: {
/** False when the caller already resolved DNS, or a jump host will. */
resolveDns?: boolean;
log?: (level: "info" | "warning" | "error", message: string) => void;
},
) => Promise<{ jumpClient: unknown | null; via: string }>;
/**
* Starts the browser step behind an `<interaction>_auth_required` message.
* The provider for the host's own auth type wins, else the first provider
* that owns the interaction. Rejects with PluginSshInteractionError when
* none can.
*/
startInteraction: (
interaction: string,
request: {
hostId: number;
socket: unknown;
requestOrigin: string;
payload?: Record<string, unknown>;
},
) => Promise<void>;
/** Cancels a pending browser step on every provider that owns it. */
cancelInteraction: (
interaction: string,
request: { hostId?: number; requestId?: string },
) => Promise<void>;
/** What a keyboard-interactive round is. Pure; no capability needed. */
classifyKeyboardInteractive: (
round: {
@@ -764,6 +840,31 @@ export interface PluginSsh {
supportsBackground: (authType: string) => boolean;
}
/** What ctx.ssh.prepare built, plus the provider hooks bound to it. */
export interface PluginSshPrepared {
config: Record<string, unknown>;
outcome: PluginSshAuthOutcome;
/** The auth type whose provider prepared it, null when none exists. */
authType: string | null;
/** The provider's banner hook, bound to this host and connection. */
onBanner?: (banner: string) => PluginSshBannerDecision | undefined;
/** The provider's auth failure hook, bound to this host and connection. */
onAuthFailed?: (context: {
error: Error;
retries: number;
canRetry: boolean;
methodNotAvailable: boolean;
}) => PluginSshAuthOutcome | undefined;
}
/** Thrown by ctx.ssh.startInteraction when no provider can start it. */
export class PluginSshInteractionError extends Error {
constructor(message: string) {
super(message);
this.name = "PluginSshInteractionError";
}
}
export interface PluginSshAuthEnv {
/** The ssh2 Client about to connect; certificate auth patches it. */
client: unknown;
@@ -845,6 +946,12 @@ export interface PluginSshAuthProvider {
requestOrigin: string;
payload: Record<string, unknown>;
}) => Promise<void>;
/** Cancels a pending browser step. */
cancelInteraction?: (request: {
userId: string;
hostId?: number;
requestId?: string;
}) => void | Promise<void>;
}
/**
@@ -986,6 +1093,23 @@ export interface PluginDesktop {
) => Promise<{ success: true }>;
}
/**
* An audit line under an action name of the plugin's choosing (an SSH login),
* for the acting user, next to the plugin_* line every privileged ctx call
* already writes.
*/
export interface PluginAudit {
record: (entry: {
action: string;
resourceType?: string;
resourceId?: string;
resourceName?: string;
details?: string;
success: boolean;
errorMessage?: string;
}) => Promise<void>;
}
export interface PluginContext {
readonly pluginId: string;
readonly manifest: PluginManifest;
@@ -1010,6 +1134,8 @@ export interface PluginContext {
readonly auth: PluginAuth;
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
readonly desktop: PluginDesktop;
/** Audit lines under the plugin's own action names. */
readonly audit: PluginAudit;
/**
* Runs `fn` with `userId` as the acting user, for background work that has
+87 -5
View File
@@ -49,6 +49,13 @@ export interface PluginTabRecord {
label: string;
host?: PluginHostRecord;
data?: Record<string, unknown>;
/** Stable across restores, for a session tab to reattach by. */
instanceId?: string;
/** A backend session a session tab should reattach to. */
restoredSessionId?: string | null;
/** A live shared session this tab joins instead of connecting its own. */
joinSharedSessionId?: string | null;
joinShareId?: string | null;
[key: string]: unknown;
}
@@ -76,7 +83,11 @@ export interface ShellApi {
openSingletonTab: (type: string, options?: OpenTabOptions) => void;
closeTab: (tabId: string) => void;
renameTab: (tabId: string, label: string) => void;
openTerminalTab: (host: PluginHostRecord, path?: string) => void;
/** Saves a quick-connect tab's host as a real host. Absent in some shells. */
saveQuickConnect?: (
tab: PluginTabRecord,
host: PluginHostRecord,
) => Promise<void>;
/** Opens a rail view in the left sidebar. */
openRailView: (id: string) => void;
/** Closes a rail view wherever it is shown. */
@@ -141,6 +152,11 @@ export interface RailItemContribution {
}
export interface PanelProps {
/**
* The command-target tab (see TabOptions.commandTarget) the user is working
* in: the active one, else the one focused last. For panels that act on it.
*/
targetTab?: PluginTabRecord;
/** Whether the panel is the one currently shown. */
active: boolean;
shell: ShellApi;
@@ -157,6 +173,33 @@ export interface PanelOptions {
keepMounted?: boolean;
}
/**
* What a session tab hands the shell through `handleRef`, so split view, tab
* close and reconnect-all can drive it without knowing what kind it is.
*/
export interface TabHandle {
focus?: () => void;
fit?: () => void;
reconnect?: () => void;
disconnect?: () => void;
isConnected?: () => boolean;
sendInput?: (data: string) => void;
paste?: (text: string) => void;
refresh?: () => void;
notifyResize?: () => void;
/** A tab that shows its own share dialog. */
canShare?: () => boolean;
openShareModal?: () => void;
/** A tab whose session the shell's share dialog can share. */
getShareTarget?: () => {
hostId: number;
sessionId: string;
protocol: "ssh";
tabInstanceId?: string;
} | null;
[key: string]: unknown;
}
export interface TabProps {
tab: PluginTabRecord;
host?: PluginHostRecord;
@@ -203,6 +246,15 @@ export interface TabOptions {
panelFrame?: boolean;
/** False keeps the tab out of saved layouts and workspaces. Default true. */
inLayouts?: boolean;
/**
* Its session takes typed commands: the history, macros and SSH tools
* panels act on the one focused last.
*/
commandTarget?: boolean;
/** Paints its own background, so the shell leaves its frame transparent. */
ownBackground?: boolean;
/** Every open is a new tab, labelled "<title> (2)" and so on. */
multiInstance?: boolean;
/** Warms the tab's code before it is opened. */
preload?: () => Promise<unknown>;
}
@@ -504,6 +556,16 @@ export interface TermixApp extends TermixAppInfo {
contribution: SlotContribution,
) => Disposer;
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
/**
* Offers a component to other plugins and to core by id, rendered where
* they choose with `PluginComponent` from @termix/plugin-sdk/ui or
* `usePluginComponent`. The id should start with a name the plugin owns
* ("terminal.view"). The props are the owner's contract; document them.
*/
registerComponent: (
id: string,
component: ComponentType<Record<string, unknown>>,
) => Disposer;
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
@@ -609,7 +671,13 @@ export interface PluginHostBridge {
useTabs: () => TabsApi;
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
useSshAuthTypes: () => { types: SshAuthTypeInfo[]; loaded: boolean };
useSlotContributions: (slotId: string) => SlotContribution[];
useSlotContributions: (
slotId: string,
context?: Record<string, unknown>,
) => SlotContribution[];
usePluginComponent: (
id: string,
) => ComponentType<Record<string, unknown>> | undefined;
}
let host: PluginHostBridge | null = null;
@@ -717,10 +785,24 @@ export function useSshAuthTypes(): {
* metadata (id, titleKey, component). For an owner that needs to build a
* catalog from what was contributed rather than just render it in place, e.g.
* host-metrics listing manager cards plugins added to "host-metrics.managers"
* next to its own. Same permission and `when` filtering as ComponentSlot.
* next to its own. Same permission and `when` filtering as ComponentSlot;
* `context` is what each contribution's `when` sees.
*/
export function useSlotContributions(slotId: string): SlotContribution[] {
return requireHost().useSlotContributions(slotId);
export function useSlotContributions(
slotId: string,
context?: Record<string, unknown>,
): SlotContribution[] {
return requireHost().useSlotContributions(slotId, context);
}
/**
* The component another plugin registered under `id` with
* app.registerComponent, or undefined while that plugin is off.
*/
export function usePluginComponent(
id: string,
): ComponentType<Record<string, unknown>> | undefined {
return requireHost().usePluginComponent(id);
}
/**
+85 -3
View File
@@ -62,6 +62,8 @@ export interface FakeContextOptions {
/** Users and roles ctx.hosts.listUsers/listRoles answer with. */
shareableUsers?: PluginShareableUser[];
shareableRoles?: PluginShareableRole[];
/** Hosts ctx.ssh.resolveHost answers with, secrets included. */
sshHosts?: PluginSshHost[];
}
export interface FakeAuthRegistrations {
@@ -116,6 +118,18 @@ export interface FakePluginContext {
title?: string;
ignoreCert?: boolean;
}>;
/** Every ctx.audit.record entry, in order. */
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
/** Every ctx.hosts.recordActivity call, in order. */
activities: Array<{ hostId: number; type: string; hostName: string }>;
/** Host ids with a live ctx.hosts.trackSession, one entry per session. */
trackedSessions: number[];
/** Every ctx.ssh.startInteraction / cancelInteraction call, in order. */
interactions: Array<{
action: "start" | "cancel";
interaction: string;
request: Record<string, unknown>;
}>;
/** Changes the acting user, as core's request middleware would. */
setActor: (userId: string | undefined) => void;
/** Implementations provided through ctx.services.provide, by service name. */
@@ -187,6 +201,10 @@ export function createFakeContext(
const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = [];
const desktopWindows: FakePluginContext["desktopWindows"] = [];
const audits: FakePluginContext["audits"] = [];
const activities: FakePluginContext["activities"] = [];
const trackedSessions: number[] = [];
const interactions: FakePluginContext["interactions"] = [];
const hostsById = new Map<number, PluginHostSummary>(
(options.hosts ?? []).map((h) => [h.id, h]),
);
@@ -486,6 +504,25 @@ export function createFakeContext(
},
listUsers: async () => options.shareableUsers ?? [],
listRoles: async () => options.shareableRoles ?? [],
trackSession: (hostId) => {
trackedSessions.push(hostId);
let active = true;
return () => {
if (!active) return;
active = false;
const index = trackedSessions.indexOf(hostId);
if (index >= 0) trackedSessions.splice(index, 1);
};
},
recordActivity: async (hostId, type, hostName) => {
activities.push({ hostId, type, hostName });
},
},
audit: {
record: async (entry) => {
audits.push({ ...entry });
},
},
ssh: {
@@ -512,8 +549,24 @@ export function createFakeContext(
}),
poolKey: (pool, host) =>
`${pool}:${host.userId}:${host.ip}:${host.port}:${host.username}`,
prepare: async () => ({ config: {}, outcome: { status: "ready" } }),
resolveHost: async (hostId, resolveOptions) =>
(options.sshHosts ?? []).find((host) =>
resolveOptions?.syncId
? host.syncId === resolveOptions.syncId
: host.id === hostId,
) ?? null,
prepare: async () => ({
config: {},
outcome: { status: "ready" },
authType: null,
}),
openTransport: async () => ({ jumpClient: null, via: "direct" }),
startInteraction: async (interaction, request) => {
interactions.push({ action: "start", interaction, request });
},
cancelInteraction: async (interaction, request) => {
interactions.push({ action: "cancel", interaction, request });
},
classifyKeyboardInteractive: ({ prompts }, host) => ({
kind: "auto",
responses: prompts.map((p) =>
@@ -586,6 +639,10 @@ export function createFakeContext(
hostShares,
auth,
desktopWindows,
audits,
activities,
trackedSessions,
interactions,
setActor: (userId) => {
actor = userId;
},
@@ -616,6 +673,8 @@ export interface MockContextOptions {
permissions?: string[];
/** Hosts ctx.hosts.list/get/checkAccess serve. See FakeContextOptions. */
hosts?: PluginHostSummary[];
/** Hosts ctx.ssh.resolveHost serves. See FakeContextOptions. */
sshHosts?: PluginSshHost[];
}
export interface MockPluginContext extends FakePluginContext {
@@ -650,6 +709,7 @@ export function createMockCtx(
router: options.router,
permissions: options.permissions,
hosts: options.hosts,
sshHosts: options.sshHosts,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -795,6 +855,14 @@ export function createMockCtx(
require("hosts:write");
return ctx.hosts.listRoles();
},
trackSession: (hostId) => {
require("hosts:read");
return ctx.hosts.trackSession(hostId);
},
recordActivity: async (hostId, type, hostName) => {
require("hosts:read");
return ctx.hosts.recordActivity(hostId, type, hostName);
},
},
ssh: {
@@ -814,9 +882,23 @@ export function createMockCtx(
require("credentials:use");
return ctx.ssh.prepare(host, prepareOptions);
},
openTransport: async (host, config) => {
openTransport: async (host, config, transportOptions) => {
require("ssh:connect");
return ctx.ssh.openTransport(host, config);
return ctx.ssh.openTransport(host, config, transportOptions);
},
resolveHost: async (hostId, resolveOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.resolveHost(hostId, resolveOptions);
},
startInteraction: async (interaction, request) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.startInteraction(interaction, request);
},
cancelInteraction: async (interaction, request) => {
require("ssh:connect");
return ctx.ssh.cancelInteraction(interaction, request);
},
jumpChain: async (jumpHosts, chainOptions) => {
require("ssh:connect");
+1
View File
@@ -112,6 +112,7 @@ const offline = () =>
loaded: true,
}),
useSlotContributions: () => [],
usePluginComponent: () => undefined,
};
afterEach(() => {