mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: finish 2.9.0 plugin moves and get every check green
This commit is contained in:
1 parent
8c1e02af89
commit
6489b62ece
335 files changed
+29878
-17749
No files matched your search
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user