feat: finish the remote-desktop plugin (B14)

This commit is contained in:
LukeGus committed 2026-09-24 01:02:45 -05:00
1 parent 0af2c94983
commit 14eb558b85
215 files changed
+30029 -3921

No files matched your search

+97 -18
View File
@@ -279,6 +279,12 @@ it, `group` is a section heading, and `permission` is who may write it: admin
fields fall back to `admin.plugins.manage`, and a short name resolves against
the plugin's own permissions exactly as `ctx.rbac` does.
`hidden: true` (**B14**) keeps a field out of the generic form: it is stored,
validated and encrypted like any other, but the plugin edits it in its own
UI. Remote desktop's host options are hidden fields, edited in its RDP, VNC
and Telnet host editor tabs, so they do not appear a second time in the
Plugins tab.
`type: "custom"` names a component the frontend registered, for the few things
a schema cannot express (a device browser, a provider list). It is deliberately
narrow: a plugin supplies data everywhere else, so it cannot ship its own form
@@ -339,8 +345,10 @@ A plugin frontend is a built ESM bundle, `dist/frontend.js`, exporting
no imports from `plugins/`; everything a plugin shows goes through the `app`
object. `scripts/check-shell-plugin-ids.cjs` (run by lint) fails on a quoted
plugin id in `src/ui` outside tests, apart from the entries in
`scripts/shell-plugin-id-allowlist.json`, each with a reason (host protocol
data like `rdp`, which Phase B moves).
`scripts/shell-plugin-id-allowlist.json`, each with a reason (host data like
the `docker` runtime name, which Phase B moves). **B14** removed the `rdp`,
`vnc` and `telnet` entries: the shell learns those protocols from
`app.registerHostProtocol`.
#### The loader
@@ -427,6 +435,7 @@ boundary and Suspense.
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
| `registerHostProtocol` | A connection protocol next to SSH (**B14**): General tab switch, Hosts panel filter and grouping, Quick Connect, the host's primary type and port |
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
| `registerPaletteEntry` | A global or per-host command palette entry |
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
@@ -437,6 +446,7 @@ boundary and Suspense.
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) |
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
@@ -504,6 +514,19 @@ their host info), `terminal.sendToActive(text, { run })` and
its terminal tab wrapper populates from the core terminal ref it already
holds. The snippets plugin's run/paste flow is the first caller.
**Host protocols (B14).** `app.registerHostProtocol({ id, settingKey,
portKey, defaultPort, titleKey, descriptionKey, icon, quickConnect })` adds a
protocol next to SSH. Its switch and port are the plugin's own host settings,
so core reads them from `host.pluginSettings[<plugin>]` and never knows the
protocol by name: the General tab draws the switch and saves it into the
plugin's settings, the Hosts panel filters and groups by it, HostItem counts
it, Quick Connect offers it (with a domain field when asked), and a host with
SSH off takes the first protocol switched on as its `connectionType` and its
port. `hostProtocols(host)` from `@termix/plugin-sdk/frontend` lists what a
host has on (`"ssh"` included), for a plugin such as session sharing that
offers every protocol without knowing who owns it. Remote desktop registers
`rdp`, `vnc` and `telnet`.
#### View ownership
Who owns a tab, panel or card comes from manifests, so it is known even for a
@@ -547,7 +570,11 @@ 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
stopgap in the right place: D1 turns them into typed bridge members.
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
`ConnectionStage` type, for remote desktop's display socket and its remote
host lookup. `useConnectionDefaults` now carries terminal defaults only; the
RDP half became remote desktop's user settings. Publishing its
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
#### Strings
@@ -596,10 +623,12 @@ 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 `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.
`ctx.registry` convention, 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; remote desktop's (**B14**) also reads a Termix export's
`pluginSettings` and the flat fields hosts had before 2.9.0. Host exports
carry every plugin's host settings (secrets redacted) under `pluginSettings`,
which is what makes the round trip work without core naming a plugin.
**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
@@ -623,8 +652,11 @@ per-chunk itself), `persist(summary)` and `discard()` for a session that
recorded nothing. `createFinished(input)` is the second half of the
contract, for a caller that already wrote its own recording to disk and only
needs the database row: remote-desktop's guacd recordings use it instead of
importing the old repository directly, and without the plugin enabled a
guacd recording still lands on disk but gets no row. Recording files live
importing the old repository directly. **B14** added `enabledFor(hostId)`
(version 1.1.0), because guacd has to be told to record before the session
exists: remote desktop records an RDP or Telnet session only when the plugin
is running and the host's switch is on, and writes the row as the session's
user. Recording files live
under `ctx.files.dataDir()/session_logs/<user>/<session>.cast`. Retention
(`retentionDays`, an admin setting) runs a sweep at boot and every 24 hours.
@@ -647,8 +679,10 @@ any other, `ctx.services.get(service, { provider })` reaches one, and
`ctx.services.providers(service)` lists the running ones. A requirement is
satisfied by any compatible provider. `sessions.live` is the first: keyed by
session type, ssh-terminal provides `ssh` and remote desktop provides `rdp`,
`vnc` and `telnet` (B14), with `createViewerToken(sessionId, readOnly)` for
a viewer. A consumer with no actor, such as a public guest route, calls it
`vnc` and `telnet` (**B14** delivered them), with
`createViewerToken(sessionId, readOnly)` for a viewer. A remote desktop
session id is guacd's own connection id, and `ownerEndSession` closes it and
every viewer joined to it. A consumer with no actor, such as a public guest route, calls it
through `ctx.asUser(<share owner>)`.
**Session sharing (B12)** is the session-sharing plugin. It provides
@@ -954,6 +988,19 @@ dropping a column before its data has moved loses it:
A column that is still read anywhere in core is not ready to be dropped. Ship
the copy and the switch first, and drop it in a later step if that is safer.
**Order at boot (B14).** On Postgres and MySQL, core's drizzle migrations run
while the database opens, and the copies into `plugin_settings` run later,
after the plugins table exists. A real `DROP COLUMN` in step 4 therefore
deletes the data before it is copied. So step 4 removes the column from
`schema.ts`, the pg/mysql variants and the `db/index.ts` bootstrap, and the
generated drizzle migration's `DROP COLUMN` statements are replaced with a
comment and `SELECT 1;`, exactly as for an adopted table. The column stays in
the database, unused, until 3.0.0 removes it. A copy reads it with raw SQL
through `utils/crypto-migration/raw-rows.ts`, whose `selectRows` and
`runStatement` work on all three engines: `getDb().all()` exists only on
SQLite. Remote desktop's host options and `user_preferences.rdp_defaults` are
the first columns handled this way.
---
## Manifest v2 reference
@@ -1230,6 +1277,8 @@ 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.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.fetch` | `network:outbound` | B |
@@ -1364,6 +1413,29 @@ share). Rejects outside the desktop app (`ELECTRON_EMBEDDED` unset, or no
shares cookies or storage with the main window or with another isolated
window.
**B14** added `ctx.desktop.launchNativeRdp({ host, port, username, domain })`
over the same bridge (a second entry in `BACKEND_REQUEST_HANDLERS`,
`launch-native-rdp`), which opens mstsc on Windows with a generated `.rdp`
file that never carries the password, and `ctx.desktop.available()`, which
says whether the server runs embedded in the desktop app. The renderer's own
`openNativeRdp` IPC call is gone; the desktop app's frontend always asks its
embedded backend, which is the one that can reach Electron.
**B14** also added `ctx.credentials.resolveHostProtocol(hostId, protocol)`,
the first member behind `credentials:read`. It returns the host's address,
jump hosts and the plaintext RDP, VNC or Telnet login the acting user may use,
for a plugin that has to hand them to another program (guacd). The logins
stay in core next to the host: an owner gets the stored login or the stored
credential it points at; a shared recipient gets only core's sharing
resolution (the owner's shared snapshot or their own override), never the
owner's raw secret; no connect access, or no host, is `null`. Every call is
audited as `plugin_credentials_read`, allowed or refused.
`ctx.hosts.create` (**B14**) hands the created row to every plugin's
`hostImportNormalizer`, the same as a bulk import row, so a plugin creating a
host can set another plugin's host settings (proxmox marks an imported
Windows guest as RDP) without core naming either.
### The actor
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
@@ -1888,8 +1960,8 @@ Then, with the app running:
## Legacy core imports: the debt D1 removes
The bundled plugins predate the SDK, apart from workspaces (A9) and snippets
(B2), which import nothing from core. The others still reach core by relative
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2) and remote-desktop (B14), which import nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
and rewrites to the compiled output path (`../../../backend/backend/...`, or
@@ -1916,8 +1988,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 | 130 files | D1 |
| A plugin importing core by relative path | Warning | 66 files | D1 |
| A plugin frontend importing core through `@/` | Warning | 120 files | D1 |
| A plugin importing core by relative path | Warning | 63 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
@@ -1957,6 +2029,13 @@ Known specifics:
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.
- Host data columns (`enableDocker` and so on) stay in core types until
Phase B moves them. Only UI branches on them left the shell. **B14** moved
remote desktop's (`enableRdp`, `enableVnc`, `enableTelnet`, the three ports,
`rdpSecurity`, `rdpIgnoreCert`, `guacamoleConfig`) into its host settings;
the protocol logins stay core host fields.
- **B14** moved remote desktop onto the SDK: it imports nothing from core,
owns no module state, and its guacamole-lite server is built in `activate`
with its own signal handlers removed (guacamole-lite installs SIGTERM and
SIGINT handlers in its constructor). guacd is always an external service,
so there is no `ctx.process`; its address is an admin setting.
+67 -27
View File
@@ -161,11 +161,9 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
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.
**B14** gave remote desktop its own `enableToolbar` host setting, copied
from `enable_terminal_toolbar`, so nothing else holds the column up. Drop
all three the B14 way (see "Order at boot" in ARCHITECTURE.md). Owner: 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
@@ -255,17 +253,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
section. Owner: whoever designs the 3.0.0 install flow (prebuild-per-platform
packaging, or a documented "avoid native code" rule for community plugins).
- **B12 (session-sharing):** the remote desktop side of sharing has no
provider yet. Session sharing reaches rdp, vnc and telnet sessions through
`sessions.live` named providers `rdp`, `vnc` and `telnet`, each with
`getSession(sessionId)` (id, userId as the session's owner, hostId,
hostName, isConnected, createdAt, tabInstanceId) and
`createViewerToken(sessionId, readOnly)`, typed in
`plugins/session-sharing/src/backend/live.ts`. Until remote-desktop provides
them, sharing or presenting an RDP/VNC/Telnet session is refused and those
links resolve 404. B14 also drops `createJoinToken`/`getSessionInfo` from
its `remote-desktop.sessions` registry entry, which core no longer reads.
Owner: B14.
- **B12 (session-sharing):** `allow_session_sharing` is still a live `ssh_data`
column. The copy into the plugin's host setting shipped
(`session-sharing-settings-migration.ts`) and nothing in core reads or
@@ -291,9 +278,8 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
a real behaviour change from the old column default, made once here rather
than silently. `ssh-terminal/src/backend/terminal-socket.ts` still reads
`resolvedHostData?.enableSessionLogging ?? hostConfig.enableSessionLogging`
off the resolved SSH host instead of `ctx.settings.getHost`, and
`plugins/remote-desktop/src/backend/routes.ts`'s
`host.enableSessionLogging !== false` check, `HostEditorData.ts`,
off the resolved SSH host instead of `ctx.settings.getHost` (remote desktop
asks `recordings.writer.enabledFor` since B14), and `HostEditorData.ts`,
`HostManagerData.ts`, `quick-connect-host.ts` and the export/import sample
all still read the column directly. The General tab's checkbox was removed
from `HostEditor.tsx` (the schema-driven Plugins group replaces it); the
@@ -301,14 +287,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
(`schema.ts`, `db/index.ts`, a drizzle migration per dialect,
`schema:generate`) needs every one of those switched first. Owner: a
dedicated follow-up step, or D0.
- **B13 (session-recording):** `remote-desktop`'s guacd recordings now insert
through the optional `recordings.writer` service's `createFinished(input)`
(added alongside `open()`/`RecordingSink` for ssh-terminal's incremental
writer) instead of importing `createCurrentSessionRecordingRepository`
directly. Without the session-recording plugin enabled, a guacd recording
is still written to disk but gets no `session_recordings` row and so never
appears in the list - a warning is logged. Owner: none needed unless a
second consumer of `recordings.writer` appears.
- **B13 (session-recording):** `accessId` (the legacy `access_id` column,
referencing the now-core-only `host_access` table) is carried in the
adopted table's shape as a plain unreferenced integer, since the SDK's
@@ -317,6 +295,56 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
in core before this step too). Owner: none needed unless a future step
wants to actually use it.
- **B14 (remote-desktop):** the moved columns stay in the database, unused:
`ssh_data.enable_rdp`, `enable_vnc`, `enable_telnet`, `rdp_port`,
`vnc_port`, `telnet_port`, `rdp_security`, `rdp_ignore_cert`, `security`,
`ignore_cert`, `guacamole_config`, and `user_preferences.rdp_defaults`.
Their drizzle migrations are `SELECT 1;` on purpose (the copy runs after
drizzle on Postgres and MySQL). Drop them physically in 3.0.0, once every
install has booted 2.9.0. The legacy `guac_enabled` and `guac_url` rows in
`settings` are left too. Owner: 3.0.0.
- **B14, found in B5 (proxmox):** `drizzle/postgres/0036_redundant_ender_wiggin.sql`
and `drizzle/mysql/0035_clumsy_leech.sql` really `DROP COLUMN` the four
proxmox host columns. On Postgres and MySQL that runs at boot before
`runProxmoxSettingsMigration` copies them, so an upgrade there loses every
host's Proxmox settings. Since nothing has shipped, rewrite those two files
to the `SELECT 1;` pattern (keeping their journal entries). Owner: D0.
- **B14, found in B5 to B13:** every earlier host-column copy
(`proxmox`, `file-manager`, `tunnels`, `web-endpoint`, `ssh-terminal`,
`tmux-monitor`, `session-sharing`, `session-recording` settings migrations)
reads the old columns with `getDb().all(sql...)`, which only exists on
SQLite. On Postgres and MySQL the call throws, the migration's catch logs a
warning and nothing is copied. Switch them to `selectRows` from
`utils/crypto-migration/raw-rows.ts`, which B14 added and uses. Owner: D0.
- **B14, found in B13:** `session-recording-settings-migration.ts` writes
`row.enable_session_logging !== false`, but SQLite returns `0`, so a host
that had recording off migrates as on. Its test only uses real booleans.
Owner: D0.
- **B14 (remote-desktop), for D2:** host payloads carry every plugin's host
settings after the sharing sanitizers, so a connect-level recipient now
receives remote desktop's `guacamoleConfig`, which can hold an RDP gateway
password in plain text. A view-level recipient saw it before B14 too.
Decide whether plugin host settings need a per-field sharing level, or make
the gateway password its own secret field. Owner: D2.
- **B14 (remote-desktop):** core still holds some locale keys that look dead
after this step but may be built dynamically: `hosts.guac.noCredential`,
`authType*`, `recording*`, `saveHostFirst`, `sharingOptionsAfterSave`,
`permissionLevel`, `typeHeader`, `targetHeader`, `permissionHeader`,
`hosts.connectRdp/Vnc/Telnet`, `hosts.copy*UrlAction`, `hosts.telnet` and
`homepage.connType_*`. Owner: whoever next cleans up `src/ui/locales/en.json`,
or D0.
- **Pre-existing, found in B14:** `npm run test -w plugins/web-endpoint`
fails "answers 503 while the tunnels plugin is not available": the route
expects `ctx.services.get` to throw for a missing service, but since B12
the runtime and the test doubles hand back an empty handle, so the route
calls an undefined `forward` and answers 500. Check `typeof forward` the
way remote desktop does. Owner: D0.
- **Pre-existing, found in B14:** each plugin's own `typecheck` script
(`tsc -p tsconfig.json`) fails with the SDK's `tsconfig.plugin.json`
(node16 resolution wants `.js` on relative imports and cannot resolve
`@termix/plugin-sdk/ui`), workspaces included. `npm run type-check` covers
plugins through `tsconfig.plugins*.json` and passes. Owner: D0.
## Manual checks after 2.9.0
- SSH terminal: password auth, key auth (and an encrypted key's passphrase
@@ -334,3 +362,15 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
off records nothing, retention prunes an old recording on schedule, a guacd
RDP/VNC/Telnet recording still gets a row and plays back, and deleting a
user anonymizes their recordings instead of removing them.
- Remote desktop: an RDP, a VNC and a Telnet host; a host behind one jump
host (tunnels plugin on and off) and behind a chain of two; macOS Screen
Sharing over VNC with no username; RDP recording (and none with
session-recording off or the host's switch off); file transfer through the
RDP drive, drag and drop included; clipboard both ways; sharing an RDP
session by link (read-only and read-write) and presenting it in a collab
room; opening Windows Remote Desktop from the desktop app on Windows;
changing the guacd URL in admin settings without a restart; turning Remote
Desktop off in admin (actions disappear, connects answer 403); a 2.8 host
that only had `connection_type = rdp` comes up as RDP with SSH off; RDP user
defaults apply to a saved host; Quick Connect over RDP and VNC; the desktop
app with a host on the remote server and one on This device.
@@ -510,6 +510,10 @@
"type": "string",
"minLength": 1,
"description": "Registered component id. Required when type is \"custom\"."
},
"hidden": {
"type": "boolean",
"description": "Stored and validated, but not drawn by the generic settings form."
}
}
}
@@ -602,6 +606,10 @@
"type": "string",
"minLength": 1,
"description": "Registered component id. Required when type is \"custom\"."
},
"hidden": {
"type": "boolean",
"description": "Stored and validated, but not drawn by the generic settings form."
}
}
}
+67 -3
View File
@@ -477,7 +477,6 @@ export interface PluginHostRecord {
folder: string | null;
jumpHosts?: unknown;
enableSsh?: boolean | null;
enableRdp?: boolean | null;
enableTerminal?: boolean | null;
enableFileManager?: boolean | null;
enableTunnel?: boolean | null;
@@ -487,7 +486,11 @@ export interface PluginHostRecord {
[key: string]: unknown;
}
/** Fields a plugin may set when creating a host it will own or manage. */
/**
* Fields a plugin may set when creating a host it will own or manage. Fields
* that belong to another plugin's host settings (enableRdp, rdpPort) are
* handed to that plugin's host import normalizer, as on a bulk import.
*/
export type PluginHostCreateInput = Partial<
Omit<PluginHostRecord, "id" | "userId">
> & {
@@ -1116,10 +1119,69 @@ export interface PluginOpenIsolatedWindowRequest {
* Electron only: rejects when the server is not running embedded in the
* desktop app. Needs desktop:window.
*/
export interface PluginNativeRdpRequest {
host: string;
port?: number;
username?: string;
domain?: string;
}
export interface PluginDesktop {
openIsolatedWindow: (
request: PluginOpenIsolatedWindowRequest,
) => Promise<{ success: true }>;
/**
* Opens the operating system's own RDP client (mstsc on Windows) for a
* host. The password is never passed; the client asks for it. Windows
* desktop app only.
*/
launchNativeRdp: (
request: PluginNativeRdpRequest,
) => 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 credentials core keeps next to the host. */
export type PluginHostProtocol = "rdp" | "vnc" | "telnet";
/**
* 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;
domain: 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 {
/** Null when the host does not exist or the acting user cannot connect to it. */
resolveHostProtocol: (
hostId: number,
protocol: PluginHostProtocol,
) => Promise<PluginProtocolTarget | null>;
}
/**
@@ -1145,7 +1207,7 @@ export interface PluginAudit {
* 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, matching credentials:read and process:spawn.
* rather than a technical gate, matching process:spawn.
*/
export interface PluginCapabilities {
/** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */
@@ -1181,6 +1243,8 @@ export interface PluginContext {
readonly auth: PluginAuth;
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
readonly desktop: PluginDesktop;
/** Plaintext host protocol credentials. Needs credentials:read. */
readonly credentials: PluginCredentials;
/** Audit lines under the plugin's own action names. */
readonly audit: PluginAudit;
+36
View File
@@ -317,6 +317,26 @@ export interface HostActionContribution {
}[];
}
/**
* A connection protocol next to SSH, shown as a switch in the host editor's
* General tab and in host filters and Quick Connect. Its switch and port are
* this plugin's own host settings (declare both in contributes.settings.host).
*/
export interface HostProtocolContribution {
id: string;
/** Boolean host setting that turns the protocol on. */
settingKey: string;
/** Number host setting holding the port. */
portKey?: string;
defaultPort: number;
titleKey: string;
descriptionKey?: string;
icon: IconComponent;
order?: number;
/** Offer it in Quick Connect, optionally with a domain field. */
quickConnect?: { showDomain?: boolean };
}
export interface HostBadgeContribution {
id: string;
when: (host: PluginHostRecord) => boolean;
@@ -524,6 +544,7 @@ export interface TermixApp extends TermixAppInfo {
section: HostEditorSectionContribution,
) => Disposer;
registerHostAction: (action: HostActionContribution) => Disposer;
registerHostProtocol: (protocol: HostProtocolContribution) => Disposer;
registerHostBadge: (badge: HostBadgeContribution) => Disposer;
registerHostContextMenuItem: (
item: HostContextMenuItemContribution,
@@ -574,6 +595,11 @@ export interface TermixApp extends TermixAppInfo {
/** HTTP client for this plugin's /plugin-api/<id>/ routes. */
api: PluginApiClient;
/**
* The same client for a resolved connection origin: "remote" reaches the
* desktop app's connected remote server, anything else is `api`.
*/
apiFor: (origin?: unknown) => PluginApiClient;
/** WebSocket URL and auth subprotocols for /plugin-ws/<id>/<path>. */
wsUrl: (
path: string,
@@ -668,6 +694,7 @@ export interface PluginHostBridge {
usePluginComponent: (
id: string,
) => ComponentType<Record<string, unknown>> | undefined;
hostProtocols: (host: PluginHostRecord) => string[];
}
let host: PluginHostBridge | null = null;
@@ -778,6 +805,15 @@ export function useSshAuthTypes(): {
* next to its own. Same permission and `when` filtering as ComponentSlot;
* `context` is what each contribution's `when` sees.
*/
/**
* The connection protocols a host has switched on: "ssh" plus every protocol
* a running plugin registered with registerHostProtocol (RDP, VNC). Lets a
* plugin offer a protocol without knowing which plugin owns its settings.
*/
export function hostProtocols(record: PluginHostRecord): string[] {
return requireHost().hostProtocols(record);
}
export function useSlotContributions(
slotId: string,
context?: Record<string, unknown>,
+10
View File
@@ -224,6 +224,12 @@ export interface PluginSettingsField {
group?: string;
/** Registered component id. Required when type is "custom". */
component?: string;
/**
* Stored and validated like any field, but not drawn by the generic
* settings form, because the plugin edits it in its own UI (a host editor
* section).
*/
hidden?: boolean;
}
export interface PluginHostSettingsContribution {
@@ -382,6 +388,7 @@ const ALLOWED_SETTINGS_FIELD = [
"permission",
"group",
"component",
"hidden",
];
/** Settings keys are stored as-is, so they stay short and index-safe. */
@@ -933,6 +940,9 @@ function validateSettingsFields(
if ("permission" in raw) {
requireString(raw.permission, `${at}.permission`, errors);
}
if ("hidden" in raw && typeof raw.hidden !== "boolean") {
errors.push(`${at}.hidden must be a boolean`);
}
if (raw.type === "select" || raw.type === "multiselect") {
validateSettingsOptions(raw.options, at, errors);
+50
View File
@@ -35,6 +35,10 @@ import type {
} from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js";
import type {
PluginNativeRdpRequest,
PluginProtocolTarget,
} from "./backend.js";
export interface FakeContextOptions {
pluginId?: string;
@@ -69,6 +73,13 @@ export interface FakeContextOptions {
shareableRoles?: PluginShareableRole[];
/** Hosts ctx.ssh.resolveHost answers with, secrets included. */
sshHosts?: PluginSshHost[];
/**
* What ctx.credentials.resolveHostProtocol answers, keyed
* "<hostId>:<protocol>". A missing key answers null.
*/
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.desktop.available() answers. Defaults to false. */
desktopAvailable?: boolean;
}
export interface FakeAuthRegistrations {
@@ -123,6 +134,10 @@ export interface FakePluginContext {
title?: string;
ignoreCert?: boolean;
}>;
/** Every ctx.desktop.launchNativeRdp call, in order. */
nativeRdpLaunches: PluginNativeRdpRequest[];
/** Every ctx.credentials.resolveHostProtocol call, in order. */
credentialReads: Array<{ hostId: number; protocol: string }>;
/** Every ctx.audit.record entry, in order. */
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
/** Every ctx.hosts.recordActivity call, in order. */
@@ -206,6 +221,8 @@ export function createFakeContext(
const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = [];
const desktopWindows: FakePluginContext["desktopWindows"] = [];
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
const credentialReads: FakePluginContext["credentialReads"] = [];
const audits: FakePluginContext["audits"] = [];
const activities: FakePluginContext["activities"] = [];
const trackedSessions: number[] = [];
@@ -635,6 +652,18 @@ export function createFakeContext(
desktopWindows.push(request);
return { success: true };
},
launchNativeRdp: async (request) => {
nativeRdpLaunches.push(request);
return { success: true };
},
available: () => options.desktopAvailable ?? false,
},
credentials: {
resolveHostProtocol: async (hostId, protocol) => {
credentialReads.push({ hostId, protocol });
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
},
},
asUser: async (userId, fn) => {
@@ -666,6 +695,8 @@ export function createFakeContext(
hostShares,
auth,
desktopWindows,
nativeRdpLaunches,
credentialReads,
audits,
activities,
trackedSessions,
@@ -704,6 +735,10 @@ export interface MockContextOptions {
services?: Record<string, object>;
/** Hosts ctx.ssh.resolveHost serves. See FakeContextOptions. */
sshHosts?: PluginSshHost[];
/** What ctx.credentials serves. See FakeContextOptions. */
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.desktop.available() answers. */
desktopAvailable?: boolean;
}
export interface MockPluginContext extends FakePluginContext {
@@ -740,6 +775,8 @@ export function createMockCtx(
hosts: options.hosts,
sshHosts: options.sshHosts,
services: options.services,
protocolTargets: options.protocolTargets,
desktopAvailable: options.desktopAvailable,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -972,6 +1009,18 @@ export function createMockCtx(
require("desktop:window");
return ctx.desktop.openIsolatedWindow(request);
},
launchNativeRdp: async (request) => {
require("desktop:window");
return ctx.desktop.launchNativeRdp(request);
},
available: () => ctx.desktop.available(),
},
credentials: {
resolveHostProtocol: async (hostId, protocol) => {
require("credentials:read");
return ctx.credentials.resolveHostProtocol(hostId, protocol);
},
},
capabilities: {
@@ -1180,6 +1229,7 @@ export interface RenderedPluginApp {
tabs: () => string[];
panels: () => string[];
hostActions: () => Array<{ id: string; tabType?: string }>;
hostProtocols: () => string[];
hostEditorSections: () => string[];
dashboardCards: () => string[];
settingsComponents: () => string[];