feat: host lock api so a plugin can load hosts from a config file (#1581)

This commit is contained in:
LukeGus committed 2026-10-19 12:02:05 -05:00
1 parent 1cdbfc674c
commit 2c785fe35b
34 files changed
+1388 -52

No files matched your search

+11
View File
@@ -1,5 +1,16 @@
# Changelog
## 1.0.14
Plugin API 1.6.0.
### Added
- `ctx.hosts.locks.provide` and the `hosts:lock` capability, so a plugin can manage hosts that stay read only for everyone else and block adding hosts by hand
- `ctx.hosts.setShares` to replace every grant on a host in one call
- `ctx.credentials.list` returns the acting user's credentials by name, without secrets
- `ctx.hosts.create` and `update` take `password`, `key`, `keyPassword`, `keyType`, `sudoPassword`, `protocolAuth` and `pluginEnable`
## 1.0.13
Plugin API 1.5.0.
+7 -1
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.13 | Plugin API 1.5.0
SDK 1.0.14 | Plugin API 1.6.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -300,6 +300,11 @@ router.post("/notes", async (req, res) => {
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
`hosts.edit` checks a core permission.
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
the hosts you manage read only for the UI, the API and other plugins (pins and order still work), and
`allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go
through. `ctx.hosts.setShares(hostId, grants)` replaces a host's whole grant list, and
`ctx.credentials.list()` finds the acting user's credentials by name.
### Errors
@@ -898,6 +903,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `hosts:write` | medium | Add and edit hosts |
| `hosts:status-provide` | medium | Be the source of host status dots |
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
| `network:probe` | medium | Check that a host answers on a TCP port |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.13",
"version": "1.0.14",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -167,6 +167,7 @@
"network:probe",
"hosts:status-provide",
"hosts:activity-provide",
"hosts:lock",
"desktop:window",
"desktop:local",
"users:impersonate",
+91
View File
@@ -698,6 +698,36 @@ export interface PluginHostCreateInput {
sshOptions?: HostSshOptions | null;
/** Host settings by plugin id, the shape a Termix export carries. */
pluginSettings?: Record<string, Record<string, unknown>>;
/**
* Each plugin's own host switch (its manifest enableKey), by plugin id.
* A plugin that is not running is skipped and logged; one with no switch
* is refused.
*/
pluginEnable?: Record<string, boolean>;
/**
* Secrets, stored encrypted with the owner's data key and never returned
* in a PluginHostRecord. An empty string or null clears the stored value.
*/
password?: string | null;
key?: string | null;
keyPassword?: string | null;
keyType?: string | null;
sudoPassword?: string | null;
/**
* Logins for plugin protocols (contributes.protocols), keyed by protocol
* id. null removes that protocol's login. Unknown protocol ids are skipped.
*/
protocolAuth?: Record<string, PluginHostProtocolAuthInput | null>;
}
/** One protocol login on a host write. Absent keys keep what is stored. */
export interface PluginHostProtocolAuthInput {
authType?: "direct" | "credential" | "none";
credentialId?: number | null;
username?: string | null;
password?: string | null;
/** The protocol's declared credentialFields, secret ones included. */
fields?: Record<string, string>;
}
/** Fields a plugin may change on a host the acting user owns. */
@@ -725,6 +755,13 @@ export const PLUGIN_HOST_INPUT_KEYS = [
"forceKeyboardInteractive",
"sshOptions",
"pluginSettings",
"pluginEnable",
"password",
"key",
"keyPassword",
"keyType",
"sudoPassword",
"protocolAuth",
] as const satisfies readonly (keyof PluginHostCreateInput)[];
/** The same shape canAccessHost returns internally, without secrets. */
@@ -761,6 +798,34 @@ export interface PluginShareableRole {
displayName: string | null;
}
/** One grant in ctx.hosts.setShares. */
export interface PluginHostShareGrant {
target: PluginShareTarget;
permissionLevel: PluginHostShareLevel;
/** Omit for a grant that never expires. */
durationHours?: number;
}
/**
* Hosts a plugin manages itself, for example from a config file. Core refuses
* edits, deletes and share changes on a locked host from the UI, the API and
* other plugins, and marks it in host reads so a layout can show it read only.
* Pins and order stay with the user. Only the plugin that locks a host may
* still write it.
*/
export interface PluginHostLockProvider {
isLocked: (hostId: number) => boolean;
/** A short line shown under the managed note, or null. */
note: (hostId: number) => string | null;
/** False blocks adding hosts by hand (editor, import, other plugins). */
allowManualCreate: () => boolean;
}
export interface PluginHostLocksApi {
/** Registers this plugin's locks. Needs hosts:lock. Removed on deactivate. */
provide: (provider: PluginHostLockProvider) => () => void;
}
/**
* 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.
@@ -823,6 +888,15 @@ export interface PluginHosts {
permissionLevel: PluginHostShareLevel,
durationHours?: number,
) => Promise<PluginHostShareResult>;
/**
* Replaces every grant on a host the acting user owns with this list.
* Grants that stay keep their row and counters, missing ones are revoked.
* Needs hosts:write.
*/
setShares: (
hostId: number,
grants: PluginHostShareGrant[],
) => 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. */
@@ -896,6 +970,8 @@ export interface PluginHosts {
) => () => void;
/** The recent activity list on the dashboard. */
activity: PluginHostActivityApi;
/** Read only hosts this plugin manages. */
locks: PluginHostLocksApi;
}
export interface PluginHostImportOptions {
@@ -2011,6 +2087,15 @@ export interface PluginSshKeyCredential {
publicKey: string | null;
}
/** A saved credential without its secrets. */
export interface PluginCredentialSummary {
id: number;
name: string;
username: string | null;
authType: string;
folder: string | null;
}
/** A key pair to save as a new credential for the acting user. */
export interface PluginSshKeyCredentialInput {
name: string;
@@ -2032,6 +2117,12 @@ export interface PluginCredentials {
* Needs credentials:use and the core credentials.view permission. Audited.
*/
listSshKeys: () => Promise<PluginSshKeyCredential[]>;
/**
* The acting user's own credentials by name, with no secret material, so a
* plugin can point a host at one. Needs credentials:use and the core
* credentials.view permission. Audited.
*/
list: () => Promise<PluginCredentialSummary[]>;
/**
* 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
+1
View File
@@ -61,6 +61,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("network:probe", "medium"),
entry("hosts:status-provide", "medium"),
entry("hosts:activity-provide", "medium"),
entry("hosts:lock", "medium"),
entry("users:read", "medium"),
entry("events:core", "medium"),
entry("notify:send", "medium"),
+1 -1
View File
@@ -37,7 +37,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.5.0";
export const PLUGIN_API_VERSION = "1.6.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
+51
View File
@@ -76,6 +76,9 @@ import type {
PluginHostProbeTarget,
PluginHostStatusEntry,
PluginActivityProvider,
PluginHostLockProvider,
PluginCredentialSummary,
PluginHostShareGrant,
PluginHostStatusProvider,
PluginNotification,
PluginNotificationChannel,
@@ -140,6 +143,8 @@ export interface FakeContextOptions {
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.credentials.listSshKeys answers. */
sshKeyCredentials?: PluginSshKeyCredential[];
/** What ctx.credentials.list answers. */
credentialSummaries?: PluginCredentialSummary[];
/** What ctx.desktop.available() answers. Defaults to false. */
desktopAvailable?: boolean;
/** What ctx.desktop.local is. Calls throw when this is not given. */
@@ -285,6 +290,8 @@ export interface FakePluginContext {
hostStatusProviders: PluginHostStatusProvider[];
/** Every provider passed to ctx.hosts.activity.provide that is still registered. */
activityProviders: PluginActivityProvider[];
/** Every provider passed to ctx.hosts.locks.provide that is still registered. */
hostLockProviders: PluginHostLockProvider[];
/** Topics emitted through ctx.events.emit, in order. */
emitted: Array<{ topic: string; payload: unknown }>;
/** Backing store behind ctx.kv. */
@@ -317,6 +324,8 @@ export interface FakePluginContext {
permissionLevel: string;
durationHours?: number;
}>;
/** Every ctx.hosts.setShares call, in order. */
hostShareSets: Array<{ hostId: number; grants: PluginHostShareGrant[] }>;
/** Everything registered through ctx.auth. */
auth: FakeAuthRegistrations;
/** Every ctx.desktop.openIsolatedWindow call, in order. */
@@ -488,7 +497,14 @@ function applyHostInput(
const {
tags,
pluginSettings: _settings,
pluginEnable: _pluginEnable,
forceKeyboardInteractive: _fki,
password: _password,
key: _key,
keyPassword: _keyPassword,
keyType: _keyType,
sudoPassword: _sudoPassword,
protocolAuth: _protocolAuth,
...rest
} = input;
const next: PluginHostRecord = { ...record };
@@ -517,6 +533,7 @@ export function createFakeContext(
const emitted: Array<{ topic: string; payload: unknown }> = [];
const hostStatusProviders: PluginHostStatusProvider[] = [];
const activityProviders: PluginActivityProvider[] = [];
const hostLockProviders: PluginHostLockProvider[] = [];
const kv = new Map<string, unknown>();
const tables: PluginTableDefinition[] = [];
const syncEntities: SyncEntityRegistration[] = [];
@@ -534,6 +551,7 @@ export function createFakeContext(
}>();
const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = [];
const hostShareSets: FakePluginContext["hostShareSets"] = [];
const desktopWindows: FakePluginContext["desktopWindows"] = [];
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
[];
@@ -1004,6 +1022,10 @@ export function createFakeContext(
hostShares.push({ hostId, targets, permissionLevel, durationHours });
return { hostId, shared: true };
},
setShares: async (hostId, grants) => {
hostShareSets.push({ hostId, grants: [...grants] });
return { hostId, shared: true };
},
listUsers: async () => options.shareableUsers ?? [],
listRoles: async () => options.shareableRoles ?? [],
trackSession: (hostId) => {
@@ -1034,6 +1056,15 @@ export function createFakeContext(
};
},
},
locks: {
provide: (provider) => {
hostLockProviders.push(provider);
return () => {
const at = hostLockProviders.indexOf(provider);
if (at >= 0) hostLockProviders.splice(at, 1);
};
},
},
status: {
provide: (provider) => {
hostStatusProviders.push(provider);
@@ -1286,6 +1317,7 @@ export function createFakeContext(
},
credentials: {
list: async () => [...(options.credentialSummaries ?? [])],
listSshKeys: async () => [
...(options.sshKeyCredentials ?? []),
...createdSshKeys.map((key) => ({
@@ -1468,6 +1500,7 @@ export function createFakeContext(
disposals,
hostStatusProviders,
activityProviders,
hostLockProviders,
emitted,
kv,
tables,
@@ -1478,6 +1511,7 @@ export function createFakeContext(
coreSettings,
sshConnections,
hostShares,
hostShareSets,
auth,
desktopWindows,
externalClientLaunches,
@@ -1560,6 +1594,8 @@ export interface MockContextOptions {
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.credentials.listSshKeys serves. */
sshKeyCredentials?: PluginSshKeyCredential[];
/** What ctx.credentials.list serves. */
credentialSummaries?: PluginCredentialSummary[];
/** What ctx.desktop.available() answers. */
desktopAvailable?: boolean;
/** What ctx.desktop.local is. See FakeContextOptions. */
@@ -1699,6 +1735,7 @@ export function createMockCtx(
services: options.services,
protocolTargets: options.protocolTargets,
sshKeyCredentials: options.sshKeyCredentials,
credentialSummaries: options.credentialSummaries,
desktopAvailable: options.desktopAvailable,
desktopLocal: options.desktopLocal,
auditEntries: options.auditEntries,
@@ -1900,6 +1937,10 @@ export function createMockCtx(
require("hosts:write");
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
},
setShares: async (hostId, grants) => {
require("hosts:write");
return ctx.hosts.setShares(hostId, grants);
},
listUsers: async () => {
require("hosts:write");
return ctx.hosts.listUsers();
@@ -1930,6 +1971,12 @@ export function createMockCtx(
return ctx.hosts.activity.provide(provider);
},
},
locks: {
provide: (provider) => {
require("hosts:lock");
return ctx.hosts.locks.provide(provider);
},
},
status: {
provide: (provider) => {
require("hosts:read");
@@ -2098,6 +2145,10 @@ export function createMockCtx(
},
credentials: {
list: async () => {
require("credentials:use");
return ctx.credentials.list();
},
listSshKeys: async () => {
require("credentials:use");
return ctx.credentials.listSshKeys();
+3
View File
@@ -151,7 +151,9 @@ HostFeatureFieldsProps (type): HostFeatureFieldsProps
HostFolder (type): HostFolder
HostGroupKey (type): HostGroupKey
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
HostLock (type): HostLock
HostOnly (value): ({ children }: { children: ReactNode; }) => Element
HostPolicy (type): HostPolicy
HostProtocolAuthInput (type): HostProtocolAuthInput
HostProtocolDef (type): HostProtocolDef
HostProtocols (type): HostProtocols
@@ -411,6 +413,7 @@ getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId
getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise<HostDefaultsLevelData>
getHostEditorSection (value): (id: string) => HostEditorSectionDef
getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
getHostPolicy (value): () => Promise<HostPolicy>
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
getHostTags (value): () => Promise<string[]>
getLiveHostStatus (value): (hostId: number) => StatusValue