* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

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

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 16:03:29 -05:00
1 parent aff29a9581
commit a89ccc6b07
577 files changed
+76479 -101831

No files matched your search

File diff suppressed because it is too large. Load diff
+11
View File
@@ -47,20 +47,30 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("users:impersonate", "high"),
entry("db:core-refs", "high"),
entry("auth:provide", "high"),
entry("auth:policy", "high"),
entry("sync:provide", "high"),
entry("ssh:transport", "high"),
entry("device:serial", "high"),
entry("notify:hub", "high"),
entry("ui:layout", "high"),
entry("hosts:write", "medium"),
entry("hosts:export", "high"),
entry("credentials:use", "medium"),
entry("credentials:write", "medium"),
entry("network:outbound", "medium"),
entry("network:serve", "medium"),
entry("network:broadcast", "medium"),
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"),
entry("audit:read", "medium"),
entry("desktop:window", "medium"),
entry("desktop:local", "high"),
entry("hosts:read", "low"),
entry("db:own", "low"),
@@ -70,6 +80,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("settings:read-core", "low"),
entry("plugins:read", "low"),
entry("ui:surface", "low"),
entry("ui:theme", "low"),
] as const;
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
+1
View File
@@ -256,6 +256,7 @@ export const LEGACY_TABLE_OWNERS: Readonly<Record<string, string>> = {
homepage_items: "homepage",
homepage_layouts: "homepage",
dashboard_service_links: "homepage",
recent_activity: "homepage",
secret_sources: "secret-sources",
opkssh_tokens: "opkssh",
vault_profiles: "vault",
+11 -286
View File
@@ -3,8 +3,6 @@
* only inside the Termix desktop app; check isElectron before calling it.
*/
import type { PluginSshPromptRequest } from "./backend.js";
export interface ServerConfig {
serverUrl?: string;
allowInvalidCertificate?: boolean;
@@ -33,31 +31,21 @@ export interface DialogResult {
[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>;
/** Whether the app-drawn title bar shows in this window. */
getTitleBarVisible?: () => Promise<boolean>;
/** Calls back when the title bar shows or hides; returns an unsubscribe. */
onTitleBarVisibleChange?: (
callback: (visible: boolean) => void,
) => () => void;
/** Keeps the window controls the system draws in the colors of the theme. */
setTitleBarOverlay?: (colors: {
color?: string;
symbolColor?: string;
}) => Promise<void>;
getSetting?: (key: string) => Promise<string | null | undefined>;
setSetting?: (key: string, value: string) => Promise<void>;
@@ -67,60 +55,6 @@ export interface ElectronAPI {
serverUrl: string,
allowInvalidCertificate?: boolean,
) => Promise<ConnectionTestResult>;
answerC2SAuth: (id: string, answer: string | null) => Promise<boolean>;
onC2SAuthPrompt: (
callback: (prompt: {
id: string;
closed?: boolean;
tunnelName?: string;
request?: PluginSshPromptRequest;
}) => void,
) => () => void;
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: (
@@ -136,62 +70,6 @@ export interface ElectronAPI {
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;
@@ -238,159 +116,6 @@ export interface ElectronAPI {
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 {
+256 -2
View File
@@ -248,6 +248,129 @@ export interface TabsApi extends Pick<
onReady: (listener: () => void) => Disposer;
}
// ---------------------------------------------------------------------------
// Layouts (plugin API 1.4)
// ---------------------------------------------------------------------------
/**
* A whole-app layout. Core renders the chosen one inside its providers and an
* error boundary; everything it shows comes from useShell(). Declare it in
* manifest contributes.layouts and add the ui:layout capability.
*/
export interface LayoutContribution {
id: string;
/** Your own i18n key or plain text, the same as the manifest's label. */
label: string;
component: ComponentType;
/** Shown on phones and narrow windows. The desktop app never uses it. */
mobileComponent?: ComponentType;
/** Theme to start with when nobody chose one. */
defaultTheme?: string;
}
/** A tab as a layout sees it. Splits are tabs too, with a `split` field. */
export interface ShellTabState extends PluginTabRecord {
instanceId: string;
customLabel?: string;
/** Set while the tab sits in a split pane. */
parentSplitTabId?: string;
openedAt?: number;
}
/** What a dock region shows and how it is sized, in Normal-size pixels. */
export interface ShellDockState {
view: string | null;
open: boolean;
width: number;
dragging: boolean;
setView: (view: string | null) => void;
setWidth: (width: number) => void;
/** Starts a drag from the dock's edge, from a mousedown. */
startResize: (event: { clientX: number; preventDefault: () => void }) => void;
resetWidth: () => void;
}
/** The live shell: state and actions, nothing drawn. Read with useShell(). */
export interface ShellController {
tabs: {
list: ShellTabState[];
/** What a tab bar shows: tabs not sitting in a split. */
topLevel: ShellTabState[];
activeId: string;
/** True once saved tabs were restored after login. */
ready: boolean;
/** The user turned on tab numbers. */
showNumbers: boolean;
setActive: (id: string) => void;
reorder: (topLevel: ShellTabState[]) => void;
close: (id: string) => Promise<void>;
rename: (id: string, label: string) => void;
/** Reconnects or reloads a session tab. */
refresh: (id: string) => void;
reconnectAll: () => void;
openSingleton: (type: string) => string | undefined;
};
split: {
/** The split shown now, or null. */
activeId: string | null;
focusedPaneId: string | null;
/** Every split with its panes, for a split menu. */
summaries: Array<{
id: string;
label: string;
full: boolean;
panes: Array<{
id: string;
index: number;
tabId: string | null;
label: string | null;
}>;
}>;
activeFull: boolean;
/** Split a tab, apply a preset, unsplit, add to a pane. */
handleAction: (action: Record<string, unknown> & { kind: string }) => void;
};
hosts: {
list: PluginHostRecord[];
loading: boolean;
loaded: boolean;
reload: () => Promise<void>;
};
regions: {
left: ShellDockState & { view: string };
right: ShellDockState;
/** What a rail click does: toggles or switches the left dock. */
railClick: (view: string) => void;
openInRightDock: (view: string) => void;
toggleRightDock: () => void;
};
palette: { isOpen: boolean; open: () => void; close: () => void };
fullscreen: { active: boolean; toggle: () => void };
/** The same callbacks tabs and panels get. */
actions: ShellApi;
env: {
isMobile: boolean;
isElectron: boolean;
user: { username: string; isAdmin: boolean };
permissions: string[];
branding: {
appName: string | null;
tagline: string | null;
logo: string | null;
};
};
/** Core registries, as hooks a layout calls while rendering. */
registries: {
useRailItems: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
usePanels: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
useTabTypes: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
useDashboardCards: () => ReadonlyArray<
{ id: string } & Record<string, unknown>
>;
};
logout: (options?: { manual?: boolean }) => void;
}
// ---------------------------------------------------------------------------
// Registrations
// ---------------------------------------------------------------------------
@@ -530,8 +653,16 @@ export interface HostBadgeContribution {
id: string;
when: (host: PluginHostRecord) => boolean;
component: ComponentType<{ host: PluginHostRecord }>;
/** Lower comes first. Ties sort by id. */
order?: number;
}
/** The dot or stripe that shows a host's status on its row. */
export type HostStatusIndicatorContribution = HostBadgeContribution;
/** A control in the host editor's toolbar. */
export type HostEditorToolbarItemContribution = HostBadgeContribution;
export interface HostContextMenuItemContribution {
id: string;
titleKey: string;
@@ -683,6 +814,66 @@ export interface SettingsComponentProps {
running: boolean;
}
/** What a companion starts with. Read from the plugin's own settings. */
export interface CompanionDefaults {
/** Open by itself the first time a matching tab shows. */
open?: boolean;
side?: "left" | "right" | "bottom";
/** Pixels of width, or height at the bottom. */
size?: number;
/** Show the edge button that opens and closes it. Default true. */
edgeButton?: boolean;
}
/** A panel a tab type can show beside it, for a picker. */
export interface CompanionInfo {
/** The id registerCompanionLayout's hidden and order lists use. */
id: string;
titleKey: string;
icon?: IconComponent;
}
/** How a host wants the panel bar beside a tab. */
export interface CompanionLayout {
/** False removes the bar and its panels. */
enabled?: boolean;
/** Where a panel opens until the user moves it. */
side?: "left" | "right" | "bottom";
/** Companion ids in bar order. */
order?: string[];
/** Companion ids left out. */
hidden?: string[];
}
export interface CompanionProps {
/** The tab it sits beside. */
tab: PluginTabRecord;
host?: PluginHostRecord;
/** The host in the shape the connection APIs take. */
sshHost?: Record<string, unknown>;
/** The tab's surface handle (see TabHandle), read at call time. */
getHandle: () => TabHandle | null;
side: "left" | "right" | "bottom";
/** False while the tab is in the background or the panel is closed. */
isVisible: boolean;
close: () => void;
setSide: (side: "left" | "right" | "bottom") => void;
}
export interface CompanionOptions {
icon?: IconComponent;
titleKey?: string;
order?: number;
/** Tab types it applies to. Any tab with a host when empty. */
tabTypes?: string[];
when?: (tab: PluginTabRecord, host?: PluginHostRecord) => boolean;
/**
* A hook returning the starting state. It runs in the plugin's scope, so it
* can read the plugin's settings.
*/
useDefaults?: () => CompanionDefaults;
}
export type ActionHandler = (...args: never[]) => unknown;
export interface ActionSlotDefinition {
@@ -715,10 +906,11 @@ export type OnboardingMode = "full" | "partial" | "rerun";
* Where a plugin step sits in onboarding. "setup" comes right after the
* appearance steps, for things that need doing before the feature works
* (like pointing at a guacd server). "explore" (the default) comes after
* that, "security" after the account security step. Onboarding is for
* that, "security" after the account security step. "start" comes before
* the appearance steps, for choices that shape how the rest looks. Onboarding is for
* choices, not for explaining features.
*/
export type OnboardingSection = "setup" | "explore" | "security";
export type OnboardingSection = "start" | "setup" | "explore" | "security";
export interface OnboardingStepProps {
/**
@@ -761,6 +953,16 @@ export interface OnboardingStepContribution {
permission?: string;
section?: OnboardingSection;
order?: number;
/**
* Exact place in the flow, 1 to 99, instead of a section. Core steps sit at
* 10 (plugins), 20, 25, 30 (preset), 40 (appearance) and 90 (security).
*/
position?: number;
/**
* Older step keys this step took over, like "preset". People who already
* saw one of them are not shown this step again.
*/
replaces?: string[];
when?: (context: {
isAdmin: boolean;
isDesktop: boolean;
@@ -926,12 +1128,40 @@ export interface TermixApp extends TermixAppInfo {
component: ComponentType<TabProps>,
options?: TabOptions,
) => Disposer;
/**
* A panel that sits beside any host tab the options match (a terminal, a
* desktop, ...), bound to that tab's host. The user opens, resizes and moves
* it; the tab keeps running underneath.
*/
registerCompanion: (
id: string,
component: ComponentType<CompanionProps>,
options?: CompanionOptions,
) => Disposer;
/**
* Lets a plugin decide, per host, how the panel bar looks on one tab type.
* Read from the host payload, so it must not call hooks.
*/
registerCompanionLayout: (
tabType: string,
resolve: (host: PluginHostRecord | undefined) => CompanionLayout,
) => Disposer;
/** Opens a companion on a tab, or closes it when it is showing. */
toggleCompanion: (tab: PluginTabRecord, id?: string) => void;
registerHostEditorSection: (
section: HostEditorSectionContribution,
) => Disposer;
registerHostAction: (action: HostActionContribution) => Disposer;
registerHostProtocol: (protocol: HostProtocolContribution) => Disposer;
registerHostBadge: (badge: HostBadgeContribution) => Disposer;
/** Draws a host row's status. A layout renders every registered one. */
registerHostStatusIndicator: (
indicator: HostStatusIndicatorContribution,
) => Disposer;
/** Adds a control to the host editor's toolbar. */
registerHostEditorToolbarItem: (
item: HostEditorToolbarItemContribution,
) => Disposer;
registerHostContextMenuItem: (
item: HostContextMenuItemContribution,
) => Disposer;
@@ -988,6 +1218,12 @@ export interface TermixApp extends TermixAppInfo {
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
/**
* A whole-app layout. Declared in manifest contributes.layouts with the
* ui:layout capability. Only the chosen layout's plugin is loaded, so
* register it in activate. Needs plugin API 1.4.
*/
registerLayout: (layout: LayoutContribution) => Disposer;
/**
* Translates a key from the plugin's own locales, for code outside a
@@ -1122,6 +1358,7 @@ export interface PluginHostBridge {
useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean };
useCurrentUser: () => CurrentUser | null;
useTheme: () => { theme: "light" | "dark" };
useShell: () => ShellController | null;
toast: ToastApi;
getApi: (pluginId: string) => PluginApiClient;
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
@@ -1138,6 +1375,7 @@ export interface PluginHostBridge {
hostProtocols: (host: PluginHostRecord) => string[];
useHostStatus: (hostId: number | undefined) => HostStatusInfo | null;
useHostActions: () => HostActionContribution[];
useCompanions: (tabType: string) => CompanionInfo[];
useActivityTypes: () => string[];
activityTarget: (type: string) => ActivityTargetInfo | undefined;
useExtensions: (pointId: string) => ExtensionContribution[];
@@ -1372,6 +1610,14 @@ export function useTheme(): { theme: "light" | "dark" } {
return requireHost().useTheme();
}
/**
* The live shell, for a layout. Null outside the signed-in app. Needs plugin
* API 1.4.
*/
export function useShell(): ShellController | null {
return requireHost().useShell();
}
export function useToast(): ToastApi {
return requireHost().toast;
}
@@ -1539,6 +1785,14 @@ export function useHostActions(): HostActionContribution[] {
return requireHost().useHostActions();
}
/**
* Every panel that can open beside a tab of this type, for a settings screen
* that lets the user hide or reorder them. Reactive.
*/
export function useCompanions(tabType: string): CompanionInfo[] {
return requireHost().useCompanions(tabType);
}
/**
* Recent-activity types every registered tab claims (registerTab's
* activityTypes), core's and plugins'. For a filter that lists what a user
+195 -1
View File
@@ -19,6 +19,7 @@ import {
type PluginEnvVar,
} from "./docs.js";
export { validateThemeCss } from "./theme-css.js";
export {
ENV_NAME_PATTERN,
MAX_ENV_DESCRIPTION_LENGTH,
@@ -36,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.3.0";
export const PLUGIN_API_VERSION = "1.11.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -426,6 +427,40 @@ export interface PluginAuthContribution {
keyboardInteractive?: string[];
}
/** A layout id or theme id: short, lowercase, used as a CSS class for themes. */
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
/**
* A whole-app layout the frontend registers with app.registerLayout. Needs
* the ui:layout capability. Declared here so core can list and choose it
* before any plugin code runs.
*/
export interface PluginLayoutContribution {
id: string;
/** i18n key in the plugin's namespace, or plain text. */
label: string;
description?: string;
/** Theme picked with this layout when nobody chose one. */
defaultTheme?: string;
/** The layout a default-look plugin starts with. */
default?: boolean;
}
/**
* A theme: one CSS file of token rules scoped under `.<id>`, applied as a
* class on <html>. Needs the ui:theme capability. No code runs for a theme.
*/
export interface PluginThemeContribution {
id: string;
/** i18n key in the plugin's namespace, or plain text. */
label: string;
mode: "light" | "dark";
/** Path of the CSS file inside the plugin. */
css: string;
/** Colors the theme picker previews it with. */
swatch?: { background: string; panel: string; text: string };
}
export interface PluginContributions {
tabs?: PluginTabContribution[];
panels?: PluginViewContribution[];
@@ -487,6 +522,10 @@ export interface PluginContributions {
* ctx.credentials.resolveHostProtocol.
*/
protocols?: PluginProtocolContribution[];
/** Whole-app layouts. Needs the ui:layout capability. */
layouts?: PluginLayoutContribution[];
/** Themes. Needs the ui:theme capability. */
themes?: PluginThemeContribution[];
}
/** A field a protocol login carries besides username and password. */
@@ -649,6 +688,21 @@ export interface PluginManifest {
nativeDependencies?: string[];
/** How a linked desktop treats this plugin. Defaults to "mirror". */
desktop?: PluginDesktopMode;
/**
* "desktop": the plugin only loads on a desktop app's own embedded backend,
* never on a server. It implies desktop "local" and may declare desktop:local.
*/
runtime?: PluginRuntime;
}
export type PluginRuntime = "desktop";
export const PLUGIN_RUNTIMES: readonly PluginRuntime[] = ["desktop"];
/** The desktop mode a manifest resolves to. */
export function resolvePluginDesktopMode(
m: Pick<PluginManifest, "desktop" | "runtime">,
): PluginDesktopMode {
return m.desktop ?? (m.runtime === "desktop" ? "local" : "mirror");
}
export const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
@@ -688,6 +742,7 @@ const ALLOWED_TOP_LEVEL = new Set([
"platforms",
"nativeDependencies",
"desktop",
"runtime",
]);
const ALLOWED_CONTRIBUTES = new Set([
@@ -708,6 +763,8 @@ const ALLOWED_CONTRIBUTES = new Set([
"uses",
"keybindingActions",
"protocols",
"layouts",
"themes",
]);
const ALLOWED_SETTINGS_FIELD = [
@@ -863,6 +920,21 @@ export function validateManifest(manifest: unknown): string[] {
`Field "desktop" must be one of: ${PLUGIN_DESKTOP_MODES.join(", ")}`,
);
}
if (
m.runtime !== undefined &&
!PLUGIN_RUNTIMES.includes(m.runtime as PluginRuntime)
) {
errors.push(
`Field "runtime" must be one of: ${PLUGIN_RUNTIMES.join(", ")}`,
);
}
if (
m.runtime === "desktop" &&
m.desktop !== undefined &&
m.desktop !== "local"
) {
errors.push(`A "desktop" runtime plugin must use desktop "local"`);
}
validateDependencyMap(m.dependencies, "dependencies", errors);
validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors);
validateProvides(m.provides, errors);
@@ -1300,6 +1372,111 @@ function validateContributes(
validateUiPresets(contributes.uiPresets, errors);
validateKeybindingActions(contributes.keybindingActions, errors);
validateProtocols(contributes.protocols, errors);
validateLayouts(contributes.layouts, errors);
validateThemes(contributes.themes, errors);
}
/** An array of objects with unique look ids; calls `each` for every valid entry. */
function validateLookList(
value: unknown,
field: string,
allowed: string[],
errors: string[],
each: (raw: Record<string, unknown>, where: string) => void,
): void {
if (value === undefined) return;
if (!Array.isArray(value)) {
errors.push(`Field "contributes.${field}" must be an array`);
return;
}
const seen = new Set<string>();
value.forEach((raw, index) => {
const where = `contributes.${field}[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, allowed, where, errors);
if (typeof raw.id !== "string" || !LOOK_ID_PATTERN.test(raw.id)) {
errors.push(
`${where}.id must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
);
} else {
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
seen.add(raw.id);
}
requireString(raw.label, `${where}.label`, errors);
each(raw, where);
});
}
function validateLayouts(value: unknown, errors: string[]): void {
validateLookList(
value,
"layouts",
["id", "label", "description", "defaultTheme", "default"],
errors,
(raw, where) => {
if (raw.description !== undefined)
requireString(raw.description, `${where}.description`, errors);
if (
raw.defaultTheme !== undefined &&
(typeof raw.defaultTheme !== "string" ||
!LOOK_ID_PATTERN.test(raw.defaultTheme))
) {
errors.push(`${where}.defaultTheme must be a theme id`);
}
if (raw.default !== undefined && typeof raw.default !== "boolean") {
errors.push(`${where}.default must be a boolean`);
}
},
);
if (
Array.isArray(value) &&
value.filter((raw) => isPlainObject(raw) && raw.default === true).length > 1
) {
errors.push('Only one entry in "contributes.layouts" may set default');
}
}
const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
function validateThemes(value: unknown, errors: string[]): void {
validateLookList(
value,
"themes",
["id", "label", "mode", "css", "swatch"],
errors,
(raw, where) => {
if (raw.mode !== "light" && raw.mode !== "dark") {
errors.push(`${where}.mode must be "light" or "dark"`);
}
if (requireString(raw.css, `${where}.css`, errors)) {
requireRelativePath(raw.css, `${where}.css`, errors);
if (!raw.css.endsWith(".css")) {
errors.push(`${where}.css must be a .css file`);
}
}
if (raw.swatch !== undefined) {
if (!isPlainObject(raw.swatch)) {
errors.push(`${where}.swatch must be an object`);
return;
}
rejectUnknown(
raw.swatch,
["background", "panel", "text"],
`${where}.swatch`,
errors,
);
for (const key of ["background", "panel", "text"]) {
const color = raw.swatch[key];
if (typeof color !== "string" || !HEX_COLOR.test(color)) {
errors.push(`${where}.swatch.${key} must be a hex color`);
}
}
}
},
);
}
export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
@@ -2324,6 +2501,23 @@ export function parseManifest(raw: unknown): ParsedManifest {
);
}
if (
(manifest.contributes?.layouts?.length ?? 0) > 0 &&
!manifest.capabilities.includes("ui:layout")
) {
errors.push(
'contributes.layouts needs the "ui:layout" capability in the capabilities array',
);
}
if (
(manifest.contributes?.themes?.length ?? 0) > 0 &&
!manifest.capabilities.includes("ui:theme")
) {
errors.push(
'contributes.themes needs the "ui:theme" capability in the capabilities array',
);
}
for (const secret of manifest.requiresSecret ?? []) {
if (secret.plugin === manifest.id) {
errors.push(
+122
View File
@@ -0,0 +1,122 @@
/**
* SSH key helpers for plugins that make, check or deploy keys: generate a key
* pair, tell what a key is, derive a public key and check a pair belongs
* together. Pure functions over ssh2, which the host supplies. Core keeps its
* own copy for the connect pipeline.
*/
import ssh2 from "ssh2";
export type SshKeyKind = "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256";
export interface GeneratedSshKeyPair {
privateKey: string;
publicKey: string;
keyType: SshKeyKind;
}
export interface GenerateSshKeyPairOptions {
/** RSA only. 2048 to 8192, default 4096. */
bits?: number;
passphrase?: string;
comment?: string;
}
/** Makes a new key pair; the public half is one authorized_keys line. */
export function generateSshKeyPair(
type: SshKeyKind,
options: GenerateSshKeyPairOptions = {},
): GeneratedSshKeyPair {
const algorithm =
type === "ssh-rsa" ? "rsa" : type === "ssh-ed25519" ? "ed25519" : "ecdsa";
if (
type !== "ssh-rsa" &&
type !== "ssh-ed25519" &&
type !== "ecdsa-sha2-nistp256"
) {
throw new Error(`Unsupported key type: ${String(type)}`);
}
const generateOptions: Record<string, unknown> = {
comment: options.comment ?? "",
};
if (options.passphrase) {
generateOptions.passphrase = options.passphrase;
generateOptions.cipher = "aes256-ctr";
}
if (type === "ssh-rsa") {
const bits = options.bits ?? 4096;
if (!Number.isInteger(bits) || bits < 2048 || bits > 8192) {
throw new Error("RSA keys need 2048 to 8192 bits");
}
generateOptions.bits = bits;
}
if (type === "ecdsa-sha2-nistp256") generateOptions.bits = 256;
const pair = ssh2.utils.generateKeyPairSync(
algorithm as "rsa",
generateOptions as never,
);
return {
privateKey: pair.private,
publicKey: pair.public.trim(),
keyType: type,
};
}
function parse(key: string, passphrase?: string) {
const parsed = ssh2.utils.parseKey(key.trim(), passphrase || undefined);
const first = Array.isArray(parsed) ? parsed[0] : parsed;
if (!first || first instanceof Error) {
throw first instanceof Error ? first : new Error("Not a readable SSH key");
}
return first;
}
/** The key's algorithm, like "ssh-ed25519", or null when it cannot be read. */
export function detectSshKeyType(
key: string,
passphrase?: string,
): string | null {
try {
return parse(key, passphrase).type;
} catch {
return null;
}
}
/** The authorized_keys line for a private key, or null. */
export function publicKeyFromPrivate(
privateKey: string,
passphrase?: string,
): string | null {
try {
const parsed = parse(privateKey, passphrase);
return `${parsed.type} ${parsed.getPublicSSH().toString("base64")}`;
} catch {
return null;
}
}
/** Whether a public key line belongs to the private key. */
export function validateSshKeyPair(
privateKey: string,
publicKey: string,
passphrase?: string,
): { isValid: boolean; error?: string } {
try {
const priv = parse(privateKey, passphrase);
const pub = parse(publicKey);
const same = priv.getPublicSSH().equals(pub.getPublicSSH());
return same
? { isValid: true }
: {
isValid: false,
error: "The public key does not match the private key",
};
} catch (error) {
return {
isValid: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
+397 -2
View File
@@ -14,7 +14,10 @@ import {
PluginCapabilityError,
} from "./backend.js";
import type {
PluginAuditEntry,
PluginAuditFilters,
PluginContext,
PluginDesktopLocal,
PluginDisposables,
PluginLogger,
} from "./backend.js";
@@ -65,12 +68,19 @@ import type {
PluginSshKeyCredential,
PluginSshKeyCredentialInput,
} from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js";
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js";
import type {
PluginExternalClientRequest,
PluginProtocolTarget,
PluginHostProbeTarget,
PluginHostStatusEntry,
PluginActivityProvider,
PluginHostLockProvider,
PluginAuthPolicyProvider,
PluginCredentialSummary,
PluginHostShareGrant,
PluginHostStatusProvider,
PluginNotification,
PluginNotificationChannel,
PluginNotifyHub,
@@ -94,6 +104,8 @@ export interface FakeScheduledJob {
export interface FakeContextOptions {
pluginId?: string;
/** What ctx.sync.host answers, for a sync provider's tests. */
syncHost?: Partial<PluginSyncHost>;
manifest?: Partial<PluginManifest>;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
@@ -101,6 +113,8 @@ export interface FakeContextOptions {
settings?: Record<string, unknown>;
/** Seed for the core settings ctx.settings.readCore can reach. */
coreSettings?: Record<string, string>;
/** What ctx.files.dataDir() answers. Defaults to /tmp/<pluginId>. */
dataDir?: string;
/** What ctx.ssh.connect and withConnection hand back as the client. */
sshClient?: unknown;
/** A real database behind ctx.db, usually createTestDb().database. */
@@ -132,10 +146,22 @@ 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. */
desktopLocal?: PluginDesktopLocal;
/** The stored audit lines ctx.audit.list and exportBatches serve. */
auditEntries?: PluginAuditEntry[];
/** What ctx.hosts.status.get and check answer, by host id. */
hostStatuses?: Record<number, PluginHostStatusEntry>;
/** What ctx.hosts.probeTargets answers. */
probeTargets?: PluginHostProbeTarget[];
/** Host ids ctx.hosts.sessionActive answers true for. */
activeSessions?: number[];
/** What ctx.network.tcpProbe and ctx.ssh.probe answer. Defaults to false. */
reachable?: (target: string | number, port?: number) => boolean;
/** The acting user's channels, what ctx.notify.channels answers. */
notificationChannels?: PluginNotificationChannel[];
/**
@@ -172,6 +198,8 @@ export interface FakeContextOptions {
baseUrl?: string;
/** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */
linkedUsers?: Record<string, number>;
/** Second factors from other plugins that ctx.auth.listSecondFactors adds. */
secondFactors?: { id: string; pluginId: string }[];
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
loginAttemptLimit?: number;
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
@@ -251,6 +279,8 @@ export interface FakeAuthRegistrations {
loginFailures: Map<string, number>;
/** Every ctx.auth.issueUserToken call, in order. */
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
/** Providers passed to ctx.auth.policy.provide that are still registered. */
policies: PluginAuthPolicyProvider[];
}
export interface FakePluginContext {
@@ -263,6 +293,12 @@ export interface FakePluginContext {
) => Promise<Record<string, string>>;
/** Everything the plugin registered through ctx.disposables. */
disposals: Array<() => void | Promise<void>>;
/** Providers registered through ctx.hosts.status.provide. */
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. */
@@ -295,6 +331,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. */
@@ -338,6 +376,8 @@ export interface FakePluginContext {
challenges: Map<string, string>;
renewers: number;
};
/** Calls every ctx.audit.subscribe handler, like a line core just accepted. */
emitAudit: (entry: PluginAuditEntry) => void;
/** Every ctx.audit.record entry, in order. */
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
/** Every ctx.hosts.recordActivity call, in order. */
@@ -464,7 +504,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 };
@@ -491,6 +538,9 @@ export function createFakeContext(
const pluginId = options.pluginId ?? "test-plugin";
const disposals: Array<() => void | Promise<void>> = [];
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[] = [];
@@ -508,6 +558,7 @@ export function createFakeContext(
}>();
const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = [];
const hostShareSets: FakePluginContext["hostShareSets"] = [];
const desktopWindows: FakePluginContext["desktopWindows"] = [];
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
[];
@@ -531,6 +582,7 @@ export function createFakeContext(
};
const secretStore = new Map<string, string>();
const audits: FakePluginContext["audits"] = [];
const auditSubscribers = new Set<(entry: PluginAuditEntry) => void>();
const activities: FakePluginContext["activities"] = [];
const trackedSessions: number[] = [];
const interactions: FakePluginContext["interactions"] = [];
@@ -579,6 +631,7 @@ export function createFakeContext(
revokedSessions: [],
loginFailures: new Map(),
userTokens: [],
policies: [],
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
@@ -665,7 +718,7 @@ export function createFakeContext(
files: {
// A fake path; a test that needs a real folder uses the OS temp dir
// itself, not this double.
dataDir: async () => `/tmp/${pluginId}`,
dataDir: async () => options.dataDir ?? `/tmp/${pluginId}`,
},
db: options.db
@@ -705,6 +758,12 @@ export function createFakeContext(
registerEntity: (entity) => {
syncEntities.push(entity);
},
provide: () => () => {},
get host() {
if (!options.syncHost)
throw new Error("Pass syncHost to use ctx.sync.host");
return options.syncHost as PluginSyncHost;
},
},
registry: {
@@ -902,6 +961,27 @@ export function createFakeContext(
disposables,
hosts: {
importMany: async (rows) => {
const created = rows.map(() => nextHostId++);
return {
success: created.length,
updated: 0,
skipped: 0,
failed: 0,
errors: [],
createdHostIds: created,
};
},
importSshConfig: async () => ({
success: 0,
updated: 0,
skipped: 0,
failed: 0,
errors: [],
createdHostIds: [],
}),
exportMany: async () => ({ hosts: [] }),
registerImportNormalizer: () => () => {},
list: async () => [...hostsById.values()],
get: async (hostId) => hostsById.get(hostId) ?? null,
checkAccess: async (hostId): Promise<PluginHostAccess> => {
@@ -956,6 +1036,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) => {
@@ -971,7 +1055,42 @@ export function createFakeContext(
recordActivity: async (hostId, type, hostName) => {
activities.push({ hostId, type, hostName });
},
probeTargets: async (filter) =>
(options.probeTargets ?? []).filter(
(target) => !filter?.hostIds || filter.hostIds.includes(target.id),
),
sessionActive: (hostId) =>
options.activeSessions?.includes(hostId) ?? false,
activity: {
provide: (provider) => {
activityProviders.push(provider);
return () => {
const at = activityProviders.indexOf(provider);
if (at >= 0) activityProviders.splice(at, 1);
};
},
},
folders: {
ensure: async () => {},
removeEmpty: async () => true,
},
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);
return () => {
const at = hostStatusProviders.indexOf(provider);
if (at >= 0) hostStatusProviders.splice(at, 1);
};
},
get: async (hostId) => hostStatuses.get(hostId) ?? null,
check: async (hostId) => hostStatuses.get(hostId) ?? null,
reportLogin: (hostId, outcome) => {
@@ -996,12 +1115,46 @@ export function createFakeContext(
},
audit: {
subscribe: (handler) => {
auditSubscribers.add(handler);
return () => {
auditSubscribers.delete(handler);
};
},
list: async (listOptions) => {
const matching = filterAuditEntries(
options.auditEntries ?? [],
listOptions?.filters,
).reverse();
const offset = listOptions?.offset ?? 0;
return {
entries: matching.slice(offset, offset + (listOptions?.limit ?? 50)),
total: matching.length,
};
},
exportBatches: async function* (filters, batchSize) {
const matching = filterAuditEntries(
options.auditEntries ?? [],
filters,
);
const size = batchSize ?? 500;
for (let at = 0; at < matching.length; at += size) {
yield matching.slice(at, at + size);
}
},
record: async (entry) => {
audits.push({ ...entry });
},
},
network: {
tcpProbe: async (host, port) => options.reachable?.(host, port) ?? false,
},
ssh: {
transports: { provide: () => () => {} },
probe: async (hostId, probeOptions) =>
options.reachable?.(hostId, probeOptions?.port) ?? false,
connect: async (host, connectOptions) => {
sshConnections.push({ host, options: connectOptions });
return {
@@ -1013,6 +1166,16 @@ export function createFakeContext(
dispose: () => {},
};
},
exec: async (host) => {
sshConnections.push({ host });
return {
stdout: "",
stderr: "",
code: 0,
truncated: false,
timedOut: false,
};
},
withConnection: async (host, connectOptions, fn) => {
sshConnections.push({ host, pool: connectOptions.pool });
return fn(sshClient as never);
@@ -1153,6 +1316,22 @@ export function createFakeContext(
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
};
},
policy: {
provide: (provider) => {
auth.policies.push(provider);
return () => {
const at = auth.policies.indexOf(provider);
if (at >= 0) auth.policies.splice(at, 1);
};
},
},
listSecondFactors: () => [
...auth.secondFactors.map((factor) => ({
id: factor.id,
pluginId,
})),
...(options.secondFactors ?? []),
],
},
users: {
@@ -1169,9 +1348,11 @@ export function createFakeContext(
return { success: true };
},
available: () => options.desktopAvailable ?? false,
local: options.desktopLocal ?? unavailableDesktopLocal(),
},
credentials: {
list: async () => [...(options.credentialSummaries ?? [])],
listSshKeys: async () => [
...(options.sshKeyCredentials ?? []),
...createdSshKeys.map((key) => ({
@@ -1195,6 +1376,13 @@ export function createFakeContext(
credentialReads.push({ hostId, protocol });
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
},
publicKey: async (credentialId) =>
(options.sshKeyCredentials ?? []).find((k) => k.id === credentialId)
?.publicKey ?? null,
resolveKeyRefs: async (key, passphrase) => ({
key,
passphrase: passphrase ?? undefined,
}),
registerSecretResolver: (scheme, resolve) => {
auth.secretResolvers.set(scheme, resolve);
disposals.push(() => {
@@ -1345,6 +1533,9 @@ export function createFakeContext(
return errors;
},
disposals,
hostStatusProviders,
activityProviders,
hostLockProviders,
emitted,
kv,
tables,
@@ -1355,6 +1546,7 @@ export function createFakeContext(
coreSettings,
sshConnections,
hostShares,
hostShareSets,
auth,
desktopWindows,
externalClientLaunches,
@@ -1368,6 +1560,9 @@ export function createFakeContext(
tls,
secretStore,
audits,
emitAudit: (entry) => {
for (const handler of auditSubscribers) handler(entry);
},
activities,
trackedSessions,
interactions,
@@ -1400,6 +1595,8 @@ export function createFakeContext(
export interface MockContextOptions {
pluginId?: string;
/** What ctx.sync.host answers, for a sync provider's tests. */
syncHost?: Partial<PluginSyncHost>;
manifest?: Partial<PluginManifest>;
/** Capabilities the plugin is granted. Anything else throws. */
capabilities?: string[];
@@ -1409,6 +1606,8 @@ export interface MockContextOptions {
settings?: Record<string, unknown>;
/** Seed for the core settings readCore can reach. */
coreSettings?: Record<string, string>;
/** What ctx.files.dataDir() answers. */
dataDir?: string;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
/** What ctx.ssh.connect and withConnection hand back as the client. */
@@ -1432,10 +1631,22 @@ 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. */
desktopLocal?: PluginDesktopLocal;
/** See FakeContextOptions. */
auditEntries?: PluginAuditEntry[];
/** What ctx.hosts.status answers. See FakeContextOptions. */
hostStatuses?: Record<number, PluginHostStatusEntry>;
/** What ctx.hosts.probeTargets answers. */
probeTargets?: PluginHostProbeTarget[];
/** Host ids ctx.hosts.sessionActive answers true for. */
activeSessions?: number[];
/** What ctx.network.tcpProbe and ctx.ssh.probe answer. */
reachable?: (target: string | number, port?: number) => boolean;
/** The acting user's channels, what ctx.notify.channels answers. */
notificationChannels?: PluginNotificationChannel[];
/**
@@ -1460,6 +1671,8 @@ export interface MockContextOptions {
/** See FakeContextOptions. */
loginAttemptLimit?: number;
/** See FakeContextOptions. */
secondFactors?: FakeContextOptions["secondFactors"];
/** See FakeContextOptions. */
locales?: Record<string, string>;
}
@@ -1481,6 +1694,63 @@ export interface MockPluginContext extends FakePluginContext {
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
* ungated. As the SDK grows a member, add its gate here in the same shape.
*/
function filterAuditEntries(
entries: PluginAuditEntry[],
filters: PluginAuditFilters | undefined,
): PluginAuditEntry[] {
return entries.filter(
(entry) =>
(filters?.userId === undefined || entry.userId === filters.userId) &&
(filters?.action === undefined || entry.action === filters.action) &&
(filters?.resourceType === undefined ||
entry.resourceType === filters.resourceType) &&
(filters?.success === undefined || entry.success === filters.success) &&
(filters?.startDate === undefined ||
entry.timestamp >= filters.startDate) &&
(filters?.endDate === undefined || entry.timestamp <= filters.endDate),
);
}
function gateDesktopLocal(
target: PluginDesktopLocal,
check: () => void,
): PluginDesktopLocal {
const wrap = (value: unknown): unknown => {
if (typeof value === "function") {
return (...args: unknown[]) => {
check();
return (value as (...a: unknown[]) => unknown)(...args);
};
}
if (value && typeof value === "object") {
return new Proxy(value as object, {
get: (obj, key) => wrap((obj as Record<string | symbol, unknown>)[key]),
});
}
return value;
};
return new Proxy(target, {
get: (obj, key) =>
key === "available"
? obj.available
: wrap((obj as unknown as Record<string | symbol, unknown>)[key]),
});
}
function unavailableDesktopLocal(): PluginDesktopLocal {
const fail = (): never => {
throw new Error("ctx.desktop.local needs the desktopLocal test option");
};
const deep = (): unknown =>
new Proxy(() => fail(), { get: () => deep(), apply: () => fail() });
return {
available: () => false,
info: fail,
fs: deep(),
pty: deep(),
} as unknown as PluginDesktopLocal;
}
export function createMockCtx(
options: MockContextOptions = {},
): MockPluginContext {
@@ -1489,9 +1759,11 @@ export function createMockCtx(
const base = createFakeContext({
pluginId: options.pluginId,
syncHost: options.syncHost,
actor: options.actor,
settings: options.settings,
coreSettings: options.coreSettings,
dataDir: options.dataDir,
sshClient: options.sshClient,
db: options.db,
router: options.router,
@@ -1503,8 +1775,14 @@ export function createMockCtx(
services: options.services,
protocolTargets: options.protocolTargets,
sshKeyCredentials: options.sshKeyCredentials,
credentialSummaries: options.credentialSummaries,
desktopAvailable: options.desktopAvailable,
desktopLocal: options.desktopLocal,
auditEntries: options.auditEntries,
hostStatuses: options.hostStatuses,
probeTargets: options.probeTargets,
activeSessions: options.activeSessions,
reachable: options.reachable,
notificationChannels: options.notificationChannels,
fetch: options.fetch,
process: options.process,
@@ -1515,6 +1793,7 @@ export function createMockCtx(
baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers,
loginAttemptLimit: options.loginAttemptLimit,
secondFactors: options.secondFactors,
locales: options.locales,
manifest: {
capabilities: options.capabilities ?? [],
@@ -1650,6 +1929,23 @@ export function createMockCtx(
},
hosts: {
importMany: async (rows, importOptions) => {
require("hosts:write");
if (importOptions?.credentials?.length) require("credentials:write");
return ctx.hosts.importMany(rows, importOptions);
},
importSshConfig: async (content, importOptions) => {
require("hosts:write");
return ctx.hosts.importSshConfig(content, importOptions);
},
exportMany: async (exportOptions) => {
require("hosts:export");
return ctx.hosts.exportMany(exportOptions);
},
registerImportNormalizer: (normalizer) => {
require("hosts:write");
return ctx.hosts.registerImportNormalizer(normalizer);
},
list: async () => {
require("hosts:read");
return ctx.hosts.list();
@@ -1682,6 +1978,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();
@@ -1698,7 +1998,42 @@ export function createMockCtx(
require("hosts:read");
return ctx.hosts.recordActivity(hostId, type, hostName);
},
probeTargets: async (filter) => {
require("hosts:read");
return ctx.hosts.probeTargets(filter);
},
sessionActive: (hostId) => {
require("hosts:read");
return ctx.hosts.sessionActive(hostId);
},
activity: {
provide: (provider) => {
require("hosts:activity-provide");
return ctx.hosts.activity.provide(provider);
},
},
folders: {
ensure: async (name, style) => {
require("hosts:write");
return ctx.hosts.folders.ensure(name, style);
},
removeEmpty: async (name) => {
require("hosts:write");
return ctx.hosts.folders.removeEmpty(name);
},
},
locks: {
provide: (provider) => {
require("hosts:lock");
return ctx.hosts.locks.provide(provider);
},
},
status: {
provide: (provider) => {
require("hosts:read");
require("hosts:status-provide");
return ctx.hosts.status.provide(provider);
},
get: async (hostId) => {
require("hosts:read");
return ctx.hosts.status.get(hostId);
@@ -1720,11 +2055,26 @@ export function createMockCtx(
ssh: {
...ctx.ssh,
transports: {
provide: (provider) => {
require("ssh:transport");
return ctx.ssh.transports.provide(provider);
},
},
probe: async (hostId, probeOptions) => {
require("ssh:connect");
return ctx.ssh.probe(hostId, probeOptions);
},
connect: async (host, connectOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.connect(host, connectOptions);
},
exec: async (host, command, execOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.exec(host, command, execOptions);
},
withConnection: async (host, connectOptions, fn) => {
require("ssh:connect");
require("credentials:use");
@@ -1811,6 +2161,13 @@ export function createMockCtx(
require("users:impersonate");
return ctx.auth.issueUserToken(userId, tokenOptions);
},
policy: {
provide: (provider) => {
require("auth:policy");
return ctx.auth.policy.provide(provider);
},
},
listSecondFactors: () => ctx.auth.listSecondFactors(),
},
users: {
@@ -1820,6 +2177,22 @@ export function createMockCtx(
},
},
audit: {
record: (entry) => ctx.audit.record(entry),
subscribe: (handler) => {
require("audit:read");
return ctx.audit.subscribe(handler);
},
list: async (listOptions) => {
require("audit:read");
return ctx.audit.list(listOptions);
},
exportBatches: (filters, batchSize) => {
require("audit:read");
return ctx.audit.exportBatches(filters, batchSize);
},
},
desktop: {
openIsolatedWindow: async (request) => {
require("desktop:window");
@@ -1830,9 +2203,16 @@ export function createMockCtx(
return ctx.desktop.launchExternalClient(request);
},
available: () => ctx.desktop.available(),
local: gateDesktopLocal(ctx.desktop.local, () =>
require("desktop:local"),
),
},
credentials: {
list: async () => {
require("credentials:use");
return ctx.credentials.list();
},
listSshKeys: async () => {
require("credentials:use");
return ctx.credentials.listSshKeys();
@@ -1851,6 +2231,14 @@ export function createMockCtx(
}
return ctx.credentials.resolveHostProtocol(hostId, protocol);
},
publicKey: async (credentialId) => {
require("credentials:use");
return ctx.credentials.publicKey(credentialId);
},
resolveKeyRefs: async (key, passphrase) => {
require("credentials:read");
return ctx.credentials.resolveKeyRefs(key, passphrase);
},
registerSecretResolver: (scheme, resolve) => {
require("auth:provide");
ctx.credentials.registerSecretResolver(scheme, resolve);
@@ -1877,6 +2265,13 @@ export function createMockCtx(
return ctx.fetch(url, init);
},
network: {
tcpProbe: async (host, port, probeOptions) => {
require("network:probe");
return ctx.network.tcpProbe(host, port, probeOptions);
},
},
process: {
run: async (file, args, runOptions) => {
require("process:spawn");
+2
View File
@@ -97,6 +97,7 @@ const offline = () =>
useHostStatus: () => null,
useCurrentUser: () => null,
useTheme: () => ({ theme: "dark" }),
useShell: () => null,
toast: { success: noop, error: noop, info: noop, warning: noop },
getApi: () => ({
get: offline,
@@ -138,6 +139,7 @@ const offline = () =>
}),
hostProtocols: () => ["ssh"],
useHostActions: () => [],
useCompanions: () => [],
useActivityTypes: () => [],
activityTarget: () => undefined,
useExtensions: () => [],
+150
View File
@@ -0,0 +1,150 @@
/**
* Checks a theme's CSS before core serves it.
*
* A theme is colors and styles only, so its file may hold nothing but rules
* whose every selector starts with the theme's own class (`.nord`,
* `:root.nord`, `html.nord ...`), optionally inside @media or @supports.
* Anything that could load from elsewhere or run code is refused: @import,
* url() to another host, other at-rules, expression() and javascript:.
*/
const ALLOWED_BLOCK_AT_RULES = new Set(["media", "supports"]);
function stripComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
}
/** Splits on commas that are not inside parentheses or brackets. */
function splitSelectors(prelude: string): string[] {
const parts: string[] = [];
let depth = 0;
let current = "";
for (const ch of prelude) {
if (ch === "(" || ch === "[") depth += 1;
if (ch === ")" || ch === "]") depth -= 1;
if (ch === "," && depth === 0) {
parts.push(current);
current = "";
continue;
}
current += ch;
}
parts.push(current);
return parts.map((part) => part.trim());
}
function withoutStrings(css: string): string {
return css.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""');
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Index of the brace closing the one at `open`, skipping strings. */
function matchingBrace(css: string, open: number): number {
let depth = 0;
let quote: string | null = null;
for (let i = open; i < css.length; i += 1) {
const ch = css[i];
if (quote) {
if (ch === "\\") i += 1;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") quote = ch;
else if (ch === "{") depth += 1;
else if (ch === "}") {
depth -= 1;
if (depth === 0) return i;
}
}
return -1;
}
function checkUrls(css: string, errors: string[]): void {
const urls = css.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi);
for (const match of urls) {
const target = match[2].trim();
if (target.startsWith("data:")) continue;
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) {
errors.push(`url(${target}) points outside the plugin`);
}
}
}
function checkRules(
css: string,
scope: RegExp,
themeId: string,
errors: string[],
): void {
let index = 0;
while (index < css.length) {
const rest = css.slice(index);
if (rest.trim() === "") return;
const open = css.indexOf("{", index);
const semicolon = css.indexOf(";", index);
if (semicolon !== -1 && (open === -1 || semicolon < open)) {
const statement = css.slice(index, semicolon).trim();
errors.push(
statement.startsWith("@")
? `${statement.split(/\s/)[0]} is not allowed in a theme`
: `Unexpected "${statement}" outside a rule`,
);
index = semicolon + 1;
continue;
}
if (open === -1) {
errors.push(`Unexpected "${rest.trim()}" at the end`);
return;
}
const close = matchingBrace(css, open);
if (close === -1) {
errors.push("A rule is missing its closing brace");
return;
}
const prelude = css.slice(index, open).trim();
const body = css.slice(open + 1, close);
if (prelude.startsWith("@")) {
const name = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
if (ALLOWED_BLOCK_AT_RULES.has(name)) {
checkRules(body, scope, themeId, errors);
} else {
errors.push(`@${name} is not allowed in a theme`);
}
} else {
for (const selector of splitSelectors(prelude)) {
if (!scope.test(selector)) {
errors.push(
`Selector "${selector}" must start with .${themeId}, :root.${themeId} or html.${themeId}`,
);
}
}
if (withoutStrings(body).includes("{")) {
errors.push(`Nested rules are not allowed in "${prelude}"`);
}
}
index = close + 1;
}
}
/** Every problem with a theme's CSS, empty when it is safe to serve. */
export function validateThemeCss(css: string, themeId: string): string[] {
const errors: string[] = [];
const clean = stripComments(css);
if (/@import\b/i.test(clean))
errors.push("@import is not allowed in a theme");
if (/expression\s*\(/i.test(clean))
errors.push("expression() is not allowed in a theme");
if (/javascript:/i.test(clean))
errors.push("javascript: is not allowed in a theme");
if (/-moz-binding|behavior\s*:/i.test(clean))
errors.push("Script bindings are not allowed in a theme");
checkUrls(clean, errors);
const id = escapeRegExp(themeId);
const scope = new RegExp(`^(?::root|html)?\\.${id}(?![\\w-])`);
checkRules(clean, scope, themeId, errors);
return [...new Set(errors)];
}
+38 -3
View File
@@ -9,6 +9,7 @@
* Two projects: tests/backend runs in node, tests/frontend in jsdom.
*/
import { createRequire } from "node:module";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -117,9 +118,41 @@ export function pluginVitestConfig(
const plugins = [wasmBinaryPlugin()];
// A local SDK linked into node_modules sits outside the plugin folder, so
// Vite must be allowed to read it, and React must come from the plugin.
const sdkDir = fs.realpathSync(fileURLToPath(new URL("..", import.meta.url)));
const server = { fs: { allow: [pluginDir, sdkDir] } };
// dedupe alone is not enough: react-dom or the testing library loaded from
// the linked SDK's own node_modules pull in a second React, and hooks then
// see no renderer.
if (!sdkDir.startsWith(pluginDir)) {
const pluginRequire = createRequire(path.join(pluginDir, "package.json"));
for (const name of [
"react",
"react-dom",
"@testing-library/react",
"react-i18next",
"i18next",
]) {
if (alias[name]) continue;
try {
alias[name] = path.dirname(
pluginRequire.resolve(`${name}/package.json`),
);
} catch {
// Not installed in the plugin: leave it to normal resolution.
}
}
}
const resolve = {
alias,
dedupe: ["react", "react-dom", "react-i18next", "i18next"],
};
return {
plugins,
resolve: { alias },
server,
resolve,
test: {
globals: true,
setupFiles: [setupFile],
@@ -127,7 +160,8 @@ export function pluginVitestConfig(
projects: [
{
plugins,
resolve: { alias },
server,
resolve,
test: {
name: `${pluginId}:backend`,
root: pluginDir,
@@ -140,7 +174,8 @@ export function pluginVitestConfig(
},
{
plugins,
resolve: { alias },
server,
resolve,
test: {
name: `${pluginId}:frontend`,
root: pluginDir,