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

+109 -18
View File
@@ -248,6 +248,20 @@ arrives with a share token), the Docker console (per-message auth), and the
Guacamole display (a single-use encrypted connection token in the query, minted
by an authenticated route).
**B9** added `optionalAuth` for a public route: core still verifies a token
when the client sent one and hands the handler that user, else an empty id.
The terminal uses it, because one socket serves both signed-in users and
share-link or room guests, who arrive with a share token instead. The route is
public so a guest can reach it at all; guest auth itself stays in the plugin's
handler (noted for D2). Every route connection now also carries `clientIp`,
`requestOrigin` and `isDataUnlocked()`, the data-key check a long-lived socket
repeats per message so an expired session stops being served.
Socket and ssh2 event listeners fire outside the upgrade's async context, so a
plugin that calls privileged ctx members from them binds them with Node's
`AsyncResource.bind` while the actor is still set; the terminal does this for
every listener it registers.
### 7. Settings
Plugins declare settings fields in the manifest with scope `admin`, `user` or
@@ -426,8 +440,10 @@ to list manager cards plugins added to `host-metrics.managers` next to its
own). The SDK has no runtime dependencies: the hooks delegate to a host
bridge core installs.
Slots core owns: `terminal.toolbar`, `terminal.dock`, `terminal.overlay`
(declared by ssh-terminal), `onboarding.steps`, `onboarding.features`,
Slots core owns: `terminal.toolbar`, `terminal.toolbarStatus`,
`terminal.sidePanel`, `terminal.overlay` (declared by ssh-terminal; **B9**
renamed `terminal.dock` to `terminal.sidePanel` and added the status slot,
where host-metrics puts its CPU, memory and disk bars), `onboarding.steps`, `onboarding.features`,
`onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`
and `session.remoteDisplay`. `host-metrics.managers` (component slot,
**B3**) is host-metrics's own: tailscale contributes its manager card there
@@ -438,6 +454,29 @@ plugin actions the same way when a core view shows a plugin's data:
`files.openHost`, and **B7**'s `tunnels.statuses` and `tunnels.open` behind the
dashboard's active tunnel counter, which reads as zero while tunnels is off.
**B9** moved the terminal itself into ssh-terminal, which adds
`terminal.open(host, { path?, joinSharedSessionId?, joinShareId?, label? })`
(the file manager's "open terminal here", the connections panel's "join a
shared session") and replaces `ShellApi.openTerminalTab`, which the shell no
longer has.
**Components by id.** `app.registerComponent(id, component)` offers a
component to other plugins and core, which render it with `PluginComponent`
from `@termix/plugin-sdk/ui` (a fallback while the owner is off) or
`usePluginComponent(id)`. ssh-terminal registers `terminal.view`, the
embeddable terminal the collab room, the homepage SSH widget, the tmux
monitor and the file manager's window render instead of importing it; its
handle comes back through a `handleRef` prop, since a registered component
cannot take a React ref.
**Session tabs.** `TabOptions` gained `commandTarget` (history, macros and SSH
tools act on the last one focused; `PanelProps.targetTab` hands a panel that
tab), `ownBackground` (the shell leaves the frame transparent) and
`multiInstance` (each open is a new tab). `TabHandle` documents what a session
tab puts on `handleRef`: focus, reconnect, disconnect and, for sharing,
`getShareTarget()`, which the shell's own share dialog uses. These replaced
every check of the `terminal` and `local-terminal` tab types in the shell.
**B2** added a live-terminal-session surface, for a plugin that needs to push
resolved text into an open SSH session rather than just fill a slot with a
button: ssh-terminal registers `terminal.listSessions()` (open sessions with
@@ -466,12 +505,28 @@ skipped, and it stays in the next snapshot (`isUnregisteredPluginTabType` in
`src/ui/plugin-host/sdk-ui.ts`. Adding to it is a contract change; removing
from it is a breaking one. Today it exports: alert, alert-dialog, badge,
button, card, checkbox, dialog, dropdown-menu, input, label, password-input,
select, select2, separator, switch, textarea, tooltip, section-card,
select, select2, separator, switch, textarea, tooltip, sheet, section-card,
metric-card, charts, the card grid, `ConnectionScreen` and the connection
status helpers, `SnippetVariablesDialog`, `FullScreenAppWrapper`, the
connection log context, `TOTPDialog`, `SSHAuthDialog`, `WarpgateDialog`,
`useTabs`/`useTabsSafe`, `ActionSlot`, `ComponentSlot` and `FOLDER_COLORS` (the
colour swatches folders and workspaces pick from). Publishing its
`PassphraseDialog`, `OPKSSHDialog` (until opkssh moves in Phase C),
`HostKeyVerificationDialog`, `useTabs`/`useTabsSafe`, `ActionSlot`,
`ComponentSlot`, `PluginComponent` and `FOLDER_COLORS` (the colour swatches
folders and workspaces pick from).
**B9** added what a terminal-like surface needs from the shell: `cn`,
`useConfirmation`, `useIsMobile`, `runAdaptivePolling`; the terminal look
(`TERMINAL_THEMES`, `TERMINAL_FONTS`, `DEFAULT_TERMINAL_CONFIG`,
`resolveTermixThemeColors`, `ensureTerminalFontsLoaded`, the font zoom limits,
the clipboard helpers and `useAppTheme`, the full theme id rather than light or
dark), shared by the SSH and local terminals, the docker console and serial
from `src/ui/lib/terminal-look/`; keyboard handling shared with the shell
(`findMatchingKeybinding`, `globalShortcutHandler`, `isTabJumpHotkey`); the
connection helpers (`isElectron`, `resolveConnectionOrigin`, `pluginWsUrl`,
`hydrateLocalSharedHostAuth`, `useConnectionDefaults`); and a few core APIs
(`logActivity`, `getHostPassword`, `patchOpenTab`, `getUserPreferences`,
`parseCustomKeybindings`, `setHostAutoTmux`, `getCookie`). The APIs are a
stopgap in the right place: D1 turns them into typed bridge members. Publishing its
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
#### Strings
@@ -520,11 +575,34 @@ every enabled plugin that declares `contributes.settings.host`, it looks up
`"<id>.hostImportNormalizer"` and, if the plugin registered one, calls it with
the raw imported row and writes back whatever it returns (or nothing, for
`null`). This is not part of the SDK's typed `ctx` surface - it is a plain
`ctx.registry` convention, the same mechanism `terminal.sessions` and
`remote-desktop.sessions` already use, just with a name core's own code knows
`ctx.registry` convention, the same mechanism `remote-desktop.sessions`
already uses, just with a name core's own code knows
to look for. web-endpoint (`src/backend/host-import.ts`) is the first plugin
to register one.
**B9**'s terminal provides `sessions.live` (live SSH sessions: look up, end,
remove a share's participants, hand a room share's control over, subscribe to
output, write) and `terminal.history` (the actor's command history, which the
AI assistant reads). It consumes three optional services that later steps
provide: `tmux.sessions` (B10; without it the terminal skips tmux attach),
`sessions.sharing` (B12; member joins and collab room events) and
`recordings.writer` (B13; without it nothing is recorded). The terminal keeps
the asciicast formatting and the 300 ms batched flush (issue #1049) and hands
the provider one `append` per batch. Service names need a dot, so "tmux" and
"recordings" became `tmux.sessions` and `recordings.writer`.
A service call runs a permission check for its actor, so a share-link guest,
who has no user, cannot make one. Guest link resolution is therefore
published on `ctx.registry` as `sessions.sharing.guests` instead: the token is
the authority there.
**Core consuming a plugin service.** Session sharing, collab and the open
tabs route are still core and reach live terminal sessions through
`getServiceImplementation(service, range)` in `service-registry.ts`
(`hosts/live-terminal-sessions.ts` wraps it). Core already authorized the
request, so this skips the per-call check and returns the same object
plugins get, or undefined while no compatible provider runs.
### 10. Lifecycle
`activate(ctx)` creates all state. Everything registered through `ctx` or `app`
@@ -1082,6 +1160,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.notify.*` | `notify:send` | A6 |
| `ctx.auth.*` | `auth:provide` | **A8** |
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.fetch` | `network:outbound` | B |
`ctx.ssh` was pulled forward from B so plugin transports go through core's
@@ -1107,6 +1186,15 @@ connect pipeline instead of importing ssh2 helpers from core:
since the file-manager plugin's interactive connect route and its dedicated
transfer sessions both need their own keepalive/timeout defaults rather than
falling back to the generic `"plugin"` purpose.
- **B9** added what an interactive transport needs, for the terminal:
`resolveHost(hostId, { syncId? })` (the host with secrets, by sync id first,
audited), the `"terminal"` purpose, `interactive` and `hostKeySocket` on
`prepare` (so a changed host key can be accepted over the socket), the
provider's `onBanner` and `onAuthFailed` hooks bound on its result,
`openTransport(..., { resolveDns, log })`, and `startInteraction` /
`cancelInteraction` for the browser sign-ins (opkssh, step-ca, vault), which
the terminal used to reach by import. Providers gained an optional
`cancelInteraction`.
- **B7** added `sock` to the connect options: an already-open stream to the
host, such as a `forwardOut` channel through another host. `connect` then
skips the transport step (port knocking, proxy, jump hosts, DNS) and runs
@@ -1165,6 +1253,12 @@ existing ones (proxmox's discovery and sync flow is the first caller):
detail" surface those calls need to read back, not a wider read grant than
`hosts:read` gives through `list`/`get`.
**B9** added `trackSession(hostId)` (the online indicator, counted with every
other feature's sessions; synchronous, so only the declaration is checked, like
`ctx.http.router`) and `recordActivity(hostId, type, hostName)` (the same rate
limit and access rule as the dashboard's route, which now shares
`services/recent-activity.ts` with it). Both need `hosts:read`.
`PluginHostRecord` carries no secret auth material (password, key, vault
token): a plugin that creates a host picks an `authType` and, for
`"credential"`, a `credentialId` it does not need to see the contents of.
@@ -1318,7 +1412,8 @@ and that is bundled into its output.
**Backend** (plus every node builtin): `@termix/plugin-sdk`, `express`, `ssh2`,
`ws`, `multer`, `cookie-parser`, `axios`, `jszip`, `guacamole-lite`,
`@anthropic-ai/sdk`, `drizzle-orm`.
`@anthropic-ai/sdk`, `drizzle-orm`, `sharp` (native, so it cannot be bundled;
the terminal's image upload loads it on first use).
**Frontend**: `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`,
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend`,
@@ -1666,8 +1761,8 @@ What the lint fence enforces today, in `eslint.config.mjs`:
| Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin code | **Error** | 0 | - |
| A plugin frontend importing core through `@/` | Warning | 135 files | D1 |
| A plugin importing core by relative path | Warning | 67 files | D1 |
| A plugin frontend importing core through `@/` | Warning | 130 files | D1 |
| A plugin importing core by relative path | Warning | 66 files | D1 |
| A plugin importing another plugin's source | Warning | 3 files | B18 |
A warning does not fail a build, so the counts are held by
@@ -1702,14 +1797,10 @@ Known specifics:
**B6** moved file-manager off port 30004 onto `/plugin-api/file-manager/`,
and **B7** moved tunnels off port 30003 onto `/plugin-api/tunnels/` and
`/plugin-ws/tunnels/c2s/stream`.
- The terminal component and `TerminalTabContent` stay in core until the
terminal's Phase B step (session manager, split view). ssh-terminal
registers them through the legacy alias; **B6** switched every core caller
of the file manager over to `invokeAction("files.openHost"/"files.openEditor")`
instead of the `openFileManager`/`openFileInEditor` shell callbacks it
removed, so `TerminalWindow.tsx`'s embedding of the core terminal component
is now the only file-manager-adjacent legacy-core import left, tracked the
same way as any other plugin depending on an unconverted core component.
- **B9** moved the terminal, its session manager, the local terminal and the
command history panel into ssh-terminal, which imports nothing from core.
The file manager's terminal window renders the `terminal.view` component
instead of importing the core terminal.
- Host data columns (`enableDocker`, `enableRdp`, `guacamoleConfig` and so on)
stay in core types until Phase B moves them. Only UI branches on them left
the shell.
+88
View File
@@ -144,3 +144,91 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
to ask Electron's main process for something, extend
`BACKEND_REQUEST_HANDLERS` there rather than building a parallel channel.
No owner needed unless a second caller appears.
- **B9 (ssh-terminal):** session sharing has no provider yet. The terminal
consumes `sessions.sharing` v1 (member joins through `authorizeJoin` and
`recordJoin`, collab room events through `subscribeRoom`/`unsubscribeRoom`)
and reads guest link resolution from `ctx.registry` as
`sessions.sharing.guests` (`resolve`, `recordJoin`), all typed in
`plugins/ssh-terminal/src/backend/services.ts`. Until something provides
them, share-link and room guests are refused and in-app joins fail. Core's
session-sharing, collab and open-tabs code reach the terminal through
`sessions.live` already. Owner: B12, which provides both from the
session-sharing plugin (the lookups are the old code in
`hosts/session-sharing` and `hosts/collab`: share repo, room repo, guest
rate limit, `canJoinRoomStageShare`, `session_sharing_globally_enabled`,
host `allowSessionSharing`, `getAuditUsername` for labels, `collabRoomHub`).
B12 also moves `eventsWsPath` in `collab/routes.ts` and the share dialog the
shell opens from a tab's `getShareTarget()`.
- **B9 (ssh-terminal), for D2:** the terminal socket is a public route with
`optionalAuth`, because share-link and room guests authenticate inside the
handler with their token. Guest auth itself is unchanged; review it with the
rest of the public routes.
- **B9 (ssh-terminal):** tmux has no provider yet. The terminal calls the
optional `tmux.sessions` v1 (`detect`, `attachOrCreate`, `waitForSession`)
and skips tmux attach without it (auto-tmux, the tmux monitor's attach, the
session picker). The toolbar's old host action list only ever held the
Tmux Monitor button, which is gone with it. `TmuxSessionPicker` lives in
ssh-terminal now. Owner: B10, which provides the service (the helpers are
`hosts/tmux/helper.ts`) and contributes its toolbar button to
`terminal.toolbar`.
- **B9 (ssh-terminal):** recording has no provider yet. The terminal calls the
optional `recordings.writer` v1 `open(meta)` and gets a sink with
`append(chunk)` (one call per 300 ms batch, the first starting with the
asciicast header), `persist(summary)` and `discard()`. Without it nothing is
recorded and no `session_recordings` row is written. Owner: B13, which
writes the file under `DATA_DIR/session_logs/<user>/<session>.cast` (mkdir
and writeFile on the first append, appendFile after, never per chunk, see
issue #1049) and creates or updates the row as the old session manager did.
- **B9 (ssh-terminal):** `enable_terminal`, `enable_terminal_toolbar` and
`enable_command_history` are still live `ssh_data` columns. The copy into
ssh-terminal host settings shipped (`ssh-terminal-settings-migration.ts`)
and the terminal reads its settings, but core host routes, types, the
export/import sample and `HostEditorData` still carry the columns.
`enable_terminal_toolbar` also drives the RDP/VNC/Telnet toolbar
(`remote-desktop/GuacamoleApp.tsx`), whose host editor switch stays in the
General tab, so remote-desktop needs its own host setting and copy first.
Then drop all three in lockstep. Owner: remote-desktop's step for its copy,
then D0.
- **B9 (ssh-terminal):** the legacy `settings` rows for the terminal
(`terminal_session_timeout_minutes`, `terminal_session_persistence_enabled`,
`command_history_enabled`, `touch_input_settings`, `terminal_image_*`) are
left in place for a release after the move; nothing reads them. Delete them
in 3.0.0. `sessionPersistence` is carried over but, as before, nothing acts
on it. Owner: D0 to decide.
- **B9 (ssh-terminal):** the image storage paths used to be rejected at save
when not absolute; the schema-driven settings form accepts any string and
the resolver ignores a bad one with a warning. A custom field or a validate
hook on plugin settings would restore the save-time error. Owner: D1.
- **B9 (ssh-terminal):** `@termix/plugin-sdk/ui` now re-exports a handful of
core APIs the terminal needs (`logActivity`, `getHostPassword`,
`patchOpenTab`, `getUserPreferences`, `parseCustomKeybindings`,
`setHostAutoTmux`, `getCookie`) and the connection helpers. They belong on
the typed host bridge, which is also where B7's `app.logActivity` line
above lands. Owner: D1.
- **B9 (ssh-terminal):** `OPKSSHDialog` is exported from
`@termix/plugin-sdk/ui` so the terminal can show the opkssh sign-in. It
leaves the ui entry when opkssh becomes a plugin that draws its own UI in
`terminal.overlay`. Owner: Phase C (opkssh).
- **B9 (host-metrics):** the terminal toolbar's CPU/memory/disk bars
(`TerminalMetricsStatus`) poll the local backend only; on the desktop app a
host that lives on a connected remote server shows no bars. Same fix as the
B7 remote-origin line above. Owner: D1.
- **B9:** the Hosts panel's "Terminal" feature filter was removed along with
the column reads, the same as B7's tunnel filter. Owner: D1, with the
generic host-settings filter.
- **B9:** the startup purge of expired open tabs in `db/index.ts` read
`terminal_session_timeout_minutes` from core settings; it was removed and
expired tabs are dropped when the tab list is read, against the terminal's
own timeout (`GET /open-tabs/session-timeout`, 30 while the terminal is
off). No owner unless the table growth matters.
- **Pre-existing (B8), found in B9:** `node scripts/check-shell-plugin-ids.cjs`
fails on `src/ui/lib/host-to-ssh-host.ts` spelling `web-endpoint` (the
`pluginSettings` lookup B8 added). Not allowlisted and not introduced here.
Owner: D0 (read it through a registry or allowlist it with a reason).
## Manual checks after 2.9.0
- SSH terminal: password auth, key auth (and an encrypted key's passphrase
prompt), TOTP keyboard-interactive, a jump host, Warpgate, split view,
snippets sent to a terminal, reconnect after a network drop and after a
page reload (session reattach), and the local terminal in the desktop app.
@@ -23,6 +23,8 @@ export const BACKEND_EXTERNALS = [
"@anthropic-ai/sdk",
"drizzle-orm",
"drizzle-orm/*",
// Native image processing, loaded on first use by the terminal's image upload.
"sharp",
];
/**
+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(() => {