mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move the terminal fully into the ssh-terminal plugin (B9)
This commit is contained in:
1 parent
535126fd1b
commit
79d77678c9
309 files changed
+41789
-14958
No files matched your search
@@ -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.
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -112,6 +112,7 @@ const offline = () =>
|
||||
loaded: true,
|
||||
}),
|
||||
useSlotContributions: () => [],
|
||||
usePluginComponent: () => undefined,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
Reference in new issue
Block a user