Files
Termix/packages/plugin-sdk/src/backend.ts
T
0149260926 v26.10.0 (#1573)
* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

* chore: lint, format, and bump version to 2.9.0

* chore: skip crowdin pre-translate in release for now

* chore: switch to calver, bump version to 26.10.0

* fix(sso): send the PKCE verifier for GitHub login (#1534)

* fix(remote-desktop): clip cursor overflow without disabling zoom (#1535)

* test(remote-desktop): preserve sessions beyond one hour (#1536)

* fix(hosts): expose controls for overflowing editor tabs (#1537)

* fix(hosts): expose controls for overflowing editor tabs

* test(hosts): mock resize observation in admin panel tests

* fix(status): skip TCP probes while host sessions are active (#1538)

* fix(database): batch session activity persistence (#1539)

* docs(api): describe cookie and API key authentication (#1540)

* fix(snippets): repair missing note column on SQLite upgrades (#1541)

* fix(docker): retain stored SSH credential association (#1543)

* fix(file-manager): render transfer toasts without plugin hooks (#1546)

* feat(hosts): keep compact row actions inline (#1547)

* fix(auth): allow retrying unavailable second-factor interfaces (#1549)

* fix(auth): reject unresolved legacy identity provisioning (#1550)

* fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551)

* fix(tmux): pass full session info to the terminal's session picker (#1552)

The tmux.sessions service reduced detected sessions to bare names, but the
picker reads session.name, so every entry rendered blank and selecting one
sent an empty name, which created a new session instead of attaching.

* fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553)

The column holds the text "true"/"false", and the resolver passed it
through as-is. The string "false" is truthy, so the password provider
treated every saved host as forced keyboard-interactive and left the
password out. Jump hops are built straight from the resolved host, so a
password hop whose server doesn't offer keyboard-interactive failed with
"All configured authentication methods failed".

* fix(database): batch database saves for bulk host writes (#1554)

* fix(database): yield to the event loop between database saves

Each SQLite save serializes and encrypts the whole database into new
buffers, which V8 only releases once the event loop turns. Boot
migrations and bulk host import write plugin settings per host and per
key in an awaited loop, so hundreds of full copies piled up and the
container ran out of memory.

* fix(database): save once per bulk host write instead of once per row

On SQLite every repository write force-saves the whole database. Boot
plugin data migrations, host defaults materialization and the bulk
host routes write one row per host per setting, so they serialized and
encrypted the full database hundreds of times in a row.

DatabaseSaveTrigger.batched wraps a function so force saves made while
it runs collapse into a single save when it finishes. Nested scopes fold
into the outer one, and work that outlives its scope saves normally.

* feat(hosts): add instance-wide predefined tag suggestions (#1548)

* feat(hosts): add shared predefined tag suggestions

* style(hosts): format tag catalog routes

* test(database): advance fake timers past the save yield (#1555)

* fix(i18n): complete Simplified Chinese core and plugin translations (#1542)

* fix(i18n): complete Chinese onboarding and navigation labels

* fix(i18n): translate remaining Chinese core and plugin interfaces

* fix(i18n): translate host editor tab overflow controls

* fix(i18n): use consistent Chinese fleet terminology

* fix(i18n): localize hardcoded controls and plugin views

* fix(i18n): translate built-in homepage widget catalog

* test(homepage): follow translated timezone placeholder

* fix(i18n): translate plugin-provided homepage widgets

* fix(i18n): localize feature settings section titles

* fix: 2.8 oidc accounts unable to sign in after upgrading (#1381)

* fix: plugins losing the saved ssh login when copying a host (#1391)

* fix: fleets, proxmox and automations not getting the host sudo password

* fix: host imports running a defaults pass and metrics restart per host (#1384)

* fix: high cpu from status probes and full database saves on every sample (#1300)

* fix: user data export freezing the server (#1393)

* fix: op:// secret references rejected as ssh keys on credentials (#1394)

* fix: slow file deletes from the trash lookups on every delete (#1390)

* fix: add openapi docs and error handling to host tag routes

* test: compare download stream buffers directly so it stops timing out

* chore: drop em dash from host row comment

* chore: update release notes for 2.9.1

* feat(ai): add remote coding agent sessions for SSH hosts (#1545)

* feat(ai): add host coding agent sessions with configured providers

* Wait for agent initialization and shutdown before resuming sessions

* Keep Pi transcripts outside its configuration migration directory

* Cover Pi session directory separation

* Update core permission catalog expectations for remote agents

* Install remote agent runtimes from verified official sources

* Wait for the identity panel credentials request in its test

* Add scoped SSH forwarding setup for remote agents

* Verify forwarding setup through a fresh SSH connection

* Bound the SSH forwarding verification timeout

* Return the forwarding verification callback result

* fix(ai): support agent send and interrupt keyboard shortcuts

* feat(ai): add agent queues attachments worktrees and session recovery

* fix(ai): handle deleted workspace paths and duplicate status events

* fix(ai): accept file references in root workspaces

* fix(ai): refresh paused queues after agent errors

* fix(ai): store agent sessions in plugin tables and lock down OpenCode

Agent sessions lived in a single ctx.kv value holding up to 2000 events,
which passed the 256 KB kv limit within minutes of a normal run. Every
save after that failed, so history, nativeId, queue and archive changes
were lost and every event wrote a kv audit row.

Sessions, events and queued prompts now have their own tables. A running
agent appends events in batches at most once a second with a lazy save;
session and queue changes save immediately. Prompts, drafts and stored
event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no
longer requested.

The OpenCode server now requires a per-session password, so other local
accounts on the host cannot drive the agent through its loopback port.

* test(upgrade): include agent session tables in the reviewed upgrade SQL

* test: run the posix-only ai agent tests on linux only

* fix: restore space to add host tags and redesign predefined tags editor

* fix: host key silently accepted when the host is missing from the database (#1397)

* fix: clearer host login failed status label and fix its translations (#1396)

* chore: add host key and status label fixes to release notes

* fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles

* chore: increment ver

* fix(audit): store plugin entries with no acting user as a null user_id (#1556)

Plugin audit entries written outside a request used the literal "system"
as user_id. That column references users.id, so the insert was refused
(Postgres logs it as an FK violation) and the entry was silently dropped.
Write null instead and keep "system" / plugin:<id> in username.

* chore: add sso/ldap dialog and audit log fixes to release notes

* fix: tunnels and host settings missing from shared hosts on desktop

* fix: remote desktop logins missing from shared hosts on desktop

* fix: simplify host status to online/offline and keep it live without a refresh

* chore: sync Crowdin translations for 2.9.1

* fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi

* chore: rework plugin readmes, json changelogs and standalone repo setup

* chore: move plugins to their own repos and drop the plugins folder

* chore(deps-dev): bump globals (#1560)

Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals).


Updates `globals` from 17.12.0 to 17.13.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: drop the 2.8 columns, moves and aliases kept for a downgrade

* chore: remove the 2.9 finish list

* feat: port the demo redesign with Manage and Settings tabs

* chore: pin the plugin signing key and use TERMIX_PAT in workflows

* chore: rename the plugin sdk to @termix-ssh/plugin-sdk

* feat: add a publish workflow for the plugin sdk

* chore: cleanup

* fix: harden plugin sockets, public routes, prebuilt binaries and login fallback

* chore: add a 2.9.1 upgrade check for postgres and mysql

* fix: stop full database rewrites on every plugin db call

* fix: stop full database rewrites on every plugin db call

* fix: restore release video link in 2.9.2 notes

* fix: use TERMIX_PAT in workflows

* feat: pin the official plugins in the docker image

* feat: implement plugin management UI

* feat: shared list kit, settings search, rail order and ui consistency fixes

* chore: run all workflows on blacksmith runners

* chore: split dashboard quick actions into quarters and drop host status row lines

* fix: open plain settings from the profile button, navigation only from the rail menu

* feat: consent check, developer mode file installs, install counts and issue links for plugins

* feat: row detail options for hosts and credentials, open manage from sidebar header

* fix: unify connection failure screens and rebuild stale local plugins

* feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev

* chore: clean up npm scripts, make npm run dev the full runner and drop concurrently

* fix: settle quick connect panel loads in tests so they stop flaking

* fix: stop the dev runner rebuilding plugins when windows reports file reads as changes

* chore: update the plugin sdk ui export snapshot

* feat: full screen onboarding with a plugin picker and per step versions

* feat: changelog.md release notes, v tags, plugin release notes and videos

* chore: add temp changelog

* chore: release the plugin sdk as 1.0.1

* feat: show only the latest plugin release notes and cap long ones

* chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub

* chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN

* chore: renew mac app store provisioning profile

* chore: drop docs job from release workflow

* chore: run sdk publish on github hosted runner

* chore: remove dependabot

* chore: general file cleanup

* chore: rewrite readme and taglines for plugin-based termix

* chore: drop optional from ai plugin row in readme

* feat: per-repo issue forms and prefilled bug reports instead of the support repo

* feat: add report a bug link to the your account settings section

* fix: open settings goes to the plugin's page and hides when it has none

* feat: show plugin features from the manifest on the plugin page

* fix: retry failed or blocked plugins on restart instead of leaving them disabled

* fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling

* feat: request a feature link for plugins and use the bug form for unverified plugins

* feat: beta channels for core and plugins with one stable/beta release workflow

* fix(sync): resolve shared hosts for connect-only recipients (#1568)

* fix(ssh): accept full passwords in keyboard-interactive prompts (#1569)

* fix(ssh): normalize PKCS8 private keys before connecting (#1570)

* fix(sidebar): reclaim space from hidden folder actions (#1571)

* fix(hosts): display protocol credential usernames for non-SSH hosts (#1572)

* fix: ask for a push/TOTP menu choice as text instead of a push confirm

* fix: keep client tunnels open when one of their connections fails (Support#1417)

* fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420)

* fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419)

* fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418)

* fix: refresh cached plugin translations when only a translation changes (Support#1411)

* fix: import sshpk as a default so the backend starts under ESM

* feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2

* fix: drop the ./ from the plugin sdk bin path so npm stops warning

* chore: name core and sdk releases Termix vX and Plugin SDK vX

* fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g)

* fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh)

* fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw)

* fix: refuse sessionless tokens and keep the secrets file owner only

* feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm)

* chore: standardize repo setup files

* chore: remove unused knip config

* chore: add LICENSE and use Node 24 from .nvmrc

* chore: update translation guidance, only en.json needs editing

* feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3

* fix: header separators render as borders and center the status divider

* fix: lighter confirm prompt overlay to stop lag when it opens

* fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4

* fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK

* fix: keep plugin tailwind variants in core's layer so overrides keep their order

* fix: manage tab refetches before giving up on a host or credential it cannot find

* fix: tab buttons like share show as soon as the terminal is ready

* fix: folder rows no longer nest action buttons inside a button

* fix: repin remote-desktop after its 1.0.0 re-release

* chore: repin bundled plugins after the 1.0.0 re-release

* fix: recent activity rows use the same separator as host status

* feat: upload banner

* fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24

* fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks

* fix: bundled plugins path for source runs and backend cleanup

* fix: load plugin strings on guest pages, plugin wording and drop unused keys

* fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms

* docs: 26.10.0 changelog

* fix: current version in the version route example

* chore: stop pushing the release- docker tag

* feat: remove the donation reminder and its column

* docs: note the removed donation reminder

* fix: link server wizard always opens inline, onboarding card grows for it

* feat: plugin text columns are longtext on mysql so they are not capped at 64KB

* fix: linked desktops keep a plugin when its update from the server fails

* chore: default mobile download links to v tags

* fix: sso only accounts cannot sign in with a passkey or other local method

* feat: note in the plugins tab that community plugins are coming

* fix: plugin validate accepts beta versions

* chore: pin bundled plugins rebuilt on sdk 1.0.5

* fix: skip locale fetches for disabled plugins

* fix: show translated names for the built-in admin and user roles

* fix: show host keys as SHA256 fingerprints like ssh-keygen

* fix: host editor scrolls to a newly enabled protocol section

* fix: pass the host to dashboard host row plugins

* fix: plugin cards show dividers between author, version and category

* fix: refuse plugin archives with paths outside the plugin folder

* fix: theme picker shows one dropdown arrow

* fix: read activity and audit times as UTC so they show in local time

* fix: show the real error for a wrong password or code on plugin sign in

* fix: explain that external sign in needs Auto-create external accounts

* fix: clear leftover plugin unpack folders at startup

* fix: shared session links open for guests again

* fix: share panels no longer list yourself as a share target

* fix: a host update without an auth type keeps the saved password

* fix: guest pages get the tab ids a guest plugin registers

* fix: refuse a 2.8 database, which already had a plugins table

* fix: dashboard cards fit on a phone

* fix: desktop setup no longer asks to secure the local account

* fix: the desktop app keeps the desktop layout in a narrow window

* fix: the desktop app shows the rail and no phone bar in a narrow window

* fix: name every sync type in the Sync panel and pluralize the link prompt

* chore: update the sdk ui snapshot for the host key dialog

* fix: turning a plugin back on restarts the plugins it turned off

* fix: quick connect asks to trust the host key

* fix: revoking a manager also removes the shares they handed out

* fix: database export works for users with TOTP on (#1426)

* fix: rdp and vnc logins use the folder credential when none is picked (#1429)

* fix: imported hosts get status checks without a restart

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: re-pin bundled plugins after search bar fix

* chore: update app icon to smaller version

* chore: round the readme logo, keep square source for app icons

* feat: grid columns and table view in the plugins tab

* fix: match the host key copy button to the fingerprint height

* fix: use brand color for toggles in sync settings

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: web terminals failing auth behind a proxy that rewrites Host (Support#651)

* fix: re-pin snippets and termix-identity after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* chore: remove Crowdin from the release workflow

* chore: remove crowdin config

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 14:58:27 -05:00

2069 lines
72 KiB
TypeScript

/**
* The backend contract: what a plugin's activate(ctx) receives.
*
* These are types only. Core builds the real object in
* src/backend/plugins/ctx.ts and injects it at activation, so the SDK stays a
* description of the contract rather than an implementation a plugin could
* reach around.
*
* ctx.ssh is the part of step B's host surface that A8 needed early, so plugin
* transports could leave core's internals behind.
*/
import type { HostSshOptions } from "./ssh-options.js";
export type { HostSshOptions } from "./ssh-options.js";
import type { PluginManifest, PluginSettingsField } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js";
/**
* Thrown when a plugin calls a guarded member without the capability.
*
* Two things must be true: the manifest declares it, and it is granted. A
* grant for something the manifest never declared is ignored, so widening a
* plugin's reach always needs a new manifest the user can see.
*/
export class PluginCapabilityError extends Error {
readonly code = "EPLUGINCAP";
readonly pluginId: string;
readonly capability: string;
constructor(pluginId: string, capability: string) {
super(
`Plugin "${pluginId}" is not granted the "${capability}" capability. ` +
`Declare it in the manifest's capabilities array and grant it in the plugin's settings.`,
);
this.name = "PluginCapabilityError";
this.pluginId = pluginId;
this.capability = capability;
}
}
/**
* Thrown by a login method or second factor to refuse a sign-in with a
* message the user can see. `code` is a stable id the login screen can map to
* its own text; `status` is the HTTP status for form methods.
*/
export class LoginMethodError extends Error {
readonly status: number;
readonly code?: string;
constructor(message: string, status = 401, code?: string) {
super(message);
this.name = "LoginMethodError";
this.status = status;
this.code = code;
}
}
export interface PluginLogger {
debug: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string, error?: Error) => void;
}
export interface PluginEvents {
/** Topics are namespaced to plugin.<id>.* unless the plugin holds events:core. */
emit: (topic: string, payload: unknown) => void;
on: (topic: string, listener: (payload: unknown) => void) => () => void;
}
/** Small key/value state owned by this plugin. Requires kv:own. */
export interface PluginKeyValue {
get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown) => Promise<void>;
delete: (key: string) => Promise<boolean>;
list: () => Promise<string[]>;
}
/**
* A folder on disk this plugin owns, for state too large for ctx.kv (session
* recordings, uploaded files). Requires files:own.
*/
export interface PluginFiles {
/**
* The plugin's own folder under DATA_DIR, created if it does not exist yet.
* Always the same path for this plugin; a plugin lays out its own
* subdirectories underneath it.
*/
dataDir: () => Promise<string>;
}
/**
* The plugin's own tables.
*
* `define` registers a table definition and returns the Drizzle table object
* to query through `db`. Both require db:own.
*
* Scoped by name: every table carries the p_<id>_ prefix, and `refs` exposes
* users, ssh_data, roles and user_roles (behind db:core-refs) so a plugin can
* join against them. In-process code could reach around
* all of this. The capability, the prefix, lint and review are the contract,
* not a sandbox.
*/
export interface PluginDatabase {
/** Registers a definition and returns its queryable table object. */
define: <T = unknown>(definition: PluginTableDefinition) => Promise<T>;
/** Drizzle handle, scoped to this plugin's tables. */
client: <T = unknown>() => Promise<T>;
/**
* Core tables a plugin may join against: users, hosts, roles, userRoles.
* Needs db:core-refs. Treat them as read-only.
*/
refs: <T = unknown>() => Promise<T>;
/**
* Flushes writes to disk. Call it after every write: on SQLite the database
* lives in memory and is saved to its encrypted file only when asked, so a
* write without it can be lost on restart. A no-op on Postgres and MySQL.
*/
persist: (options?: {
/**
* Mark the database dirty and let the debounced save pick it up, rather
* than saving now. For frequent, low-value writes such as samples, where
* losing a couple of seconds on a crash is fine.
*/
lazy?: boolean;
}) => Promise<void>;
/**
* The engine the server runs on. Table objects encode the same on all
* three, but MySQL has no RETURNING, so portable code reads a written row
* back by its key rather than relying on .returning().
*/
readonly dialect: "sqlite" | "postgres" | "mysql";
}
/** A row's shape on the wire, before it is written to a table. */
export type SyncRow = Record<string, unknown>;
export interface SyncEntityReference {
/**
* Where a local numeric id lives. A plain column ("credentialId"), or a
* path into a JSON column: "jumpHosts[].hostId" walks every item of the
* jumpHosts array, "layout.panes[].hostId" walks nested objects. A JSON
* column may be stored as a string or as parsed JSON.
*/
field: string;
/**
* Wire field holding the portable id, e.g. "credentialSyncId". Only for a
* plain column; a JSON path is rewritten in place.
*/
syncField?: string;
/** The entity the id points at. */
entityType: string;
/** How a JSON path stores the local id. Defaults to "number". */
idType?: "number" | "string";
}
export interface SyncEntityPermissions {
/** Checked before a pushed row that does not exist yet is written. */
create?: string;
/** Checked before a pushed change to an existing row is written. */
update?: string;
/** Checked before a pushed delete runs. */
delete?: string;
}
export interface SyncWriteEvent {
/** The local id of the row that was written. */
id: number | null;
userId: string;
/** The row as it arrived, before references were resolved. */
wire: SyncRow;
created: boolean;
/** Another entity's syncId to its local id here, for ids serialize put on the wire. */
resolveId?: (entityType: string, syncId: string) => Promise<number | null>;
}
export interface SyncEntityRegistration {
/** Stable wire name. Never change it: records on both sides match on it. */
type: string;
/** The table, as returned by ctx.db.define. */
table: unknown;
/** Column holding the owning user id. Defaults to "userId". */
userColumn?: string;
/** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
encryptedFields?: readonly string[];
/** Local-id to sync-id translations applied on the way out and back. */
references?: readonly SyncEntityReference[];
/** Lower sorts first. Reference targets must sort before their referrers. */
order?: number;
/**
* Fields that stay on this side: never sent, never overwritten by an
* inbound row, and ignored when deciding whether a row changed.
*/
readOnlyFields?: readonly string[];
/** One row per user rather than many, keyed on the owner. */
singleton?: boolean;
/**
* Names core uses when its own rows point at this entity without knowing
* which plugin provides it. Nothing in core uses one today.
*/
answersTo?: readonly string[];
/**
* Rows this returns false for are left out of sync in both directions, for
* state that belongs to one install (a per-device "last session").
*/
shouldSync?: (row: SyncRow) => boolean;
/** RBAC permissions a pushed change needs on the server. */
permissions?: SyncEntityPermissions;
/**
* Runs after `references` on the way out, for anything they cannot
* express. Gets the row with references already translated.
*/
serialize?: (
row: SyncRow,
resolveSyncId: (entityType: string, id: number) => Promise<string | null>,
) => Promise<SyncRow>;
/** Runs after `references` on the way in. */
deserialize?: (
row: SyncRow,
resolveId: (entityType: string, syncId: string) => Promise<number | null>,
) => Promise<SyncRow>;
/** Runs after a synced row was written on this side. */
afterWrite?: (event: SyncWriteEvent) => Promise<void>;
/**
* Replaces the plain delete of a synced row, for rows with cleanup of their
* own. Gets the stored row.
*/
remove?: (row: SyncRow, userId: string) => Promise<void>;
}
/**
* Adds an entity to sync between a linked desktop and its server.
*
* Deletes need nothing extra: core notices a registered row is gone and
* sends the delete itself.
*/
export interface PluginSync {
registerEntity: (entity: SyncEntityRegistration) => void;
}
/**
* A plain key/value hand-off between plugins. `provide` and `revoke` only take
* keys under the plugin's own id (`<pluginId>.something`); anyone may
* `consume`.
*/
export interface PluginRegistry {
provide: <T>(key: string, value: T) => void;
consume: <T>(key: string) => T | undefined;
revoke: (key: string, value?: unknown) => boolean;
}
export interface PluginServices {
/**
* `name` registers one of several providers of the same service, for a
* service keyed by something the caller picks (sessions.live by session
* type). It must be listed in the manifest's `provides[].names`.
*/
provide: <T extends object>(
service: string,
implementation: T,
options?: { name?: string },
) => void;
/**
* The service must be listed in the manifest's `requires` (or be one this
* plugin provides). `provider` picks a named provider; omitted, the unnamed
* one. A `userId` other than the acting user needs users:impersonate.
*/
get: <T extends object>(
service: string,
options?: { userId?: string; provider?: string },
) => T;
/** Names of the providers running now ("" for an unnamed one). */
providers: (service: string) => string[];
}
export interface PluginSecrets {
/**
* The acting user's own copy of a secret this plugin stored, decrypted, or
* null when nothing is stored. Needs secrets:own and an actor. Not audited:
* a plugin reads its keys on every request that uses them.
*/
get: (key: string) => Promise<string | null>;
/**
* Stores a secret for the acting user, encrypted at rest. Null clears it.
* Needs secrets:own and an actor. Audited without the value.
*/
set: (key: string, value: string | null) => Promise<void>;
/** Removes the acting user's secret. Needs secrets:own. Audited. */
delete: (key: string) => Promise<void>;
/**
* Encrypts a value with the installation key, for a plugin that keeps a
* secret in its own table and has to read it without an acting user (a
* second factor checked during login). Needs secrets:own. Not audited.
*/
seal: (value: string) => Promise<string>;
/** Reverses seal(). Null when the value cannot be decrypted. */
unseal: (sealed: string) => Promise<string | null>;
offer: (
key: string,
resolve: (userId: string) => Promise<string | null> | string | null,
) => void;
withdraw: (key: string) => boolean;
getShared: (
pluginId: string,
key: string,
options?: { userId?: string },
) => Promise<string | null>;
}
/**
* Express types, structurally. The SDK does not depend on express: a plugin
* brings its own copy, and a nominal type from core's would not match it.
*/
export interface PluginRequestLike {
method: string;
url: string;
path: string;
headers: Record<string, unknown>;
[key: string]: unknown;
}
export interface PluginResponseLike {
status: (code: number) => PluginResponseLike;
json: (body: unknown) => unknown;
[key: string]: unknown;
}
export type PluginNextFunction = (error?: unknown) => void;
export type PluginMiddleware = (
req: PluginRequestLike,
res: PluginResponseLike,
next: PluginNextFunction,
) => void;
export interface PluginRouterOptions {
/**
* Paths served without authentication, relative to the plugin's mount point
* and matched exactly ("/callback", not "/plugin-api/<id>/callback"). A
* ":name" segment matches one segment, and a trailing "/*" matches any
* number of further segments, for a proxied page ("/chooser/:id/*").
* An entry only opens the route registered with that same path, for the
* methods it was registered with, so a literal route beside a ":param"
* ("/webhook/config" next to "/webhook/:token") still needs a login.
*
* For the handful of routes an unauthenticated third party has to reach: an
* OIDC callback, an inbound webhook. Every entry is audited when the router
* is registered and every request that uses one is logged, because "this
* plugin opened a hole in auth" is exactly the thing an operator should be
* able to find later.
*/
public?: readonly string[];
/** Body size limit for JSON and urlencoded bodies. Defaults to "1mb". */
bodyLimit?: string;
/**
* Skips the built-in body parsers entirely, for a router that parses its own
* (multer, a raw body for signature checking, a proxied stream).
*/
rawBody?: boolean;
}
/**
* HTTP routes, served at /plugin-api/<id>/ by the main backend server.
*
* No plugin gets its own port or nginx block. Core runs auth, the actor, the
* enabled check, body limits and an error wrapper in front of every route, so
* a plugin's router only has to handle its own paths.
*/
export interface PluginHttp {
/**
* Returns an Express Router mounted at /plugin-api/<id>/. Call it once in
* activate and register routes on the result; it is unmounted automatically
* on deactivate.
*/
router: <T = unknown>(options?: PluginRouterOptions) => T;
/**
* The public base URL a request came in on: origin plus the install's base
* path, no trailing slash. Honours forwarded headers from a trusted proxy,
* BASE_PATH and EXTERNAL_FORCE_HTTPS. For building absolute callback URLs.
*/
baseUrl: (req: unknown) => string;
}
/**
* Core RBAC, as a plugin sees it.
*
* Every method takes either a short name this plugin declares
* ("services.use"), which is prefixed with the plugin id, or a full id
* belonging to another plugin or a core group, which is used as given. That is
* what makes a cross-plugin check expressible without letting a plugin gate its
* own routes on someone else's authority.
*/
export interface PluginRbac {
/** Whether the user this call is running as holds `permission`. */
has: (permission: string) => Promise<boolean>;
/** Whether `userId` holds `permission`. */
hasFor: (userId: string, permission: string) => Promise<boolean>;
/**
* Middleware that rejects a request whose user lacks `permission`.
*
* A plugin may only require a permission it declares in
* contributes.permissions, so a route cannot gate on admin.users.manage and
* borrow someone else's authority.
*/
require: (permission: string) => PluginMiddleware;
}
export interface PluginWebSocketConnection {
/** The authenticated user for this socket. */
readonly userId: string;
/** The upgrade request, for query parameters and headers. */
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 = (
connection: PluginWebSocketConnection,
) => void | Promise<void>;
export interface PluginWebSocketOptions {
/**
* Serves the upgrade without core authentication. The handler gets an empty
* userId and must authenticate the socket itself, which is what a share-link
* 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;
}
/**
* WebSocket routes, served at /plugin-ws/<id>/<path> through the main server's
* upgrade event. Every socket a plugin opened is closed on deactivate.
*/
export interface PluginWebSockets {
route: (
path: string,
handler: PluginWebSocketHandler,
options?: PluginWebSocketOptions,
) => void;
/**
* Hands the raw upgrade to the plugin, for a library that insists on owning
* its own WebSocket server (guacamole-lite attaches this way). Auth and the
* actor still run first.
*/
upgrade: (
path: string,
handler: (
request: unknown,
socket: unknown,
head: unknown,
userId: string,
) => void,
options?: PluginWebSocketOptions,
) => void;
}
/**
* Anything the runtime should undo when the plugin deactivates. Everything
* registered through ctx is tracked automatically; this is for resources a
* plugin creates itself, such as a server or an interval.
*/
export interface PluginDisposables {
add: (dispose: () => void | Promise<void>) => void;
}
/**
* The plugin's own settings, as declared in contributes.settings.
*
* Reading and writing a plugin's own settings needs no capability: the
* manifest already says which fields exist, and a plugin that could not read
* its own configuration would be useless. Only readCore, which reaches outside
* the plugin's namespace, is gated on settings:read-core.
*
* Values are validated against the declared field on every write, so a key the
* manifest never declared is rejected rather than stored. Secret fields are
* encrypted at rest and returned decrypted here; the HTTP surface never sends
* them to a browser.
*/
export interface PluginSettings {
/** Install-wide admin settings. */
get: <T = unknown>(key: string) => Promise<T | undefined>;
set: (key: string, value: unknown) => Promise<void>;
/** Per-user settings. */
getUser: <T = unknown>(userId: string, key: string) => Promise<T | undefined>;
setUser: (userId: string, key: string, value: unknown) => Promise<void>;
/**
* Per-host settings. A host follows its host defaults (server, user,
* folder) for every key it has not set itself, and the value read here is
* already the resolved one.
*/
getHost: <T = unknown>(
hostId: number | string,
key: string,
) => Promise<T | undefined>;
/**
* The value as one user sees it. Only a `personal` field differs from
* getHost: on a host shared with that user and following its defaults, it
* resolves against the user's own defaults rather than the owner's.
*/
getHostFor: <T = unknown>(
hostId: number | string,
userId: string,
key: string,
) => Promise<T | undefined>;
/**
* What a user's hosts get for a host field when nothing more specific
* sets it: their own defaults, then the server's, then the manifest
* default. For something with no host, such as a quick connect.
*/
getHostDefault: <T = unknown>(
userId: string,
key: string,
) => Promise<T | undefined>;
/**
* Writes a host value, which makes it the host's own rather than
* following its defaults. `{ inherit: true }` hands the key back to the
* defaults instead and ignores `value`.
*/
setHost: (
hostId: number | string,
key: string,
value: unknown,
options?: { inherit?: boolean },
) => Promise<void>;
/**
* Every host that saved a value for one of this plugin's host fields, across
* all users, with the host's owner. For a background job that must know
* which hosts to act on, and as whom, before any request is in flight.
* Requires hosts:read. Secret fields cannot be listed.
*/
listHostValues: <T = unknown>(
key: string,
) => Promise<Array<{ hostId: number; userId: string; value: T }>>;
/** Every value in one scope, with declared defaults merged in. */
getAll: (
scope: "admin" | "user" | "host",
scopeId?: string | number,
) => Promise<Record<string, unknown>>;
/**
* Fires when a key in this plugin's settings changes, from either the ctx
* API or the HTTP routes. Disposed automatically on deactivate.
*/
onChange: (key: string, listener: (value: unknown) => void) => () => void;
/**
* Checks a save from the settings screen or host editor before anything is
* written. Return field key to message for what is wrong; nothing means the
* save goes ahead. Disposed automatically on deactivate.
*/
onValidate: (
scope: "admin" | "user" | "host",
validator: (
values: Record<string, unknown>,
/**
* `defaults` is set when the values are a host defaults level being
* saved rather than one host, so there is no hostId.
*/
context: {
hostId?: number;
defaults?: { level: "admin" | "user" | "folder" };
},
) => Record<string, string> | void | Promise<Record<string, string> | void>,
) => () => void;
/**
* Reads a core server setting from a small documented allowlist.
* Requires the settings:read-core capability.
*/
readCore: (key: string) => Promise<string | null>;
}
/** A host's non-secret fields, for listing, tag matching and display. */
export interface PluginHostSummary {
id: number;
userId: string;
name: string | null;
ip: string;
port: number;
username: string;
/** Comma-separated, as ssh_data stores them. Split on "," to match tags. */
tags: string | null;
folder: string | null;
authType: string;
}
/**
* A host as ctx.hosts hands it to a plugin that creates, updates or scans
* hosts on the user's behalf (import, discovery, sync), decrypted for that
* user. Every field is listed here and core builds it field by field, so a
* core column a plugin needs has to be added to this contract first. Carries
* no secret auth material (password, key, vault tokens): a plugin that
* creates a host picks an authType and, for "credential", a credentialId it
* does not need to see the contents of.
*/
export interface PluginHostRecord {
id: number;
userId: string;
/** Stable across a desktop and the server it syncs with. */
syncId: string | null;
name: string | null;
ip: string;
port: number;
username: string;
authType: string;
credentialId: number | null;
overrideCredentialUsername: boolean;
/** "ssh", or the id of the plugin protocol a host without SSH uses. */
connectionType: string;
/** Comma-separated, as ssh_data stores them. */
tags: string | null;
folder: string | null;
parentHostId: number | null;
pin: boolean;
notes: string | null;
jumpHosts: PluginHostJumpHost[];
enableSsh: boolean;
sshPort: number | null;
statusCheckEnabled: boolean;
/** Seconds between status checks; null follows the global setting. */
statusCheckInterval: number | null;
connectionOrigin: "local" | "remote" | null;
/** Keepalive, legacy algorithms, agent and environment options. */
sshOptions: HostSshOptions;
/**
* The calling plugin's own host settings, secrets redacted. Another
* plugin's settings are that plugin's to hand out, through a service.
*/
pluginSettings: Record<string, unknown>;
/** Core's last reachability check, or null before the first one. */
status: PluginHostStatusEntry | null;
/** Desktop only: kept on this device, never synced to the server. */
localOnly: boolean;
createdAt: string | null;
updatedAt: string | null;
}
export interface PluginHostJumpHost {
hostId: number;
}
/**
* The fields a plugin may set when it creates a host. A key not listed here
* is refused with an error rather than dropped. Host settings go in
* `pluginSettings`, keyed by the plugin that declares them, and are
* validated against that plugin's manifest: an undeclared key is refused, and
* settings for a plugin that is not running are skipped and logged.
*/
export interface PluginHostCreateInput {
name: string;
ip: string;
port: number;
username: string;
authType: string;
credentialId?: number | null;
overrideCredentialUsername?: boolean;
connectionType?: string;
/** Comma-separated or a list. */
tags?: string | string[] | null;
folder?: string | null;
pin?: boolean;
notes?: string | null;
jumpHosts?: PluginHostJumpHost[] | null;
enableSsh?: boolean;
sshPort?: number | null;
statusCheckEnabled?: boolean;
statusCheckInterval?: number | null;
forceKeyboardInteractive?: boolean;
sshOptions?: HostSshOptions | null;
/** Host settings by plugin id, the shape a Termix export carries. */
pluginSettings?: Record<string, Record<string, unknown>>;
}
/** Fields a plugin may change on a host the acting user owns. */
export type PluginHostUpdateInput = Partial<PluginHostCreateInput>;
/** Every key PluginHostCreateInput and PluginHostUpdateInput accept. */
export const PLUGIN_HOST_INPUT_KEYS = [
"name",
"ip",
"port",
"username",
"authType",
"credentialId",
"overrideCredentialUsername",
"connectionType",
"tags",
"folder",
"pin",
"notes",
"jumpHosts",
"enableSsh",
"sshPort",
"statusCheckEnabled",
"statusCheckInterval",
"forceKeyboardInteractive",
"sshOptions",
"pluginSettings",
] as const satisfies readonly (keyof PluginHostCreateInput)[];
/** The same shape canAccessHost returns internally, without secrets. */
export interface PluginHostAccess {
hasAccess: boolean;
isOwner: boolean;
isShared: boolean;
permissionLevel?: "connect" | "view" | "edit" | "manage";
expiresAt?: string | null;
}
export type PluginHostShareLevel = "connect" | "view" | "edit" | "manage";
export interface PluginShareTarget {
type: "user" | "role";
id: string | number;
}
export interface PluginHostShareResult {
hostId: number;
shared: boolean;
reason?: string;
}
/** A user or role a plugin can offer as a share target. No secrets, no roles' permission lists. */
export interface PluginShareableUser {
id: string;
username: string;
}
export interface PluginShareableRole {
id: number;
name: string;
displayName: string | null;
}
/**
* Hosts the acting user can see, and the RBAC + sharing operations a plugin
* needs to build a feature (like fleets) on top of hosts it does not own.
*
* list/get/checkAccess need hosts:read. share, and the user/role pickers a
* share UI needs, need hosts:write: sharing changes who can reach a host,
* which is a write on that host's access, even though the plugin owns
* neither the host nor the grant.
*/
export interface PluginHosts {
/** Every host the acting user owns or was shared, without secrets. */
list: () => Promise<PluginHostSummary[]>;
/** One host the acting user can see, or null if it does not exist or they cannot. */
get: (hostId: number) => Promise<PluginHostSummary | null>;
/** The same access rule ctx.ssh.connect enforces, but naming the level, not just yes/no. */
checkAccess: (
hostId: number,
level: PluginHostShareLevel,
) => Promise<PluginHostAccess>;
/**
* Creates a host owned by the acting user, encrypted the same way the host
* editor's own create route does it. Needs hosts:write. For a plugin that
* imports or discovers hosts on the user's behalf (B5's proxmox discovery).
*/
create: (host: PluginHostCreateInput) => Promise<PluginHostRecord>;
/**
* Updates a host the acting user owns. Refuses a host it does not own
* (sharing a write onto someone else's host goes through share(), not
* update()). Needs hosts:write.
*/
update: (
hostId: number,
patch: PluginHostUpdateInput,
) => Promise<PluginHostRecord | null>;
/**
* Deletes a host the acting user owns, the same way the host editor's
* delete does: its access grants, activity and plugin settings go with it
* and remote sync gets a tombstone. Needs hosts:write, and the acting user
* needs the core hosts.delete permission. False when the host does not
* exist or is not theirs.
*/
delete: (hostId: number) => Promise<boolean>;
/**
* Every host the acting user owns, decrypted and wide (PluginHostRecord,
* not the narrow list() summary). For a plugin that scans its own hosts in
* the background (auto-sync) and needs fields list() does not carry, such
* as credentialId or jumpHosts. Needs hosts:write, matching create/update:
* this is the same "full host detail" surface a create/update caller needs
* to read back, not a wider read grant than hosts:read gives via list/get.
*/
listOwned: () => Promise<PluginHostRecord[]>;
/**
* Grants access to a host the caller manages, snapshotting shared secrets
* for each target the same way the host editor's own share action does.
* Refuses a host the caller does not hold "manage" on.
*/
share: (
hostId: number,
targets: PluginShareTarget[],
permissionLevel: PluginHostShareLevel,
durationHours?: number,
) => Promise<PluginHostShareResult>;
/** Users the caller may pick as a share target, for building a picker UI. */
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>;
/**
* Core's reachability status, the one behind the host list's status dot.
* Needs hosts:read.
*/
status: PluginHostStatusApi;
}
/**
* online: the port answers, a session is open or a login worked. offline: the
* port does not answer. "reachable" is no longer sent and stays only so older
* plugins still type check.
*/
export type PluginHostStatus = "online" | "reachable" | "offline";
export interface PluginHostStatusEntry {
status: PluginHostStatus;
lastChecked: string;
/** @deprecated No longer sent. */
reason?: "host_key_changed";
}
/**
* Core checks every host with status checks on by opening a TCP connection to
* it. A plugin that logs in to hosts can report a working login, which marks
* the host online right away.
*/
export interface PluginHostStatusApi {
/**
* The last known status, or null when core has not checked the host yet or
* the acting user cannot reach it.
*/
get: (hostId: number) => Promise<PluginHostStatusEntry | null>;
/** Checks the host now unless core checked it recently. */
check: (hostId: number) => Promise<PluginHostStatusEntry | null>;
/**
* Reports a login attempt. Only a working login changes the status; a
* failed one is ignored. Not audited: pollers call it every sample.
*/
reportLogin: (
hostId: number,
outcome: { ok: boolean; hostKeyChanged?: boolean },
) => void;
/**
* Tells core which port proves a host of this connection type is up, for
* protocols whose port lives in the plugin's own host settings. Core falls
* back to the host's port. Removed on deactivate.
*/
registerPort: (
connectionType: string,
resolve: (
hostId: number,
) => number | undefined | Promise<number | undefined>,
) => () => void;
}
/**
* A host as the SSH pipeline reads it: resolved by core, with secrets filled
* in for the acting user. Plugins get one from core and hand it back; they
* should not build one from scratch.
*/
export interface PluginSshHost {
id: number;
ip: string;
port: number;
username: string;
userId?: string | null;
authType?: string | null;
sshOptions?: HostSshOptions | null;
[key: string]: unknown;
}
/** Picks keepalive and timeout defaults. "plugin" is the generic one. */
/** What a connection is for, as a free label. Auth providers see it. */
export type PluginSshPurpose = string;
/**
* Keepalive, timeout and environment defaults for a kind of connection.
* terminal: an interactive shell. session: a long-lived browsing session.
* stream: a long transfer or console. forward: port forwarding. background
* (the default): short exec work such as polling.
*/
export type PluginSshProfile =
"terminal" | "session" | "stream" | "forward" | "background";
export type PluginSshAuthOutcome =
| { status: "ready" }
| {
status: "interaction-required";
/** Transports send "<interaction>_auth_required", e.g. "opkssh". */
interaction: string;
message: string;
/** Extra boolean HTTP transports put in their 401 body. */
flag?: string;
}
| {
status: "error";
code:
| "missing-secret"
| "invalid-key"
| "passphrase-required"
| "provider-missing"
| "failed";
message: string;
}
| {
/** Try once more with these config changes. */
status: "retry";
patch: Record<string, unknown>;
message: string;
};
export type PluginSshBannerDecision =
| {
/** Hold the connect timeout open and wait for the out-of-band step. */
action: "hold";
timeoutMs: number;
message: string;
details?: Record<string, unknown>;
}
| {
/** The out-of-band step finished; resume the normal connect timeout. */
action: "release";
details?: Record<string, unknown>;
};
export interface PluginKeyboardInteractivePrompt {
prompt: string;
echo?: boolean;
}
/**
* A round the user finishes in a browser: open a URL, confirm a code there,
* then continue. Transports show one generic dialog for it and name their
* messages after `id` (the terminal sends "<id>_auth_required" and waits for
* "<id>_auth_continue").
*/
export interface PluginBrowserSignInRound {
kind: "browser";
/** The keyboard-interactive handler that claimed the round. */
id: string;
/** Product name for the dialog title. */
label: string;
url: string | null;
/** A code to compare in the browser, or "N/A". */
code: string;
instructions: string;
}
export type PluginKeyboardInteractiveDecision =
| { kind: "auto"; responses: string[] }
| PluginBrowserSignInRound
| { kind: "totp"; promptIndex: number }
| { kind: "input"; promptIndex: number; isPush: boolean };
export type PluginSshPromptRequest =
| { kind: "totp"; prompt: string; retry: boolean }
| { kind: "input"; prompt: string; echo: boolean; isPush: boolean }
| PluginBrowserSignInRound;
/** What a keyboard-interactive handler's detect may return. */
export type PluginKeyboardInteractiveDetection =
| Exclude<PluginKeyboardInteractiveDecision, PluginBrowserSignInRound>
| Omit<PluginBrowserSignInRound, "id" | "label">;
/**
* Claims keyboard-interactive rounds on any host, whatever its auth type,
* for a server that speaks its own prompt style (a bastion asking for a
* browser sign-in). Handlers run before core's TOTP and push detection.
*
* `settings` is this plugin's host-scope settings for the host, with manifest
* defaults filled in. Both hooks are synchronous; they run inside ssh2's
* keyboard-interactive callback.
*/
export interface PluginKeyboardInteractiveHandler {
/** Listed in contributes.auth.keyboardInteractive. */
id: string;
/** Product name shown when a browser round is claimed. */
label: string;
detect: (
round: {
name: string;
instructions: string;
prompts: PluginKeyboardInteractivePrompt[];
},
host: PluginSshHost,
settings: Record<string, unknown>,
) => PluginKeyboardInteractiveDetection | null;
/** Answer password prompts with the stored password instead of asking. */
autoAnswerPasswords?: (
host: PluginSshHost,
settings: Record<string, unknown>,
) => boolean;
}
/** Answers keyboard-interactive prompts. Resolve null to give up. */
export interface PluginSshPromptChannel {
ask: (request: PluginSshPromptRequest) => Promise<string | null>;
}
export interface PluginSshConnectOptions {
purpose?: PluginSshPurpose;
/** Keepalive and timeout defaults. Defaults to "background". */
profile?: PluginSshProfile;
/** Overall time to reach "ready". Defaults to 30s. */
timeoutMs?: number;
/** Without one, the stored password answers password prompts. */
prompt?: PluginSshPromptChannel;
/** Config fields to force, e.g. a longer readyTimeout. */
overrides?: Record<string, unknown>;
/**
* An already-open stream to the host, such as a forwardOut channel through
* another host. The transport step is skipped: no port knocking, proxy,
* jump hosts or DNS lookup. Host key checks and auth still run.
*/
sock?: unknown;
}
export interface PluginSshConnection<Client = unknown> {
/** An ssh2 Client, already "ready". */
client: Client;
jumpClient: Client | null;
/** The host as core resolved it: ip, port, username, name and the rest. */
host: PluginSshHost;
/** Ends the connection and its jump chain. Also runs on deactivate. */
dispose: () => void;
}
/**
* SSH through core's one connect pipeline: host resolution, auth providers,
* jump hosts, proxies, host key checks, keepalive and port knocking.
*
* Every call needs ssh:connect and credentials:use, runs as the acting user
* (or the host's owner for background work on a resolved host) and writes an
* audit line per new connection. Connections still open on deactivate are
* closed.
*/
export interface PluginSsh {
connect: <Client = unknown>(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
) => Promise<PluginSshConnection<Client>>;
/** Borrows a pooled connection. The pool key is `<pool>:<owner>:<address>`. */
withConnection: <T, Client = unknown>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
) => Promise<T>;
/**
* A ready client at the end of a jump host chain, each hop resolved and
* authenticated through the pipeline. For forwarding to something that is
* not an SSH server (a remote desktop port behind a bastion).
*/
jumpChain: <Client = unknown>(
jumpHosts: Array<{ hostId: number }>,
options?: {
/** Background work: resolve the hops as this host's owner. */
forHost?: PluginSshHost;
/** Answers a hop asking for TOTP or other input. */
prompt?: PluginSshPromptChannel;
},
) => Promise<PluginSshConnection<Client>>;
/** The pool key withConnection would use, for clearing it. */
poolKey: (pool: string, host: PluginSshHost) => string;
/**
* Closes this plugin's pooled connections to a host, for when its details
* changed. Only keys this plugin created are touched.
*/
dropPooled: (pool: string, hostId: number) => void;
/**
* 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.
*/
prepare: (
host: PluginSshHost,
options: {
purpose?: PluginSshPurpose;
/** Keepalive and timeout defaults. Defaults to "background". */
profile?: PluginSshProfile;
client: unknown;
/** 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<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;
/** Answers a jump host asking for TOTP or other input. */
prompt?: PluginSshPromptChannel;
},
) => 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: {
name: string;
instructions: string;
prompts: PluginKeyboardInteractivePrompt[];
},
host: PluginSshHost,
) => PluginKeyboardInteractiveDecision;
/** Stored password for password prompts, empty for the rest. */
autoResponses: (
prompts: PluginKeyboardInteractivePrompt[],
password: string | null | undefined,
) => string[];
/** Whether an auth type carries a secret (drives "auth_required" flows). */
requiresSecret: (authType: string) => boolean;
/** Whether an auth type can connect unattended, for polling. */
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;
userId: string;
hostId: number;
purpose: string;
interactive: boolean;
log: (level: "info" | "warning" | "error", message: string) => void;
}
/**
* An SSH auth type. Its `type` is what ssh_data.auth_type stores, and it must
* be listed in the manifest's contributes.auth.sshAuthTypes.
*/
export interface PluginSshAuthProvider {
type: string;
labelKey: string;
descriptionKey?: string;
/** Host and credential editor fields, in the settings field schema. */
fields?: PluginSettingsField[];
/** Also offered as a stored credential type. */
credentialType?: boolean;
/** Needs a browser sign-in or a person at the keyboard. */
needsUserInteraction?: boolean;
/** Carries a secret, so a shared host needs one resolved per recipient. */
requiresSecret?: boolean;
/** Can connect unattended. Defaults to true. */
supportsBackground?: boolean;
/**
* Works for a host that was never saved (nothing in host settings, no host
* id to key a cache on), so Quick Connect offers it. Default false.
*/
quickConnect?: boolean;
/** Interaction name its outcomes use, for startInteraction routing. */
interaction?: string;
connectOptions?: (
host: PluginSshHost,
purpose: string,
) => Record<string, unknown>;
/** Fills the ssh2 config for this host. Never connects. */
prepare: (
config: Record<string, unknown>,
host: PluginSshHost,
env: PluginSshAuthEnv,
) => Promise<PluginSshAuthOutcome>;
/** Claims keyboard-interactive rounds for hosts of this type. */
onKeyboardInteractive?: (
round: {
name: string;
instructions: string;
prompts: PluginKeyboardInteractivePrompt[];
},
host: PluginSshHost,
) => PluginKeyboardInteractiveDecision | null;
/** Synchronous: decide on a retry before the socket closes. */
onAuthFailed?: (
host: PluginSshHost,
env: PluginSshAuthEnv,
context: {
error: Error;
retries: number;
canRetry: boolean;
methodNotAvailable: boolean;
},
) => PluginSshAuthOutcome | undefined;
/**
* Claims an ssh2 "banner" event during the handshake, for a server that
* holds the connection open pending an out-of-band step (Tailscale SSH
* check mode). A transport that can show this sends `<type>_check_required`
* / `<type>_check_completed` over its socket, with `details` merged in.
* Return nothing to leave the banner unhandled.
*/
onBanner?: (
banner: string,
host: PluginSshHost,
env: PluginSshAuthEnv,
) => PluginSshBannerDecision | undefined;
/** Starts the browser step behind an interaction-required outcome. */
startInteraction?: (request: {
userId: string;
hostId: number;
host: { name?: string | null; ip: string; username: string };
socket: unknown;
requestOrigin: string;
payload: Record<string, unknown>;
}) => Promise<void>;
/** Cancels a pending browser step. */
cancelInteraction?: (request: {
userId: string;
hostId?: number;
requestId?: string;
}) => void | Promise<void>;
}
/**
* Who a login method says the user is. Core finds or provisions the user,
* runs second factors and issues the session; a method never does.
*/
export type PluginVerifiedIdentity =
| {
kind: "user";
userId: string;
/** The method already proved a second factor (a verified passkey). */
mfaSatisfied?: boolean;
/** Password logins unlock the user's data key with it. */
password?: string;
/** Redirect methods: where the browser goes back to. */
returnTo?: string;
rememberMe?: boolean;
}
| {
kind: "external";
/** Stable provider id, e.g. an SSO provider row id. */
provider: string;
/** The provider's id for the user. At most 255 characters. */
subject: string;
email?: string | null;
name?: string | null;
groups?: string[];
/** Provider says the user is an admin (admin group, LDAP group). */
isAdmin?: boolean;
/** Allowed-users list to check before provisioning or signing in. */
allowedUsers?: string | null;
mfaSatisfied?: boolean;
/** Redirect methods: where the browser goes back to. */
returnTo?: string;
rememberMe?: boolean;
/**
* Provider groups mapped to Termix roles. Core adds the desired roles
* and removes managed roles the user no longer maps to, on every login.
* Roles outside `managed` are never touched.
*/
roles?: { desired: string[]; managed: string[] };
/**
* Stored on the session so `ctx.auth.revokeSessions` can find it later,
* for an identity provider's back-channel logout.
*/
logoutClaims?: {
providerId?: number | null;
sub?: string | null;
sid?: string | null;
};
/**
* The identifier a 2.8 install kept for the user. Only for a plugin
* that took over a 2.8 login method: core also checks `identifier`
* against `allowedUsers`. `providerRowId` is no longer read.
*/
legacy?: { identifier: string; providerRowId?: number | null };
/**
* The key this attempt was counted under with
* `ctx.auth.loginRateLimit.recordFailure`. Cleared on success.
*/
rateLimitKey?: string;
};
/** Request shape a login method sees. Structural, like PluginRequestLike. */
export interface PluginLoginRequest {
body: Record<string, unknown>;
query: Record<string, unknown>;
headers: Record<string, unknown>;
ip?: string;
[key: string]: unknown;
}
export interface PluginLoginInstance {
/** Passed back to start/verify as instanceId, e.g. an SSO provider id. */
id: string;
label: string;
enabled: boolean;
/**
* Provider type shown to 2.8 clients by the old `GET /users/sso-providers`
* route ("oidc", "github", "google", "ldap"). Leave unset otherwise.
*/
type?: string;
/**
* Redirect methods: the login page starts this instance without a click,
* for a server that signs everyone in through one provider. The first
* instance that asks wins.
*/
autoStart?: boolean;
}
export interface PluginLoginMethod {
id: string;
labelKey: string;
icon?: string;
kind: "redirect" | "form";
/**
* True for a method that authenticates against something other than a
* password or key this server holds: SSO, an external directory, a
* federated identity provider. Governs whether the "ask for a second
* factor after external logins" admin setting applies to it. Leave unset
* for a local method (password, a passkey, a plugin's own local form).
*/
external?: boolean;
/** Enabled instances, shown on the login screen. No secrets. */
describe?: () => Promise<PluginLoginInstance[]>;
/** Redirect methods: where to send the browser. */
start?: (
request: PluginLoginRequest,
instanceId: string | null,
) => Promise<{ redirectUrl: string }>;
/** Redirect methods: the provider's callback. */
callback?: (request: PluginLoginRequest) => Promise<PluginVerifiedIdentity>;
/** Form methods: check what the user typed. */
verify?: (
request: PluginLoginRequest,
instanceId: string | null,
) => Promise<PluginVerifiedIdentity>;
}
export interface PluginSecondFactor {
id: string;
labelKey: string;
isEnrolled: (userId: string) => Promise<boolean>;
/** Anything the client needs before the user answers. */
challenge?: (userId: string) => Promise<unknown>;
/**
* True when the answer is right. Return an object to refuse with a specific
* message, for example when the factor had to be reset.
*/
verify: (
userId: string,
body: Record<string, unknown>,
) => Promise<boolean | { ok: false; error: string; code?: string }>;
/** Removes the user's enrolment. Called by the admin reset. */
reset?: (userId: string) => Promise<void>;
}
/**
* Login methods, second factors and SSH auth types. Every register call needs
* auth:provide and a matching contributes.auth entry, and is undone on
* deactivate. Core owns sessions: a method returns an identity and core does
* the rest.
*/
export interface PluginAuth {
registerSshAuthProvider: (provider: PluginSshAuthProvider) => void;
/** A host-independent keyboard-interactive handler. */
registerKeyboardInteractiveHandler: (
handler: PluginKeyboardInteractiveHandler,
) => void;
registerLoginMethod: (method: PluginLoginMethod) => void;
registerSecondFactor: (factor: PluginSecondFactor) => void;
/**
* Marks a user as enrolled in one of this plugin's factors. Core keeps the
* row even when the plugin is gone, so it can refuse the login instead of
* skipping the factor.
*/
recordEnrollment: (userId: string, factorId: string) => Promise<void>;
removeEnrollment: (userId: string, factorId: string) => Promise<void>;
/**
* Finishes a redirect login from one of this plugin's own public routes:
* runs the registered method's `callback`, then core finds or provisions
* the user, runs second factors and redirects the browser back with a
* session, exactly like `/users/auth/<methodId>/callback`. The method must
* be one this plugin registered.
*/
completeRedirectLogin: (
methodId: string,
req: unknown,
res: unknown,
) => Promise<void>;
/**
* Revokes sessions whose login carried matching `logoutClaims`, for a
* back-channel logout. Needs `sub` or `sid`; `providerId` narrows it.
* Returns how many sessions ended.
*/
revokeSessions: (match: {
providerId?: number | null;
sub?: string | null;
sid?: string | null;
}) => Promise<number>;
/**
* Core's login rate limiter, the one password login uses. Keys are kept
* apart per plugin. Put the key in the identity's `rateLimitKey` so a
* successful login clears it.
*/
loginRateLimit: {
isLocked: (
ip: string,
key: string,
) => Promise<{ locked: boolean; remainingTime?: number }>;
recordFailure: (ip: string, key: string) => Promise<void>;
};
/**
* How many users are linked to an external identity from `provider` (the
* `provider` of the identities this plugin returns). For refusing to
* delete a provider people still sign in with.
*/
countLinkedUsers: (provider: string) => Promise<number>;
}
export interface PluginOpenIsolatedWindowRequest {
/** http(s) only. Refused when it points anywhere else. */
url: string;
/** Isolated Electron session partition; a fresh one when omitted. */
partition?: string;
title?: string;
/** Present an invalid TLS certificate on this window's own origin only. */
ignoreCert?: boolean;
}
/**
* Opens a desktop window outside the main renderer, for a target a plugin
* does not want sharing Termix's own session (a tunnelled or direct web UI).
* Electron only: rejects when the server is not running embedded in the
* desktop app. Needs desktop:window.
*/
/**
* A host to open in the operating system's own client for a protocol. The
* desktop app supports "rdp" (mstsc on Windows) today; any other protocol is
* refused.
*/
export interface PluginExternalClientRequest {
protocol: string;
host: string;
port?: number;
username?: string;
domain?: string;
}
export interface PluginDesktop {
openIsolatedWindow: (
request: PluginOpenIsolatedWindowRequest,
) => Promise<{ success: true }>;
/**
* Opens the operating system's own client for a protocol. The password is
* never passed; the client asks for it. Desktop app only.
*/
launchExternalClient: (
request: PluginExternalClientRequest,
) => Promise<{ success: boolean; error?: string }>;
/** Whether the server runs embedded in the desktop app, so the calls above can work. */
available: () => boolean;
}
/**
* A host protocol whose login core keeps next to the host: any id a plugin
* declares in contributes.protocols.
*/
export type PluginHostProtocol = string;
/**
* What a plugin needs to hand a host's protocol login to something core
* does not run (guacd): the address and the plaintext credentials the acting
* user is allowed to use. A shared recipient gets the owner's shared
* snapshot or their own override, never the owner's raw secret.
*/
export interface PluginProtocolTarget {
host: {
id: number;
name: string | null;
ip: string;
/** The host's SSH port, the old fallback when no protocol port is set. */
port: number;
ownerUserId: string;
jumpHosts: Array<{ hostId: number }>;
};
/** True when the acting user is not the owner. */
shared: boolean;
auth: {
/** "direct", "credential", or "none" when the user is asked at connect time. */
authType: string;
username: string;
password: string;
/** The protocol's declared credentialFields, secret ones included; "" when unset. */
fields: Record<string, string>;
};
}
/** One of the acting user's saved SSH key credentials, public half only. */
export interface PluginSshKeyCredential {
id: number;
name: string;
username: string | null;
/** "<type> <base64>", derived from the private key when none was stored. Null when it cannot be. */
publicKey: string | null;
}
/** A key pair to save as a new credential for the acting user. */
export interface PluginSshKeyCredentialInput {
name: string;
description?: string;
username?: string | null;
privateKey: string;
publicKey: string;
/** The key's algorithm token, such as "ssh-ed25519". */
keyType: string;
}
/**
* Plaintext host credentials, for a plugin that has to hand them to another
* program. Needs credentials:read, and every call is audited.
*/
export interface PluginCredentials {
/**
* The acting user's own SSH key credentials, without any private material.
* Needs credentials:use and the core credentials.view permission. Audited.
*/
listSshKeys: () => Promise<PluginSshKeyCredential[]>;
/**
* Saves a key pair as a new encrypted credential owned by the acting user
* and returns its id. Needs credentials:write, the core credentials.create
* permission and an unlocked data key. Audited.
*/
createSshKey: (input: PluginSshKeyCredentialInput) => Promise<{ id: number }>;
/**
* The host's login for one of this plugin's own contributes.protocols.
* Null when the host does not exist or the acting user cannot connect to
* it. Needs credentials:read. Audited.
*/
resolveHostProtocol: (
hostId: number,
protocol: PluginHostProtocol,
) => Promise<PluginProtocolTarget | null>;
/**
* Registers this plugin as the resolver for "<scheme>://..." references in
* a host's secret fields (secret-sources registers "op" for
* "op://vault/item/field"). Needs auth:provide, like registerSshAuthProvider.
*
* `resolve` runs once per host resolution, for the user the field's owner
* resolves as (the host owner, or the recipient for a personal override),
* and its result is cached briefly by the connect pipeline. A scheme with no
* registered resolver fails the connect with a message naming the plugin
* that would provide it.
*/
registerSecretResolver: (
scheme: string,
resolve: (userId: string, reference: string) => Promise<string>,
) => void;
}
/**
* 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;
/**
* The HTTP request or WebSocket upgrade this happened on, for the line's
* IP address and user agent.
*/
request?: unknown;
}) => Promise<void>;
}
/** A notification channel a user set up. Its config never leaves the hub. */
export interface PluginNotificationChannel {
id: number;
name: string;
/** "webhook", "ntfy", "discord", "email", or whatever the hub supports. */
type: string;
enabled: boolean;
}
export type PluginNotificationSeverity =
"info" | "success" | "warning" | "critical";
/**
* Who receives an alert. Left out, it goes to the acting user. "admins" and
* a permission reach every user who holds it when the alert is sent.
*/
export type PluginNotificationAudience =
{ userId: string } | { permission: string } | "admins";
export interface PluginNotification {
title: string;
body?: string;
/** Defaults to "warning". */
severity?: PluginNotificationSeverity;
/**
* What kind of alert this is, "<pluginId>.<kind>" by convention, e.g.
* "acme-ssl.renewal_failed". Users route alerts to channels by it.
* Defaults to the sending plugin's id.
*/
category?: string;
audience?: PluginNotificationAudience;
/**
* Channels to deliver to on top of the recipient's own routing rules.
* Only channels the recipient owns are used.
*/
channelIds?: number[];
/** Where the alert points: a tab type to open, or an http(s) URL. */
link?: { tab?: string; url?: string };
/** While an unread alert with this key exists, repeats are dropped. */
dedupeKey?: string;
/**
* Extra fields a webhook receiver gets alongside the message. `sourceId`
* and `sourceName` name what sent it (an automation, a rule).
*/
context?: {
hostId?: number;
hostName?: string;
sourceId?: number | string;
sourceName?: string;
triggerType?: string;
value?: unknown;
threshold?: unknown;
[key: string]: unknown;
};
}
export interface PluginNotifyResult {
/** Users the alert reached. */
recipients: number;
/** Channel deliveries that succeeded, across every recipient. */
delivered: number;
failures: Array<{ channelId: number; name: string; error: string }>;
}
/**
* The plugin that stores alerts and delivers them to channels. Core resolves
* the audience and hands the hub a list of user ids; it never sees a channel.
*/
export interface PluginNotifyHub {
deliver: (input: {
/** The plugin that sent the alert. */
source: string;
recipients: string[];
notification: PluginNotification;
}) => Promise<PluginNotifyResult>;
channels: (userId: string) => Promise<PluginNotificationChannel[]>;
}
/**
* Alerts. Every plugin sends through here and the hub plugin decides where
* they land. Needs notify:send. With no hub running, send reaches nobody and
* channels answers an empty list, so callers never have to check.
*/
export interface PluginNotify {
/** The acting user's channels, without their config. Needs an actor. */
channels: () => Promise<PluginNotificationChannel[]>;
/**
* Sends an alert. With no audience it goes to the acting user, so a call
* with neither is refused. One channel failing does not stop the others.
*/
send: (notification: PluginNotification) => Promise<PluginNotifyResult>;
/**
* Makes this plugin the hub. Needs notify:hub. Only one hub runs at a time;
* a second one is refused. Revoked on deactivate.
*/
serve: (hub: PluginNotifyHub) => () => void;
}
export interface PluginFetchInit {
/** Return redirect responses without following them, or reject them (default). */
redirect?: "error" | "manual";
method?: string;
headers?: Record<string, string>;
body?: string;
/**
* Aborts the request when no response headers arrive within this long.
* Defaults to 30 seconds. A streamed body may also sit idle between chunks
* for this long, never less than 5 minutes.
*/
timeoutMs?: number;
/**
* Exact private or loopback hosts this request may reach. Everything else
* private is refused, and redirects are never followed.
*/
allowPrivateHosts?: readonly string[];
/** Aborts the request, and a streamed body, when the caller gives up. */
signal?: AbortSignal;
/**
* TLS for a private CA. `ca` trusts this PEM bundle instead of the system
* store. `rejectUnauthorized: false` skips verification, only for fetching
* a CA root by fingerprint, where the caller checks the answer itself.
*/
tls?: { ca?: string; rejectUnauthorized?: boolean };
}
/**
* Outbound HTTP through core's SSRF guard: http and https only, no embedded
* credentials, DNS pinned, private addresses refused unless listed. Needs
* network:outbound.
*/
export type PluginFetch = (
url: string,
init?: PluginFetchInit,
) => Promise<Response>;
/**
* A generic capability check, for a privileged action that has no dedicated
* ctx method to wrap it: a plugin bundling its own native dependency to touch
* hardware (a serial port, a USB device) is the first caller. Core cannot
* mediate that access the way it mediates ctx.ssh or ctx.db, so the
* capability here is a declared, reviewable, audited statement of intent
* rather than a technical gate. Running programs has its own member,
* ctx.process.
*/
export interface PluginCapabilities {
/** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */
has: (capability: string) => Promise<boolean>;
/** Throws PluginCapabilityError if this plugin does not hold `capability`. Audited like every guarded ctx method. */
require: (capability: string) => Promise<void>;
}
export interface PluginProcessOptions {
/** Added to a small base environment (PATH, HOME, temp, locale, proxy), never the whole server one. */
env?: Record<string, string>;
cwd?: string;
/** Kills the program with SIGKILL after this long. */
timeoutMs?: number;
}
/** A running program started with ctx.process.run. */
export interface PluginProcessHandle {
readonly pid: number | undefined;
/** Output as UTF-8 text, in the chunks it arrives in. */
onStdout: (listener: (chunk: string) => void) => void;
onStderr: (listener: (chunk: string) => void) => void;
/** Settles when the program ends, or rejects when it cannot start. */
readonly exited: Promise<{ code: number | null; signal: string | null }>;
kill: (signal?: "SIGTERM" | "SIGKILL") => void;
}
/** A program a plugin downloads and runs, pinned to one checksum. */
export interface PluginBinarySpec {
/** File name under the plugin's data dir, e.g. "opkssh-linux-amd64". */
name: string;
version: string;
/** https URL of the release asset. Redirects are followed. */
url: string;
/** Hex SHA-256 the file must have. */
sha256: string;
/**
* Absolute paths checked before downloading, such as a copy baked into the
* Docker image. Only a regular file named `name` is considered, and one
* whose checksum matches is used in place.
*/
prebuilt?: readonly string[];
}
/**
* Programs on the Termix server. Needs process:spawn. Every program still
* running is killed on deactivate.
*/
export interface PluginProcess {
/** Starts `file` with `args`, no shell. Audited. */
run: (
file: string,
args: readonly string[],
options?: PluginProcessOptions,
) => Promise<PluginProcessHandle>;
/**
* Returns the path of a verified executable: a matching prebuilt copy, the
* copy downloaded earlier, or a fresh download (which also needs
* network:outbound). Throws when the checksum does not match.
*/
ensureBinary: (spec: PluginBinarySpec) => Promise<string>;
}
/**
* Timers owned by the plugin. Every timer is cleared on deactivate, a run that
* throws is logged instead of crashing the server, and a repeating job never
* overlaps itself: a tick that arrives while the last run is still going is
* skipped. No capability needed.
*/
export interface PluginSchedule {
/** Runs `fn` every `intervalMs`. Returns a function that stops it. */
every: (
intervalMs: number,
fn: () => void | Promise<void>,
options?: {
/** Delays the first run by up to this much, so many timers spread out. */
jitterMs?: number;
/** Also run once straight away. */
runNow?: boolean;
},
) => () => void;
/** Runs `fn` once after `delayMs`. Returns a function that cancels it. */
after: (delayMs: number, fn: () => void | Promise<void>) => () => void;
}
/** The certificate core serves, read from its configured paths. */
export interface PluginTlsCertificateInfo {
subject: string;
issuer: string;
/** DNS names and IPs from the subject alternative names. */
names: string[];
notBefore: string;
notAfter: string;
/** True for core's own first-boot certificate. */
selfSigned: boolean;
/** SHA-256 fingerprint, colon separated. */
fingerprint: string;
}
export interface PluginTlsStatus {
/** Whether Termix is set up to serve HTTPS at all. */
enabled: boolean;
/** Null when there is no readable certificate. */
certificate: PluginTlsCertificateInfo | null;
/** The plugin that renews the certificate, when one is registered. */
renewal: { pluginId: string; pluginName: string } | null;
}
export interface PluginTlsReloadResult {
/** Whether the new certificate is being served now. */
applied: boolean;
message: string;
}
/**
* The server certificate. Needs system:tls, and every call is audited. Core
* owns serving it; a plugin can only replace it, reload it and answer ACME
* http-01 challenges.
*/
export interface PluginSystem {
tlsStatus: () => Promise<PluginTlsStatus>;
/**
* Replaces the served certificate and key. Throws when either does not
* parse, the certificate has expired, or the key does not match it. Does
* not reload: call reloadTls() after.
*/
writeTlsCertificate: (
certificatePem: string,
privateKeyPem: string,
) => Promise<PluginTlsCertificateInfo>;
/** Serves the certificate on disk without a restart. */
reloadTls: () => Promise<PluginTlsReloadResult>;
/**
* Answers GET /.well-known/acme-challenge/<token> with `content` until the
* returned function is called or the plugin is disabled.
*/
publishHttpChallenge: (token: string, content: string) => Promise<() => void>;
/**
* Tells core this plugin renews the certificate, so admin settings stop
* warning that nothing does. Dropped on deactivate.
*/
registerTlsRenewer: () => Promise<() => void>;
}
/** One installed plugin as ctx.plugins.list reports it. */
export interface PluginSummary {
id: string;
version: string;
source: "bundled" | "user";
/** "active" when running; anything else means it is not. */
state: string;
}
/** The installed plugins. Needs plugins:read. */
export interface PluginPlugins {
list: () => Promise<PluginSummary[]>;
}
export interface PluginContext {
readonly pluginId: string;
readonly manifest: PluginManifest;
readonly log: PluginLogger;
readonly events: PluginEvents;
readonly kv: PluginKeyValue;
readonly files: PluginFiles;
readonly db: PluginDatabase;
readonly sync: PluginSync;
readonly registry: PluginRegistry;
readonly services: PluginServices;
readonly secrets: PluginSecrets;
readonly http: PluginHttp;
readonly ws: PluginWebSockets;
readonly rbac: PluginRbac;
readonly settings: PluginSettings;
readonly disposables: PluginDisposables;
/** A generic capability check, for privileged code no ctx method wraps. */
readonly capabilities: PluginCapabilities;
/** Hosts the actor can see, and host-sharing operations. Needs hosts:read / hosts:write. */
readonly hosts: PluginHosts;
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
readonly ssh: PluginSsh;
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
readonly auth: PluginAuth;
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
readonly desktop: PluginDesktop;
/** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */
readonly credentials: PluginCredentials;
/** Audit lines under the plugin's own action names. */
readonly audit: PluginAudit;
/** Timers that stop on deactivate. */
readonly schedule: PluginSchedule;
/** Core's notification channels. Needs notify:send. */
readonly notify: PluginNotify;
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
readonly fetch: PluginFetch;
/** Programs on the Termix server. Needs process:spawn. */
readonly process: PluginProcess;
/** The server certificate. Needs system:tls. */
readonly system: PluginSystem;
/** The installed plugins and whether each runs. Needs plugins:read. */
readonly plugins: PluginPlugins;
/**
* Runs `fn` with `userId` as the acting user, for background work that has
* no request behind it. Always audited, and every core API inside still
* applies that user's RBAC.
*
* Needs users:impersonate. That and a `userId` option on services.get or
* secrets.getShared are the only ways a plugin can name a user.
*/
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
/** The acting user for the current call, when there is one. */
currentActor: () => string | undefined;
}
export type PluginActivate = (ctx: PluginContext) => void | Promise<void>;
export type PluginDeactivate = () => void | Promise<void>;
export interface PluginModule {
activate: PluginActivate;
deactivate?: PluginDeactivate;
}
/**
* Identity helper that gives a plugin entry its types without importing the
* runtime. Everything a plugin creates belongs inside activate: the module is
* cached by the ESM loader, so disable-then-enable re-runs activate on the
* same module object.
*/
export function definePlugin(plugin: PluginModule): PluginModule {
return plugin;
}
export type { PluginManifest } from "./manifest.js";
export type { PluginTableDefinition } from "./db.js";
export type {
PluginSettingsField,
PluginSettingsContribution,
PluginSettingsScope,
} from "./manifest.js";