fix: finish 2.9.0 plugin moves and get every check green

This commit is contained in:
LukeGus committed 2026-09-24 23:35:18 -05:00
1 parent 8c1e02af89
commit 6489b62ece
335 files changed
+29878 -17749

No files matched your search

+28 -3
View File
@@ -456,6 +456,16 @@ export interface PluginSettings {
value: unknown,
) => 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",
@@ -468,6 +478,19 @@ export interface PluginSettings {
*/
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>,
context: { hostId?: number },
) => 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.
@@ -513,9 +536,6 @@ export interface PluginHostRecord {
folder: string | null;
jumpHosts?: unknown;
enableSsh?: boolean | null;
enableTerminal?: boolean | null;
enableFileManager?: boolean | null;
enableTunnel?: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
[key: string]: unknown;
@@ -1454,6 +1474,11 @@ export interface PluginAudit {
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>;
}
+147
View File
@@ -644,6 +644,16 @@ export interface TermixApp extends TermixAppInfo {
tabs: TabsApi;
/** The desktop app, when the frontend runs in it. */
desktop: DesktopApi;
/**
* Fires after this plugin's settings are saved from the settings screen or
* the host editor. Disposed automatically.
*/
onSettingsChanged: (
listener: (change: {
scope: "admin" | "user" | "host";
hostId?: number;
}) => void,
) => Disposer;
/** Registered cleanup, run when the plugin is disabled. */
onDispose: (dispose: Disposer) => void;
}
@@ -722,6 +732,7 @@ export interface PluginHostBridge {
useTheme: () => { theme: "light" | "dark" };
toast: ToastApi;
getApi: (pluginId: string) => PluginApiClient;
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
useTabs: () => TabsApi;
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
useSshAuthTypes: () => { types: SshAuthTypeInfo[]; loaded: boolean };
@@ -739,6 +750,81 @@ export interface PluginHostBridge {
activityTarget: (type: string) => ActivityTargetInfo | undefined;
useHomepageWidgetTypes: () => HomepageWidgetContribution[];
homepageWidgetType: (id: string) => HomepageWidgetContribution | undefined;
core: PluginCoreApi;
usePluginUiPreferences: (pluginId: string) => {
values: Record<string, unknown>;
set: (key: string, value: unknown) => void;
};
}
/** A key combination a user bound in Appearance > Keybindings. */
export interface KeyCombo {
key: string;
isCode: boolean;
ctrl: boolean;
alt: boolean;
shift: boolean;
meta: boolean;
}
export type KeybindingActionType =
| "copy"
| "paste"
| "sendControlCode"
| "sendText"
| "runSnippet"
| "nextTab"
| "previousTab"
| "openCommandPalette"
| "reconnectSession";
export interface KeybindingAction {
type: KeybindingActionType;
text?: string;
controlCode?: string;
snippetId?: string;
appendEnter?: boolean;
}
export interface CustomKeybinding {
id: string;
combo: KeyCombo;
action: KeybindingAction;
enabled: boolean;
overridesDefaultId?: string;
createdAt: string;
updatedAt: string;
}
/** Core calls a plugin frontend makes for the signed-in user. */
export interface PluginCoreApi {
/** Adds a recent-activity entry. `type` is one of the plugin's activityTypes. */
logActivity: (
type: string,
hostId: number,
hostName: string,
) => Promise<void>;
/** A host's stored password or sudo password, for autofill. Null when unset. */
getHostPassword: (
hostId: number,
field: "password" | "sudoPassword",
) => Promise<string | null>;
/** Updates the saved open-tab record a page reload reattaches from. */
patchOpenTab: (
instanceId: string,
updates: {
hostId?: number;
label?: string;
tabOrder?: number;
backendSessionId?: string | null;
},
) => Promise<void>;
/** The user's custom keybindings, enabled or not. */
getCustomKeybindings: () => Promise<CustomKeybinding[]>;
/** Turns auto tmux on or off in a host's terminal options. */
setHostAutoTmux: (hostId: number, autoTmux: boolean) => Promise<void>;
/** A browser-side UI preference (a cookie, or the desktop app's store). */
getClientPreference: (name: string) => string | undefined;
}
/** What a recent-activity entry opens and how it is labelled. */
@@ -849,11 +935,72 @@ export function useToast(): ToastApi {
return requireHost().toast;
}
export function logActivity(
type: string,
hostId: number,
hostName: string,
): Promise<void> {
return requireHost().core.logActivity(type, hostId, hostName);
}
export function getHostPassword(
hostId: number,
field: "password" | "sudoPassword",
): Promise<string | null> {
return requireHost().core.getHostPassword(hostId, field);
}
export function patchOpenTab(
instanceId: string,
updates: Parameters<PluginCoreApi["patchOpenTab"]>[1],
): Promise<void> {
return requireHost().core.patchOpenTab(instanceId, updates);
}
export function getCustomKeybindings(): Promise<CustomKeybinding[]> {
return requireHost().core.getCustomKeybindings();
}
export function setHostAutoTmux(
hostId: number,
autoTmux: boolean,
): Promise<void> {
return requireHost().core.setHostAutoTmux(hostId, autoTmux);
}
export function getClientPreference(name: string): string | undefined {
return requireHost().core.getClientPreference(name);
}
export function usePluginApi(): PluginApiClient {
const bridge = requireHost();
return bridge.getApi(bridge.usePluginId());
}
/**
* This plugin's client for a resolved connection origin: "remote" reaches the
* desktop app's connected remote server, anything else is usePluginApi's.
*/
export function usePluginApiFor(origin: unknown): PluginApiClient {
const bridge = requireHost();
return bridge.getApiFor(bridge.usePluginId(), origin);
}
/**
* This plugin's Appearance area: the preset it declared in
* contributes.uiPresets for the user's level, with the user's changes on top.
* `set` saves one change; null clears it back to the preset.
*/
export function usePluginUiPreferences<
T extends Record<string, unknown> = Record<string, unknown>,
>(): {
values: T;
set: <K extends keyof T & string>(key: K, value: T[K] | null) => void;
} {
const bridge = requireHost();
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never;
}
export function useTabs(): TabsApi {
return requireHost().useTabs();
}
+161 -1
View File
@@ -224,6 +224,19 @@ export interface PluginSettingsField {
group?: string;
/** Registered component id. Required when type is "custom". */
component?: string;
/**
* Host scope only: the admin field whose value a new host starts with,
* instead of `default`. Hosts that never saved the field still read
* `default`.
*/
defaultFrom?: string;
/**
* Host scope only: the lowest share level whose recipients see this value
* in the host payload. Defaults to "connect", everyone who sees the host.
*/
shareRead?: "connect" | "view" | "edit" | "manage";
/** Host scope only: only the host's owner may change it. */
ownerOnly?: boolean;
/**
* 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
@@ -236,6 +249,8 @@ export interface PluginHostSettingsContribution {
/** Boolean field rendered first, gating the rest of the section. */
enableKey?: string;
enableLabelKey?: string;
/** What the enable switch reads before a host saves it. Off by default. */
enableDefault?: boolean;
fields: PluginSettingsField[];
}
@@ -288,6 +303,27 @@ export interface PluginContributions {
*/
hostCapability?: HostCapabilityContribution | HostCapabilityContribution[];
auth?: PluginAuthContribution;
http?: PluginHttpContribution;
/**
* This plugin's Appearance defaults for each interface preset, read in the
* frontend with usePluginUiPreferences. Every preset names the same keys;
* values are booleans, numbers, strings or string arrays.
*/
uiPresets?: Record<
"simple" | "balanced" | "advanced",
Record<string, unknown>
>;
}
export interface PluginHttpContribution {
/**
* Old URLs something outside Termix still calls (a webhook a third party
* posts to). Each must start with "/<plugin id>/"; a request under it is
* served by this plugin's router with that prefix removed, so
* "/automations/webhook/x" reaches the route "/webhook/x". Core routes win
* a clash.
*/
legacyPaths?: string[];
}
export interface PluginManifest {
@@ -375,6 +411,8 @@ const ALLOWED_CONTRIBUTES = new Set([
"permissions",
"settings",
"hostCapability",
"http",
"uiPresets",
"auth",
]);
@@ -393,8 +431,13 @@ const ALLOWED_SETTINGS_FIELD = [
"group",
"component",
"hidden",
"defaultFrom",
"shareRead",
"ownerOnly",
];
const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
/** Settings keys are stored as-is, so they stay short and index-safe. */
const SETTINGS_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
@@ -782,6 +825,78 @@ function validateContributes(
validateSettings(contributes.settings, errors);
validateHostCapability(contributes.hostCapability, errors);
validateAuthContribution(contributes.auth, errors);
validateHttpContribution(contributes.http, pluginId, errors);
validateUiPresets(contributes.uiPresets, errors);
}
function isPresetValue(value: unknown): boolean {
return (
typeof value === "boolean" ||
(typeof value === "number" && Number.isFinite(value)) ||
typeof value === "string" ||
(Array.isArray(value) && value.every((item) => typeof item === "string"))
);
}
function validateUiPresets(value: unknown, errors: string[]): void {
if (value === undefined) return;
const where = "contributes.uiPresets";
if (!isPlainObject(value)) {
errors.push(`Field "${where}" must be an object`);
return;
}
rejectUnknown(value, ["simple", "balanced", "advanced"], where, errors);
let keys: string | undefined;
for (const level of ["simple", "balanced", "advanced"]) {
const preset = value[level];
if (!isPlainObject(preset)) {
errors.push(`${where}.${level} must be an object`);
continue;
}
for (const [key, entry] of Object.entries(preset)) {
if (!isPresetValue(entry)) {
errors.push(
`${where}.${level}.${key} must be a boolean, number, string or string array`,
);
}
}
const names = Object.keys(preset).sort().join(",");
if (keys === undefined) keys = names;
else if (keys !== names) {
errors.push(`${where} presets must all name the same keys`);
}
}
}
const LEGACY_PATH_PATTERN = /^\/[a-z][a-z0-9-]*(\/[A-Za-z0-9_.-]+)+$/;
function validateHttpContribution(
value: unknown,
pluginId: string | undefined,
errors: string[],
): void {
if (value === undefined) return;
if (!isPlainObject(value)) {
errors.push('Field "contributes.http" must be an object');
return;
}
rejectUnknown(value, ["legacyPaths"], "contributes.http", errors);
const paths = value.legacyPaths;
if (paths === undefined) return;
if (!Array.isArray(paths)) {
errors.push('Field "contributes.http.legacyPaths" must be an array');
return;
}
paths.forEach((path, index) => {
const at = `contributes.http.legacyPaths[${index}]`;
if (typeof path !== "string" || !LEGACY_PATH_PATTERN.test(path)) {
errors.push(`${at} must be an absolute path like "/<plugin id>/name"`);
return;
}
if (pluginId && !path.startsWith(`/${pluginId}/`)) {
errors.push(`${at} must start with "/${pluginId}/"`);
}
});
}
const AUTH_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
@@ -861,7 +976,7 @@ function validateSettings(settings: unknown, errors: string[]): void {
}
rejectUnknown(
host,
["enableKey", "enableLabelKey", "fields"],
["enableKey", "enableLabelKey", "enableDefault", "fields"],
`"${at}"`,
errors,
);
@@ -876,6 +991,9 @@ function validateSettings(settings: unknown, errors: string[]): void {
// An enable switch with no label is a blank row in the host editor.
requireString(host.enableLabelKey, `${at}.enableLabelKey`, errors);
}
if ("enableDefault" in host && typeof host.enableDefault !== "boolean") {
errors.push(`${at}.enableDefault must be a boolean`);
}
if (!Array.isArray(host.fields)) {
errors.push(`${at}.fields must be an array`);
@@ -887,6 +1005,48 @@ function validateSettings(settings: unknown, errors: string[]): void {
errors,
typeof host.enableKey === "string" ? host.enableKey : undefined,
);
const adminKeys = new Set(
(Array.isArray(settings.admin) ? settings.admin : [])
.filter(isPlainObject)
// A secret never becomes a default the host editor can read.
.filter((field) => field.type !== "secret")
.map((field) => field.key),
);
host.fields.forEach((raw, index) => {
if (!isPlainObject(raw)) return;
const fieldAt = `${at}.fields[${index}]`;
if ("defaultFrom" in raw && !adminKeys.has(raw.defaultFrom as string)) {
errors.push(
`${fieldAt}.defaultFrom must name one of this plugin's non-secret admin fields`,
);
}
if (
"shareRead" in raw &&
!SHARE_LEVELS.includes(raw.shareRead as string)
) {
errors.push(
`${fieldAt}.shareRead must be one of: ${SHARE_LEVELS.join(", ")}`,
);
}
if ("ownerOnly" in raw && typeof raw.ownerOnly !== "boolean") {
errors.push(`${fieldAt}.ownerOnly must be a boolean`);
}
});
}
for (const scope of ["admin", "user"] as const) {
const fields = settings[scope];
if (!Array.isArray(fields)) continue;
fields.forEach((raw, index) => {
if (!isPlainObject(raw)) return;
for (const key of ["defaultFrom", "shareRead", "ownerOnly"]) {
if (key in raw) {
errors.push(
`${where}.${scope}[${index}].${key} is only valid on host fields`,
);
}
}
});
}
}
+89 -10
View File
@@ -243,6 +243,12 @@ export interface FakeAuthRegistrations {
export interface FakePluginContext {
ctx: PluginContext;
/** Runs what ctx.settings.onValidate registered, as a settings save would. */
validateSettings: (
scope: "admin" | "user" | "host",
values: Record<string, unknown>,
context?: { hostId?: number },
) => Promise<Record<string, string>>;
/** Everything the plugin registered through ctx.disposables. */
disposals: Array<() => void | Promise<void>>;
/** Topics emitted through ctx.events.emit, in order. */
@@ -417,6 +423,10 @@ export function createFakeContext(
Object.entries(options.coreSettings ?? {}),
);
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
const settingsValidators = new Set<{
scope: "admin" | "user" | "host";
validator: Parameters<PluginContext["settings"]["onValidate"]>[1];
}>();
const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = [];
const desktopWindows: FakePluginContext["desktopWindows"] = [];
@@ -499,6 +509,15 @@ export function createFakeContext(
: null;
const holds = (permission: string) =>
held === null || held.has(qualifyPermission(pluginId, permission));
// Like the runtime, a route can only require a permission the manifest
// declares. Checked when the test hands in a manifest.
const declaredPermissions = options.manifest?.contributes?.permissions
? new Set(
options.manifest.contributes.permissions.map((entry) =>
qualifyPermission(pluginId, entry.name),
),
)
: null;
for (const [key, value] of Object.entries(options.settings ?? {})) {
settings.set(settingsKey("admin", undefined, key), value);
@@ -694,8 +713,16 @@ export function createFakeContext(
// other plugins' namespaces is covered by core's tests, not here.
has: async (permission) => holds(permission),
hasFor: async (_userId, permission) => holds(permission),
require: (permission) =>
((_req, res, next) => {
require: (permission) => {
if (
declaredPermissions &&
!declaredPermissions.has(qualifyPermission(pluginId, permission))
) {
throw new Error(
`Plugin ${pluginId} cannot require permission "${permission}": it is not declared in contributes.permissions`,
);
}
return ((_req, res, next) => {
if (holds(permission)) {
next();
return;
@@ -704,7 +731,8 @@ export function createFakeContext(
error: "Insufficient permissions",
required: qualifyPermission(pluginId, permission),
});
}) as PluginMiddleware,
}) as PluginMiddleware;
},
},
// Nothing here is capability-checked, per the module doc; createMockCtx
@@ -729,6 +757,22 @@ export function createFakeContext(
setHost: async (hostId, key, value) =>
writeSetting("host", String(hostId), key, value),
listHostValues: async (key) => {
const found: Array<{ hostId: number; userId: string; value: never }> =
[];
for (const [storedKey, value] of settings) {
const [scope, scopeId, name] = storedKey.split(":");
if (scope !== "host" || name !== key || value === undefined) continue;
const hostId = Number(scopeId);
found.push({
hostId,
userId: hostRecordsById.get(hostId)?.userId ?? actor ?? "",
value: value as never,
});
}
return found;
},
getAll: async (scope, scopeId) => {
const prefix = `${scope}:${scopeId ?? ""}:`;
const all: Record<string, unknown> = {};
@@ -754,6 +798,16 @@ export function createFakeContext(
return unsubscribe;
},
onValidate: (scope, validator) => {
const entry = { scope, validator };
settingsValidators.add(entry);
const unsubscribe = () => {
settingsValidators.delete(entry);
};
disposals.push(unsubscribe);
return unsubscribe;
},
readCore: async (key) => coreSettings.get(key) ?? null,
},
@@ -919,12 +973,17 @@ export function createFakeContext(
),
requiresSecret: (authType) =>
["password", "key", "credential", "agent"].includes(authType),
supportsBackground: (authType) =>
authType !== "none" &&
!auth.sshAuthProviders.some(
(provider) =>
provider.type === authType && provider.supportsBackground === false,
),
// Matches core: the built-in types, plus any registered provider that
// does not opt out. An unknown type cannot connect unattended.
supportsBackground: (authType) => {
if (["password", "key", "credential", "agent"].includes(authType)) {
return true;
}
const provider = auth.sshAuthProviders.find(
(candidate) => candidate.type === authType,
);
return !!provider && provider.supportsBackground !== false;
},
},
auth: {
@@ -1150,6 +1209,14 @@ export function createFakeContext(
return {
ctx,
validateSettings: async (scope, values, context = {}) => {
const errors: Record<string, string> = {};
for (const entry of settingsValidators) {
if (entry.scope !== scope) continue;
Object.assign(errors, (await entry.validator(values, context)) ?? {});
}
return errors;
},
disposals,
emitted,
kv,
@@ -1385,7 +1452,15 @@ export function createMockCtx(
}
ctx.events.emit(topic, payload);
},
on: (topic, listener) => ctx.events.on(topic, listener),
on: (topic, listener) => {
if (!topic.startsWith("plugin.") && !granted.has("events:core")) {
throw new Error(
`Plugin ${pluginId} may only listen to "plugin.*" topics. ` +
`Declare the events:core capability to listen to core topics.`,
);
}
return ctx.events.on(topic, listener);
},
},
http: {
@@ -1411,6 +1486,10 @@ export function createMockCtx(
// Reading and writing a plugin's OWN settings is ungated, exactly as in
// the real runtime. Only readCore reaches outside the plugin.
...ctx.settings,
listHostValues: async (key) => {
require("hosts:read");
return ctx.settings.listHostValues(key);
},
readCore: async (key) => {
require("settings:read-core");
return ctx.settings.readCore(key);
@@ -0,0 +1,68 @@
/**
* Keeps a test server's random port off the fetch spec's blocked list.
*
* Tests start servers on port 0 and call them with fetch, which refuses
* ports like 6000 or 6667 with "bad port". Linux never hands those out as
* ephemeral ports, but Windows can (its dynamic range may start at 1024), so
* a server that lands on one closes and listens again.
*/
import net from "node:net";
export const FETCH_BLOCKED_PORTS = new Set([
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79,
87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137,
139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532,
540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723,
2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669,
6679, 6697, 10080,
]);
function wantsRandomPort(args: unknown[]): boolean {
const first = args[0];
if (first === 0 || first === "0") return true;
if (first && typeof first === "object" && !Array.isArray(first)) {
const port = (first as { port?: unknown }).port;
return port === 0 || port === "0";
}
return false;
}
const PATCHED = Symbol.for("termix.safePorts");
export function installSafePorts(): void {
const proto = net.Server.prototype as net.Server & { [PATCHED]?: boolean };
if (proto[PATCHED]) return;
proto[PATCHED] = true;
const listen = proto.listen as (
this: net.Server,
...args: unknown[]
) => net.Server;
proto.listen = function (this: net.Server, ...args: unknown[]) {
if (wantsRandomPort(args)) retryOnBlockedPort(this, args);
return listen.apply(this, args);
} as typeof proto.listen;
}
function retryOnBlockedPort(server: net.Server, args: unknown[]): void {
const emit = server.emit;
// The caller's callback is already a pending "listening" listener, so a
// retry leaves it out and it fires once, on the good port.
const retryArgs = args.filter((arg) => typeof arg !== "function");
server.emit = ((event: string | symbol, ...rest: unknown[]) => {
if (event === "listening") {
const address = server.address();
if (
address &&
typeof address === "object" &&
FETCH_BLOCKED_PORTS.has(address.port)
) {
server.close(() => server.listen(...(retryArgs as [])));
return true;
}
server.emit = emit;
}
return emit.call(server, event, ...rest);
}) as typeof server.emit;
}
+25
View File
@@ -6,6 +6,9 @@
*/
import { afterEach, vi } from "vitest";
import { installSafePorts } from "./safe-ports.js";
installSafePorts();
if (typeof window !== "undefined" && !window.matchMedia) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
@@ -114,6 +117,28 @@ const offline = () =>
}),
useSlotContributions: () => [],
usePluginComponent: () => undefined,
getApiFor: () => ({
get: offline,
delete: offline,
post: offline,
put: offline,
patch: offline,
}),
hostProtocols: () => ["ssh"],
useHostActions: () => [],
useActivityTypes: () => [],
activityTarget: () => undefined,
useHomepageWidgetTypes: () => [],
homepageWidgetType: () => undefined,
usePluginUiPreferences: () => ({ values: {}, set: noop }),
core: {
logActivity: async () => {},
getHostPassword: async () => null,
patchOpenTab: async () => {},
getCustomKeybindings: async () => [],
setHostAutoTmux: async () => {},
getClientPreference: () => undefined,
},
};
afterEach(() => {