feat: harden plugin api for separate repos, signed updates and generic external login

This commit is contained in:
LukeGus committed 2026-09-29 18:11:59 -05:00
1 parent fb9c168b6b
commit 80944301dd
310 files changed
+6577 -4927

No files matched your search

+35 -12
View File
@@ -96,8 +96,8 @@ export interface PluginFiles {
* 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 read-only so a plugin can join against
* them without being able to write them. In-process code could reach around
* 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.
*/
@@ -106,7 +106,10 @@ export interface PluginDatabase {
define: <T = unknown>(definition: PluginTableDefinition) => Promise<T>;
/** Drizzle handle, scoped to this plugin's tables. */
client: <T = unknown>() => Promise<T>;
/** Read-only references to the core tables a plugin may point at. */
/**
* 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
@@ -235,6 +238,11 @@ 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;
@@ -252,7 +260,11 @@ export interface PluginServices {
implementation: T,
options?: { name?: string },
) => void;
/** `provider` picks a named provider; omitted, the unnamed one. */
/**
* 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 },
@@ -1340,6 +1352,12 @@ export interface PluginLoginInstance {
* 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 {
@@ -1468,7 +1486,13 @@ export interface PluginOpenIsolatedWindowRequest {
* Electron only: rejects when the server is not running embedded in the
* desktop app. Needs desktop:window.
*/
export interface PluginNativeRdpRequest {
/**
* 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;
@@ -1480,12 +1504,11 @@ export interface PluginDesktop {
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.
* Opens the operating system's own client for a protocol. The password is
* never passed; the client asks for it. Desktop app only.
*/
launchNativeRdp: (
request: PluginNativeRdpRequest,
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;
@@ -1942,8 +1965,8 @@ export interface PluginContext {
* no request behind it. Always audited, and every core API inside still
* applies that user's RBAC.
*
* This is the only way a plugin can name a user. Nothing else trusts a user
* id that came from plugin code.
* 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>;
+2
View File
@@ -44,6 +44,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("ssh:connect", "high"),
entry("process:spawn", "high"),
entry("users:write", "high"),
entry("users:impersonate", "high"),
entry("db:core-refs", "high"),
entry("auth:provide", "high"),
entry("device:serial", "high"),
entry("notify:hub", "high"),
+171 -6
View File
@@ -429,7 +429,35 @@ const REFUSED_STATEMENTS = new Set([
/** What a migration may CREATE, ALTER or DROP. Anything else is refused. */
const TABLE_OBJECTS = new Set(["TABLE", "VIEW"]);
const HARMLESS_OBJECTS = new Set(["SEQUENCE", "TYPE"]);
/** Allowed, but only under the plugin's own prefix. */
const PREFIXED_OBJECTS = new Set(["SEQUENCE", "TYPE"]);
/**
* Functions that read files, change server settings or run a string as SQL.
* A SELECT calling one of them could do anything, so it is refused.
*/
const REFUSED_FUNCTIONS = new Set([
"SET_CONFIG",
"PG_READ_FILE",
"PG_READ_BINARY_FILE",
"PG_LS_DIR",
"PG_STAT_FILE",
"PG_FILE_WRITE",
"LO_IMPORT",
"LO_EXPORT",
"LO_FROM_BYTEA",
"DBLINK",
"DBLINK_EXEC",
"QUERY_TO_XML",
"QUERY_TO_XML_AND_XMLSCHEMA",
"PG_TERMINATE_BACKEND",
"PG_CANCEL_BACKEND",
"PG_RELOAD_CONF",
"LOAD_FILE",
"LOAD_EXTENSION",
"SYS_EXEC",
"SYS_EVAL",
]);
const CREATE_MODIFIERS = [
"OR",
@@ -460,9 +488,27 @@ export function findUnownedTableWrites(
pluginId: string,
sql: string,
ownedLegacy: ReadonlySet<string> = new Set(),
ownedIndexes: ReadonlySet<string> = new Set(),
): string[] {
const prefix = tablePrefix(pluginId);
const problems: string[] = [];
const indexes = new Set([
...ownedIndexes,
...collectOwnedIndexes(pluginId, [sql], ownedLegacy),
]);
// The splitter and tokenizer follow standard quoting only. MySQL backslash
// escapes, E'' strings and Postgres dollar quoting would let a string end
// somewhere else for the engine than for this check, so none are allowed.
if (sql.includes("\\")) {
problems.push("backslashes are not allowed in a plugin migration");
}
if (/\$[A-Za-z_0-9]*\$/.test(sql)) {
problems.push(
"dollar-quoted strings are not allowed in a plugin migration",
);
}
if (problems.length > 0) return problems;
for (const statement of splitStatements(sql)) {
const tokens = tokenize(statement);
@@ -507,16 +553,47 @@ export function findUnownedTableWrites(
continue;
}
if (first !== "CREATE" && find("CASCADE") > 0) {
problems.push("CASCADE is not allowed in a plugin migration");
continue;
}
if (first === "CREATE" || first === "ALTER" || first === "DROP") {
let at = skip(1, CREATE_MODIFIERS);
const object = upper(at);
if (object === "INDEX") {
// The index name is free; the table after ON is what it writes to.
const on = find("ON", at);
if (on > 0) table(skip(on + 1, ["ONLY"]));
if (on > 0) {
table(skip(on + 1, ["ONLY"]));
} else if (first === "CREATE") {
problems.push("CREATE INDEX without ON is not allowed");
} else {
// DROP INDEX name: only an index this plugin created.
const nameAt = skip(at + 1, ["IF", "EXISTS", "CONCURRENTLY"]);
const name = tokens[nameAt];
if (
!name ||
name.kind === "punct" ||
tokens[nameAt + 1]?.value === "." ||
(!name.value.startsWith(prefix) && !indexes.has(name.value))
) {
problems.push(
`${first} INDEX "${name?.value ?? ""}" is not an index this plugin created`,
);
}
}
continue;
}
if (PREFIXED_OBJECTS.has(object)) {
const name = tokens[skip(at + 1, ["IF", "NOT", "EXISTS"])];
if (!name || !name.value.startsWith(prefix)) {
problems.push(
`${first} ${object} "${name?.value ?? ""}" is not prefixed "${prefix}"`,
);
}
continue;
}
if (HARMLESS_OBJECTS.has(object)) continue;
if (!TABLE_OBJECTS.has(object)) {
problems.push(
`${first} ${object || tokens[at]?.value || ""} is not allowed in a plugin migration`.trim(),
@@ -565,6 +642,14 @@ export function findUnownedTableWrites(
}
if (first === "UPDATE") {
// MySQL writes every table before SET: UPDATE a JOIN b ... SET b.x = 1.
const set = find("SET");
const end = set > 0 ? set : tokens.length;
for (let at = 1; at < end; at++) {
if (upper(at) === "JOIN" || tokens[at].value === ",") {
table(at + 1);
}
}
table(
skip(1, [
"OR",
@@ -582,12 +667,51 @@ export function findUnownedTableWrites(
if (first === "DELETE") {
const from = find("FROM");
if (from > 0) table(skip(from + 1, ["ONLY"]));
else problems.push("DELETE without FROM is not allowed");
if (from < 0) {
problems.push("DELETE without FROM is not allowed");
continue;
}
// MySQL: DELETE a, b FROM ... deletes from a and b.
let at = skip(1, ["LOW_PRIORITY", "QUICK", "IGNORE"]);
if (at < from) {
while (at < from) {
at = table(at);
if (tokens[at]?.value !== ",") break;
at++;
}
continue;
}
at = table(skip(from + 1, ["ONLY"]));
// DELETE FROM a, b USING ... deletes from both.
while (tokens[at]?.value === ",") at = table(at + 1);
continue;
}
if (first !== "SELECT" && first !== "COMMENT") {
if (first === "SELECT") {
// SELECT ... INTO creates a table on Postgres and writes a file on
// MySQL.
if (find("INTO") > 0) {
problems.push("SELECT INTO is not allowed in a plugin migration");
continue;
}
for (let at = 0; at < tokens.length; at++) {
if (REFUSED_FUNCTIONS.has(upper(at)) && tokens[at + 1]?.value === "(") {
problems.push(
`${tokens[at].value}() is not allowed in a plugin migration`,
);
}
}
// setval can break a core table's ids, so only the plugin's own.
for (const match of statement.matchAll(/setval\s*\(\s*([^,]*)/gi)) {
const target = /'([^']*)'/.exec(match[1])?.[1];
if (!target || !target.startsWith(prefix)) {
problems.push("setval() may only target this plugin's own sequences");
}
}
continue;
}
if (first !== "COMMENT") {
problems.push(
`"${tokens[0].value}" statements are not allowed in a plugin migration`,
);
@@ -596,3 +720,44 @@ export function findUnownedTableWrites(
return problems;
}
/**
* Names of the indexes a plugin's migrations create on its own tables, so a
* later DROP INDEX with no ON clause can be checked against them.
*/
export function collectOwnedIndexes(
pluginId: string,
sqls: readonly string[],
ownedLegacy: ReadonlySet<string> = new Set(),
): Set<string> {
const prefix = tablePrefix(pluginId);
const owned = new Set<string>();
for (const sql of sqls) {
for (const statement of splitStatements(sql)) {
const tokens = tokenize(statement);
const upper = (at: number) =>
tokens[at]?.kind === "word" ? tokens[at].value.toUpperCase() : "";
if (upper(0) !== "CREATE") continue;
let at = 1;
while (CREATE_MODIFIERS.includes(upper(at))) at++;
if (upper(at) !== "INDEX") continue;
at++;
while (["IF", "NOT", "EXISTS"].includes(upper(at))) at++;
const name = tokens[at];
let on = at;
while (on < tokens.length && upper(on) !== "ON") on++;
if (upper(on + 1) === "ONLY") on++;
const target = tokens[on + 1];
if (
name &&
name.kind !== "punct" &&
target &&
tokens[on + 2]?.value !== "." &&
(target.value.startsWith(prefix) || ownedLegacy.has(target.value))
) {
owned.add(name.value);
}
}
}
return owned;
}
+394
View File
@@ -0,0 +1,394 @@
/**
* The desktop app's bridge (window.electronAPI), typed for plugins. It exists
* only inside the Termix desktop app; check isElectron before calling it.
*/
export interface ServerConfig {
serverUrl?: string;
allowInvalidCertificate?: boolean;
[key: string]: unknown;
}
export interface ConnectionTestResult {
success: boolean;
error?: string;
[key: string]: unknown;
}
export interface DialogOptions {
title?: string;
defaultPath?: string;
buttonLabel?: string;
filters?: Array<{ name: string; extensions: string[] }>;
properties?: string[];
[key: string]: unknown;
}
export interface DialogResult {
canceled: boolean;
filePath?: string;
filePaths?: string[];
[key: string]: unknown;
}
export interface LocalDirectoryResult {
success: boolean;
path: string;
parent?: string;
entries: LocalFileEntry[];
error?: string;
}
export interface LocalCollectedFile {
path: string;
name: string;
relativePath: string;
size: number;
created?: string;
modified: string;
}
export type LocalPathMutationResult =
| ({ success: true } & Partial<LocalFileEntry>)
| { success: false; error?: string };
export interface ElectronAPI {
getAppVersion: () => Promise<string>;
setZoomFactor?: (factor: number) => void;
getPlatform: () => Promise<string>;
getSetting?: (key: string) => Promise<string | null | undefined>;
setSetting?: (key: string, value: string) => Promise<void>;
getServerConfig: () => Promise<ServerConfig>;
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
testServerConnection: (
serverUrl: string,
allowInvalidCertificate?: boolean,
) => Promise<ConnectionTestResult>;
getC2STunnelConfig: () => Promise<unknown[]>;
saveC2STunnelConfig: (
config: unknown[],
) => Promise<{ success: boolean; error?: string }>;
checkLocalPortAvailable: (
host: string,
port: number,
) => Promise<{ available: boolean; error?: string }>;
getC2STunnelPresetDefaultName: () => Promise<string>;
startC2STunnel: (
tunnel: unknown,
index: number,
authToken?: string,
) => Promise<{
success: boolean;
tunnelName?: string;
error?: string;
code?: string;
}>;
testC2STunnel: (
tunnel: unknown,
index: number,
authToken?: string,
) => Promise<{
success: boolean;
message?: string;
error?: string;
code?: string;
}>;
stopC2STunnel: (
tunnelName: string,
) => Promise<{ success: boolean; error?: string }>;
getC2STunnelStatuses: () => Promise<Record<string, unknown>>;
onC2STunnelStatuses?: (
callback: (statuses: Record<string, unknown>) => void,
) => () => void;
startC2SAutoStartTunnels: () => Promise<{
success: boolean;
started: number;
errors: string[];
}>;
/** Where the linked server relays client tunnel streams, a /plugin-ws/ path. */
setC2SRelayPath?: (
relayPath: string,
) => Promise<{ success: boolean; error?: string }>;
onCloseActiveTab?: (callback: () => void) => () => void;
clearSessionCookies: () => Promise<void>;
getSessionCookie: (
name: string,
targetUrl?: string,
) => Promise<string | null>;
waitForSessionCookie: (
name: string,
targetUrl?: string,
previousValue?: string | null,
timeoutMs?: number,
) => Promise<{ success: boolean; value?: string; error?: string }>;
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
getLocalHomeDirectory?: () => Promise<string>;
listLocalDirectory?: (path?: string) => Promise<LocalDirectoryResult>;
statLocalPaths?: (
paths: string[],
) => Promise<
Array<
| (LocalFileEntry & { success: true })
| { success: false; path: string; error?: string }
>
>;
collectLocalFiles?: (paths: string[]) => Promise<{
success: boolean;
files: LocalCollectedFile[];
truncated?: boolean;
error?: string;
}>;
createLocalFolder?: (
parentPath: string,
folderName: string,
) => Promise<LocalPathMutationResult>;
renameLocalPath?: (
path: string,
newName: string,
) => Promise<LocalPathMutationResult>;
trashLocalPath?: (path: string) => Promise<LocalPathMutationResult>;
chmodLocalPath?: (
path: string,
permissions: string,
) => Promise<LocalPathMutationResult>;
openExternalEditor: (fileData: {
fileName: string;
content: string;
encoding?: "utf8" | "base64";
editorPath?: string | null;
}) => Promise<{
success: boolean;
editId?: string;
path?: string;
error?: string;
}>;
closeExternalEditor: (editId: string) => Promise<{
success: boolean;
error?: string;
}>;
onExternalEditorSaved?: (
callback: (payload: {
editId: string;
content: string;
encoding: "utf8";
path: string;
}) => void,
) => () => void;
onUpdateAvailable: (callback: () => void) => void;
onUpdateDownloaded: (callback: () => void) => void;
removeAllListeners: (channel: string) => void;
isElectron: boolean;
isDev: boolean;
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
createTempFile: (fileData: {
fileName: string;
content: string;
encoding?: "base64" | "utf8";
}) => Promise<{
success: boolean;
tempId?: string;
path?: string;
error?: string;
}>;
createTempFolder: (folderData: {
folderName: string;
files: Array<{
relativePath: string;
content: string;
encoding?: "base64" | "utf8";
}>;
}) => Promise<{
success: boolean;
tempId?: string;
path?: string;
error?: string;
}>;
startDragToDesktop: (dragData: {
tempId: string;
fileName: string;
}) => Promise<{
success: boolean;
error?: string;
}>;
cleanupTempFile: (tempId: string) => Promise<{
success: boolean;
error?: string;
}>;
startLocalTerminal(dimensions: {
cols: number;
rows: number;
shell?: "default" | "wsl";
}): Promise<{ sessionId: string; shell: string }>;
readyLocalTerminal(sessionId: string): Promise<boolean>;
writeLocalTerminal(sessionId: string, data: string): Promise<boolean>;
resizeLocalTerminal(
sessionId: string,
cols: number,
rows: number,
): Promise<boolean>;
closeLocalTerminal(sessionId: string): Promise<boolean>;
onLocalTerminalData(
sessionId: string,
callback: (data: string) => void,
): () => void;
onLocalTerminalExit(
sessionId: string,
callback: (exitCode: number) => void,
): () => void;
/** Local disk browsing for the dual-pane file manager (desktop only). */
localFs?: {
home: () => Promise<LocalFsResult<LocalFsHomeInfo>>;
list: (dirPath: string) => Promise<LocalFsResult<LocalDirectoryListing>>;
mkdir: (
parentPath: string,
name: string,
) => Promise<LocalFsResult<{ path: string }>>;
createFile: (
parentPath: string,
name: string,
) => Promise<LocalFsResult<{ path: string }>>;
rename: (
oldPath: string,
newName: string,
) => Promise<LocalFsResult<{ path: string }>>;
trash: (paths: string[]) => Promise<LocalFsResult<LocalTrashResult>>;
ensureDir: (
dirPath: string,
rootPath?: string,
) => Promise<LocalFsResult<{ path: string }>>;
exists: (paths: string[]) => Promise<LocalFsResult<{ existing: string[] }>>;
walk: (paths: string[]) => Promise<LocalFsResult<LocalWalkResult>>;
reveal: (targetPath: string) => Promise<LocalFsResult<unknown>>;
open: (targetPath: string) => Promise<LocalFsResult<unknown>>;
};
/** Streamed local<->remote transfers driven by the main process. */
localTransfer?: {
/** The /plugin-api/<id> path that serves the streaming routes. */
setApiPath?: (apiPath: string) => Promise<LocalFsResult<object>>;
upload: (
options: LocalUploadRequest,
) => Promise<LocalFsResult<{ bytes: number }>>;
download: (
options: LocalDownloadRequest,
) => Promise<LocalFsResult<{ path: string }>>;
cancel: (
transferId: string,
) => Promise<LocalFsResult<{ cancelled: boolean }>>;
onProgress: (
callback: (payload: LocalTransferProgress) => void,
) => () => void;
};
}
export type LocalFsResult<T> =
({ success: true } & T) | { success: false; error: string; code?: string };
export interface LocalFsHomeInfo {
home: string;
separator: string;
platform: string;
}
export interface LocalFileEntry {
name: string;
path: string;
type: "file" | "directory" | "link" | "other";
created?: string;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
size: number;
modifiedTimestamp?: number;
linkTarget?: string;
hidden: boolean;
}
export interface LocalDirectoryListing {
path: string;
parent: string | null;
entries: LocalFileEntry[];
}
export interface LocalTrashResult {
trashed: number;
failed: Array<{ path: string; error: string }>;
}
export interface LocalWalkFile {
localPath: string;
/** Path relative to the drop root; "/"-separated and includes the root's own name. */
relativePath: string;
size: number;
}
export interface LocalWalkResult {
files: LocalWalkFile[];
emptyDirs: string[];
totalBytes: number;
}
/**
* Which Termix backend a transfer talks to. The main process resolves the
* actual URL and credentials for the origin; the renderer never supplies them.
*/
export type LocalTransferOrigin = "local" | "remote";
export interface LocalUploadRequest {
transferId: string;
origin: LocalTransferOrigin;
fields: Record<string, string>;
localPath: string;
fileName: string;
deviceId?: string;
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
authToken?: string;
}
export interface LocalDownloadRequest {
transferId: string;
origin: LocalTransferOrigin;
body: Record<string, unknown>;
destPath: string;
/** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */
rootPath: string;
expectedSize?: number;
/** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */
overwrite?: boolean;
deviceId?: string;
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
authToken?: string;
}
export interface LocalTransferProgress {
transferId: string;
transferred: number;
total?: number;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
IS_ELECTRON: boolean;
electronClipboard?: {
writeText(text: string): Promise<boolean>;
readText(): Promise<string>;
};
}
}
+43 -5
View File
@@ -155,9 +155,6 @@ export interface PluginTabRecord {
instanceId?: string;
/** A backend session a session tab should reattach to. */
restoredSessionId?: string | null;
/** A live shared session this tab joins instead of connecting its own. */
joinSharedSessionId?: string | null;
joinShareId?: string | null;
[key: string]: unknown;
}
@@ -192,6 +189,11 @@ export interface ShellApi {
options?: OpenTabOptions,
) => void;
openSingletonTab: (type: string, options?: OpenTabOptions) => void;
/**
* Connects to a host the way clicking it in the host list does: its
* default connection, or `type` when given.
*/
connectHost: (host: PluginHostRecord, type?: string) => void;
closeTab: (tabId: string) => void;
renameTab: (tabId: string, label: string) => void;
/** Saves a quick-connect tab's host as a real host. Absent in some shells. */
@@ -212,7 +214,7 @@ export interface ShellApi {
export interface TabsApi extends Pick<
ShellApi,
"openTab" | "openSingletonTab" | "closeTab" | "openRailView"
"openTab" | "openSingletonTab" | "connectHost" | "closeTab" | "openRailView"
> {
/** The current tabs and split layout, or null before the shell mounts. */
getLayout: () => ShellLayout | null;
@@ -1085,6 +1087,15 @@ export interface PluginCoreApi {
listHosts: (pluginId: string) => Promise<PluginHostRecord[]>;
/** The user's stored credentials, without their secrets. */
listCredentials: () => Promise<PluginCredentialSummary[]>;
/** Tells the shell hosts were added or changed, so lists reload. */
notifyHostsChanged: () => void;
/** How the user wants host status shown: the brand accent, or green/red. */
getHostStatusColorScheme: () => "accent" | "status";
/**
* The token the desktop app's embedded backend accepts, for work the
* desktop main process does on the renderer's behalf. Null in a browser.
*/
getLocalAuthToken: () => string | null;
}
/** A stored credential as a picker needs it. Secrets never leave core. */
@@ -1114,8 +1125,17 @@ export interface HostStatusInfo {
let host: PluginHostBridge | null = null;
/** Called once by core. Not part of the plugin API. */
/**
* Called once by core, before any plugin loads. Not part of the plugin API:
* once set it cannot be swapped, so a plugin cannot replace the bridge every
* other plugin talks through.
*/
export function __setPluginHost(bridge: PluginHostBridge | null): void {
if (host && bridge !== host) {
throw new Error(
"@termix/plugin-sdk/frontend: the plugin host is already set",
);
}
host = bridge;
}
@@ -1211,6 +1231,21 @@ export function logActivity(
return requireHost().core.logActivity(type, hostId, hostName);
}
/** Tells the shell hosts were added or changed, so its lists reload. */
export function notifyHostsChanged(): void {
requireHost().core.notifyHostsChanged();
}
/** How the user wants host status shown: the brand accent, or green/red. */
export function getHostStatusColorScheme(): "accent" | "status" {
return requireHost().core.getHostStatusColorScheme();
}
/** The desktop app's local backend token, or null in a browser. */
export function getLocalAuthToken(): string | null {
return requireHost().core.getLocalAuthToken();
}
export function getHostPassword(
hostId: number,
field: "password" | "sudoPassword",
@@ -1583,3 +1618,6 @@ export function useConnectionRetry({
export type { PluginManifest } from "./manifest.js";
export type { ReactNode };
// The desktop bridge types, and the window.electronAPI global they declare.
export type * from "./desktop.js";
+104 -11
View File
@@ -14,10 +14,40 @@ import semver from "semver";
import { isKnownCapability } from "./capabilities.js";
/**
* The SDK major version this build implements. engine.api is the real
* compatibility gate; engine.termix is a display string and is never enforced.
* The plugin API this build implements. A minor bump adds to the API and
* never removes; a major bump may break plugins.
*
* engine.api is a semver range checked against this ("1" means any 1.x,
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const SUPPORTED_PLUGIN_API_VERSION = "1";
export const PLUGIN_API_VERSION = "1.0.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
semver.major(PLUGIN_API_VERSION),
);
/** Whether this build's plugin API satisfies a manifest's engine.api. */
export function isApiCompatible(range: string): boolean {
return (
semver.validRange(range) !== null &&
semver.satisfies(PLUGIN_API_VERSION, range)
);
}
/**
* Whether a core version satisfies a manifest's engine.termix. A prerelease
* core (2.9.0-beta.1) counts as that release.
*/
export function isTermixCompatible(
range: string,
coreVersion: string | null,
): boolean {
if (!coreVersion || !semver.valid(semver.coerce(coreVersion))) return true;
if (semver.validRange(range) === null) return false;
return semver.satisfies(semver.coerce(coreVersion)!.version, range);
}
export const PLUGIN_CATEGORIES = [
"Terminal",
@@ -59,14 +89,12 @@ export const RESERVED_PLUGIN_IDS: readonly string[] = [
/** A preset key is a plain property name, never a prototype one. */
const PRESET_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
const API_VERSION_PATTERN = /^[0-9]+$/;
/** Service names are dotted, e.g. "ssh.transport". */
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
/** Action ids name a frontend function, so segments may be camelCase. */
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const PERMISSION_PATTERN = /^[a-z0-9-]+(\.[a-z0-9_-]+)+$/;
/**
* A plugin-relative permission name. Unlike a full id one segment is fine,
* and underscores are allowed in the first segment too ("manage_providers").
@@ -98,9 +126,12 @@ export interface PluginAuthor {
}
export interface PluginEngine {
/** Display string only, e.g. ">=2.9.0". Never enforced. */
/** Semver range of Termix releases the plugin runs on, e.g. ">=2.9.0". */
termix: string;
/** SDK major version as a string. The real gate. */
/**
* Semver range of the plugin API it needs, e.g. "1" or "^1.2". Use the
* lowest minor whose API you call, so older cores refuse it cleanly.
*/
api: string;
}
@@ -257,6 +288,11 @@ export interface PluginSettingsField {
shareRead?: "connect" | "view" | "edit" | "manage";
/** Host scope only: only the host's owner may change it. */
ownerOnly?: boolean;
/**
* JSON fields only: keys inside the stored object that hold secrets, such
* as a gateway password. Core clears them from exports like any secret.
*/
secretKeys?: 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
@@ -344,6 +380,13 @@ export interface PluginContributions {
* off and a desktop can list them in its sync settings.
*/
syncEntities?: string[];
/**
* Other plugins' action, slot and extension point ids this plugin calls,
* fills or extends. Declaration only: nothing breaks when the owner is
* missing, but the coupling is written down where tools and people can
* see it.
*/
uses?: string[];
/**
* Keybinding actions this plugin adds to Appearance > Keybindings, with
* the parameters a saved binding carries. Core validates saved bindings
@@ -562,6 +605,7 @@ const ALLOWED_CONTRIBUTES = new Set([
"uiPresets",
"auth",
"syncEntities",
"uses",
"keybindingActions",
"protocols",
]);
@@ -584,6 +628,7 @@ const ALLOWED_SETTINGS_FIELD = [
"defaultFrom",
"shareRead",
"ownerOnly",
"secretKeys",
];
const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
@@ -738,9 +783,20 @@ function validateEngine(engine: unknown, errors: string[]): void {
}
rejectUnknown(engine, ["termix", "api"], '"engine"', errors);
requireString(engine.termix, "engine.termix", errors);
if (typeof engine.api !== "string" || !API_VERSION_PATTERN.test(engine.api)) {
if (
typeof engine.termix === "string" &&
semver.validRange(engine.termix) === null
) {
errors.push(
`Field "engine.api" must be an integer string, got: ${JSON.stringify(engine.api)}`,
`Field "engine.termix" must be a semver range, got: ${JSON.stringify(engine.termix)}`,
);
}
if (
typeof engine.api !== "string" ||
semver.validRange(engine.api) === null
) {
errors.push(
`Field "engine.api" must be a semver range such as "1" or "^1.2", got: ${JSON.stringify(engine.api)}`,
);
}
}
@@ -895,6 +951,28 @@ function validateSyncEntities(value: unknown, errors: string[]) {
}
}
function validateUses(value: unknown, errors: string[]) {
const where = "contributes.uses";
if (!Array.isArray(value) || value.length === 0) {
errors.push(`${where} must be a non-empty array`);
return;
}
const seen = new Set<string>();
for (const id of value) {
if (
typeof id !== "string" ||
!/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(id)
) {
errors.push(
`${where} entries must be dotted ids such as "terminal.open"`,
);
continue;
}
if (seen.has(id)) errors.push(`${where} duplicates "${id}"`);
seen.add(id);
}
}
function validateNameList(value: unknown, where: string, errors: string[]) {
if (!Array.isArray(value) || value.length === 0) {
errors.push(`${where} must be a non-empty array`);
@@ -1025,6 +1103,9 @@ function validateContributes(
if (contributes.syncEntities !== undefined) {
validateSyncEntities(contributes.syncEntities, errors);
}
if (contributes.uses !== undefined) {
validateUses(contributes.uses, errors);
}
validatePermissions(contributes.permissions, pluginId, errors);
validateActions(contributes.actions, errors);
validateActionSlots(contributes.actionSlots, errors);
@@ -1534,6 +1615,18 @@ function validateSettings(settings: unknown, errors: string[]): void {
if ("ownerOnly" in raw && typeof raw.ownerOnly !== "boolean") {
errors.push(`${fieldAt}.ownerOnly must be a boolean`);
}
if ("secretKeys" in raw) {
const keys = raw.secretKeys;
if (raw.type !== "json") {
errors.push(`${fieldAt}.secretKeys is only valid on json fields`);
} else if (
!Array.isArray(keys) ||
keys.length === 0 ||
keys.some((key) => typeof key !== "string" || key.length === 0)
) {
errors.push(`${fieldAt}.secretKeys must be a non-empty string array`);
}
}
});
}
for (const scope of ["admin", "user"] as const) {
@@ -1957,10 +2050,10 @@ export function parseManifest(raw: unknown): ParsedManifest {
const manifest = raw as PluginManifest;
if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) {
if (!isApiCompatible(manifest.engine.api)) {
return {
errors: [
`Plugin targets SDK API version ${manifest.engine.api}, but this build implements ${SUPPORTED_PLUGIN_API_VERSION}`,
`Plugin needs plugin API ${manifest.engine.api}, but this build implements ${PLUGIN_API_VERSION}`,
],
};
}
+43 -11
View File
@@ -68,7 +68,7 @@ import type {
import type { SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js";
import type {
PluginNativeRdpRequest,
PluginExternalClientRequest,
PluginProtocolTarget,
PluginHostStatusEntry,
PluginNotification,
@@ -297,8 +297,8 @@ export interface FakePluginContext {
title?: string;
ignoreCert?: boolean;
}>;
/** Every ctx.desktop.launchNativeRdp call, in order. */
nativeRdpLaunches: PluginNativeRdpRequest[];
/** Every ctx.desktop.launchExternalClient call, in order. */
externalClientLaunches: PluginExternalClientRequest[];
/** Every ctx.credentials.resolveHostProtocol call, in order. */
credentialReads: Array<{ hostId: number; protocol: string }>;
/** Every ctx.credentials.createSshKey call, with the id it answered. */
@@ -361,6 +361,11 @@ export interface FakePluginContext {
runScheduled: () => Promise<void>;
/** Changes the acting user, as core's request middleware would. */
setActor: (userId: string | undefined) => void;
/**
* Runs `fn` as `userId`, the way a request from that user would. For test
* code standing in for a caller; unlike ctx.asUser it needs no capability.
*/
actAs: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
/** Provided or seeded services, by name or "<service>#<provider>". */
services: Map<string, object>;
}
@@ -497,7 +502,8 @@ export function createFakeContext(
const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = [];
const desktopWindows: FakePluginContext["desktopWindows"] = [];
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
[];
const credentialReads: FakePluginContext["credentialReads"] = [];
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
const notifications: FakePluginContext["notifications"] = [];
@@ -695,10 +701,12 @@ export function createFakeContext(
registry: {
provide: (key, value) => {
assertOwnRegistryKey(pluginId, key);
registryProviders.set(key, value);
},
consume: (key) => registryProviders.get(key) as never,
revoke: (key, value) => {
assertOwnRegistryKey(pluginId, key);
if (!registryProviders.has(key)) return false;
if (value !== undefined && registryProviders.get(key) !== value) {
return false;
@@ -1128,8 +1136,8 @@ export function createFakeContext(
desktopWindows.push(request);
return { success: true };
},
launchNativeRdp: async (request) => {
nativeRdpLaunches.push(request);
launchExternalClient: async (request) => {
externalClientLaunches.push(request);
return { success: true };
},
available: () => options.desktopAvailable ?? false,
@@ -1309,7 +1317,7 @@ export function createFakeContext(
hostShares,
auth,
desktopWindows,
nativeRdpLaunches,
externalClientLaunches,
credentialReads,
createdSshKeys,
notifications,
@@ -1337,6 +1345,15 @@ export function createFakeContext(
setActor: (userId) => {
actor = userId;
},
actAs: async (userId, fn) => {
const previous = actor;
actor = userId;
try {
return await fn();
} finally {
actor = previous;
}
},
services,
};
}
@@ -1477,6 +1494,11 @@ export function createMockCtx(
const gatedCtx: PluginContext = {
...ctx,
asUser: async (userId, fn) => {
require("users:impersonate");
return ctx.asUser(userId, fn);
},
db: {
define: async (definition) => {
require("db:own");
@@ -1487,7 +1509,7 @@ export function createMockCtx(
return guardedDb.client();
},
refs: async () => {
require("db:own");
require("db:core-refs");
return guardedDb.refs();
},
persist: async (persistOptions) => {
@@ -1745,9 +1767,9 @@ export function createMockCtx(
require("desktop:window");
return ctx.desktop.openIsolatedWindow(request);
},
launchNativeRdp: async (request) => {
launchExternalClient: async (request) => {
require("desktop:window");
return ctx.desktop.launchNativeRdp(request);
return ctx.desktop.launchExternalClient(request);
},
available: () => ctx.desktop.available(),
},
@@ -1882,7 +1904,7 @@ export interface TestDbOptions {
* Runs after the core stub tables exist and before the plugin's migrations,
* e.g. to create the legacy table an adoption migration renames.
*/
before?: (sqlite: TestSqlite) => void | Promise<void>;
before?: (sqlite: TestSqlite) => unknown;
/** Skip applying the migrations, to apply them by hand later. */
skipMigrations?: boolean;
}
@@ -2046,6 +2068,8 @@ export interface RenderWithAppOptions {
ready?: boolean;
/** Stands in for app.api and usePluginApi(), e.g. a stub of the routes. */
api?: import("./frontend.js").PluginApiClient;
/** What app.desktop.remoteServerUrl() answers. Read on every call. */
remoteServerUrl?: () => string | null | Promise<string | null>;
}
/** A call a plugin made on the shell, recorded instead of performed. */
@@ -2150,3 +2174,11 @@ export async function renderWithApp(
const host = (await import(/* @vite-ignore */ TEST_HOST)) as PluginTestHost;
return host.renderPlugin(plugin, options);
}
function assertOwnRegistryKey(pluginId: string, key: string): void {
if (typeof key !== "string" || !key.startsWith(`${pluginId}.`)) {
throw new Error(
`Plugin ${pluginId} may only use registry keys under "${pluginId}.", not "${key}"`,
);
}
}
+4
View File
@@ -98,6 +98,7 @@ const offline = () =>
useTabs: () => ({
openTab: noop,
openSingletonTab: noop,
connectHost: noop,
closeTab: noop,
getLayout: () => null,
applyLayout: async () => ({ skipped: [] }),
@@ -142,6 +143,9 @@ const offline = () =>
setClientPreference: () => {},
listHosts: async () => [],
listCredentials: async () => [],
notifyHostsChanged: () => {},
getHostStatusColorScheme: () => "accent",
getLocalAuthToken: () => null,
},
};
+15 -6
View File
@@ -14,12 +14,11 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Walks up from a plugin directory to the repo that holds src/ui.
* Walks up from a plugin directory to a Termix checkout, if it is in one.
*
* Plugin frontends and their tests still reach core through the "@/" alias
* (the legacy debt A7 and D1 remove), so the preset has to resolve it. A
* plugin developed outside this repo has no src/ui to point at, which is
* exactly right: the alias stops resolving and the import fails loudly.
* Inside the repo the SDK's browser entries and the test host resolve to
* core's source, so a test exercises the real registries. Outside it they
* resolve to the builds the SDK ships in dist/host.
*/
function findRepoRoot(startDir: string): string | null {
let dir = startDir;
@@ -83,7 +82,13 @@ export function pluginVitestConfig(
"@/types": path.join(repoRoot, "src", "types"),
"@": path.join(repoRoot, "src", "ui"),
}
: {}),
: {
// Outside a Termix checkout: the UI kit resolves through the
// package, and the test host is the copy the SDK ships.
"@termix/plugin-host/testing": fileURLToPath(
new URL("./host/testing-host.js", import.meta.url),
),
}),
...options.alias,
};
@@ -112,6 +117,10 @@ export function pluginVitestConfig(
environment: "jsdom",
globals: true,
setupFiles: [setupFile],
// Run the SDK through Vite rather than plain Node, so its import
// of the test host sees the alias above and the plugin and the
// host share one copy of @termix/plugin-sdk/frontend.
server: { deps: { inline: [/@termix\/plugin-sdk/] } },
include: ["tests/frontend/**/*.test.{ts,tsx}"],
},
},