feat: implement plugin management UI

This commit is contained in:
LukeGus committed 2026-10-06 10:35:56 -05:00
1 parent 88d7bc5525
commit 3ed5775ed5
52 files changed
+20072 -41

No files matched your search

+2
View File
@@ -508,6 +508,7 @@ async function initializeCompleteDatabase(): Promise<void> {
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
auto_update INTEGER NOT NULL DEFAULT 0,
pinned_version TEXT,
manifest_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_plugins_registry_id ON plugins (registry_id);
@@ -1605,6 +1606,7 @@ const migrateSchema = () => {
// Plugin runtime: lastError reports why a plugin is blocked or failed, and
// grants record whether a capability came from an admin or from bundling.
addColumnIfNotExists("plugins", "last_error", "TEXT");
addColumnIfNotExists("plugins", "pinned_version", "TEXT");
addColumnIfNotExists(
"plugin_permission_grants",
"source",
+2
View File
@@ -1005,6 +1005,8 @@ export const plugins = mysqlTable(
autoUpdate: boolean("auto_update")
.notNull()
.default(false),
/** Version an admin pinned. Never auto-updated while set. */
pinnedVersion: text("pinned_version"),
manifestJson: text("manifest_json").notNull(),
},
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
+2
View File
@@ -1005,6 +1005,8 @@ export const plugins = pgTable(
autoUpdate: boolean("auto_update")
.notNull()
.default(false),
/** Version an admin pinned. Never auto-updated while set. */
pinnedVersion: text("pinned_version"),
manifestJson: text("manifest_json").notNull(),
},
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
+2
View File
@@ -1003,6 +1003,8 @@ export const plugins = sqliteTable(
autoUpdate: integer("auto_update", { mode: "boolean" })
.notNull()
.default(false),
/** Version an admin pinned. Never auto-updated while set. */
pinnedVersion: text("pinned_version"),
manifestJson: text("manifest_json").notNull(),
},
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
@@ -20,6 +20,7 @@ export interface PluginCreateInput {
state?: string;
lastError?: string | null;
autoUpdate?: boolean;
pinnedVersion?: string | null;
manifestJson: string;
}
@@ -32,6 +33,7 @@ export interface PluginUpdateInput {
state?: string;
lastError?: string | null;
autoUpdate?: boolean;
pinnedVersion?: string | null;
manifestJson?: string;
}
@@ -68,6 +70,7 @@ export class PluginRepository {
state: input.state ?? "disabled",
lastError: input.lastError ?? null,
autoUpdate: input.autoUpdate ?? false,
pinnedVersion: input.pinnedVersion ?? null,
manifestJson: input.manifestJson,
installedAt: now,
updatedAt: now,
@@ -101,6 +104,10 @@ export class PluginRepository {
lastError:
input.lastError === undefined ? existing.lastError : input.lastError,
autoUpdate: input.autoUpdate ?? existing.autoUpdate,
pinnedVersion:
input.pinnedVersion === undefined
? existing.pinnedVersion
: input.pinnedVersion,
manifestJson: input.manifestJson ?? existing.manifestJson,
updatedAt: now,
},
@@ -178,6 +178,28 @@ export class PluginSettingsRepository {
return affected;
}
/** How many stored values the plugin has in each scope. */
async countByScope(
pluginId: string,
): Promise<Record<PluginSettingsScope, number>> {
const rows = await this.context.drizzle
.select({ scope: pluginSettings.scope })
.from(pluginSettings)
.where(eq(pluginSettings.pluginId, pluginId));
const counts: Record<PluginSettingsScope, number> = {
admin: 0,
user: 0,
host: 0,
secret: 0,
};
for (const row of rows) {
const scope = row.scope as PluginSettingsScope;
if (scope in counts) counts[scope] += 1;
}
return counts;
}
async deleteByPlugin(pluginId: string): Promise<number> {
const result = await this.context.drizzle
.delete(pluginSettings)
+540 -32
View File
@@ -17,6 +17,7 @@ import { describePluginFrontend } from "../../plugins/assets.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
import { PluginManageError } from "../../plugins/manage.js";
import {
findField,
getAllSettings,
@@ -247,11 +248,17 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
let icon: string | undefined;
let dependencies: Record<string, string> = {};
let optionalDependencies: Record<string, string> = {};
let description: string | undefined;
let author: string | undefined;
let repository: string | undefined;
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
capabilities?: unknown;
icon?: unknown;
description?: unknown;
author?: unknown;
repository?: unknown;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
@@ -262,6 +269,17 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
dependencies = manifest?.dependencies ?? {};
optionalDependencies = manifest?.optionalDependencies ?? {};
description =
typeof manifest?.description === "string"
? manifest.description
: undefined;
const rawAuthor = manifest?.author as { name?: unknown } | undefined;
author =
typeof rawAuthor?.name === "string" ? rawAuthor.name : undefined;
repository =
typeof manifest?.repository === "string"
? manifest.repository
: undefined;
} catch {
contributes = null;
}
@@ -285,6 +303,13 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
...summary,
tier: record.tier,
source: record.source,
registryId: record.registryId ?? null,
autoUpdate: Boolean(record.autoUpdate),
pinnedVersion: record.pinnedVersion ?? null,
description,
author,
repository,
signedBy: loaded?.signedBy ?? null,
capabilities,
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
lastError: loaded?.lastError ?? record.lastError ?? null,
@@ -315,12 +340,70 @@ async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
return isServerManaged(pluginId);
}
async function auditPluginAction(
req: Request,
action: string,
pluginId: string,
details: Record<string, unknown> = {},
): Promise<void> {
const userId = (req as AuthenticatedRequest).userId as string;
const { ipAddress, userAgent } = getRequestMeta(req);
try {
await logAudit({
userId,
username: await getAuditUsername(userId),
action,
resourceType: "plugin",
resourceId: pluginId,
resourceName: pluginId,
details: JSON.stringify(details),
ipAddress,
userAgent,
success: true,
});
} catch {
// An audit failure must not undo a change that already happened.
}
}
function sendManageError(
res: Response,
error: unknown,
fallback: string,
operation: string,
): void {
if (error instanceof PluginManageError) {
res.status(error.status).json({
error: error.message,
code: error.code,
...(error.details ?? {}),
});
return;
}
databaseLogger.error(
fallback,
error instanceof Error ? error : new Error(String(error)),
{ operation },
);
res.status(500).json({ error: fallback });
}
async function refuseOnLinkedDesktop(res: Response): Promise<boolean> {
const { isLinkedDesktop } = await import("../../plugins/manage.js");
if (!(await isLinkedDesktop())) return false;
res.status(409).json({
error: "Plugins on a linked desktop are managed by the server",
code: "MANAGED_BY_SERVER",
});
return true;
}
/**
* @openapi
* /plugins/{id}/state:
* patch:
* summary: Enable or disable a plugin
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on.
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would.
* tags:
* - Plugins
* parameters:
@@ -329,6 +412,10 @@ async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
* required: true
* schema:
* type: string
* - in: query
* name: dryRun
* schema:
* type: boolean
* requestBody:
* required: true
* content:
@@ -364,8 +451,7 @@ router.patch(
}
try {
const repository = createCurrentPluginRepository();
const record = await repository.findById(pluginId);
const record = await createCurrentPluginRepository().findById(pluginId);
if (!record) {
res.status(404).json({ error: "Plugin not found" });
return;
@@ -380,33 +466,42 @@ router.patch(
return;
}
// Persist first: if the start or stop below throws, the recorded
// intent still matches what the user asked for, and the next boot
// acts on it rather than silently reverting.
await repository.update(pluginId, {
state: enabled ? "enabled" : "disabled",
lastError: null,
});
const { planStateChange, setPluginState } =
await import("../../plugins/manage.js");
const { activatePlugin, deactivatePlugin } =
await import("../../plugins/index.js");
if (req.query.dryRun === "1" || req.query.dryRun === "true") {
res.json({
id: pluginId,
enabled,
...(await planStateChange(pluginId, enabled)),
});
return;
}
if (enabled) await activatePlugin(pluginId);
else await deactivatePlugin(pluginId);
const result = await setPluginState(pluginId, enabled);
await auditPluginAction(
req,
enabled ? "enable_plugin" : "disable_plugin",
pluginId,
{
enable: result.enable,
disable: result.disable,
},
);
databaseLogger.info(
`Plugin ${pluginId} ${enabled ? "enabled" : "disabled"}`,
{ operation: "plugin_state_change", pluginId },
);
res.json({ id: pluginId, enabled });
res.json({ id: pluginId, enabled, ...result });
} catch (error) {
databaseLogger.error(
`Failed to change state for plugin ${pluginId}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_state_change" },
sendManageError(
res,
error,
"Failed to change plugin state",
"plugin_state_change",
);
res.status(500).json({ error: "Failed to change plugin state" });
}
},
);
@@ -668,7 +763,7 @@ router.delete(
* /plugins/{id}/data:
* delete:
* summary: Delete everything a plugin stores
* description: Drops every table under the plugin's prefix and clears its key/value state, settings and secrets, capability grants and migration ledger. Role permissions stay. Disabling a plugin never touches its data; this is the explicit uninstall path, and it cannot be undone. The plugin is deactivated first so nothing is writing while its tables go.
* description: Drops every table under the plugin's prefix and clears its key/value state, settings and secrets, migration ledger and files. Role permissions stay, and the capabilities the admin agreed to are granted again. The plugin stays installed but is switched off, so nothing is writing while its tables go. It cannot be undone.
* tags:
* - Plugins
* parameters:
@@ -700,19 +795,18 @@ router.delete(
return;
}
// Stopped first: dropping a table out from under a running plugin turns
// its next query into an error rather than a clean shutdown.
const { loader } = getPluginRuntime();
if (loader.get(pluginId)?.state === "active") {
await loader.deactivate(pluginId);
if (await isManagedByLinkedServer(pluginId)) {
res.status(409).json({
error: "Managed by the linked server",
code: "MANAGED_BY_SERVER",
});
return;
}
const { removePluginData } = await import("../../plugins/data.js");
const removed = await removePluginData(pluginId, {
knownPluginIds: getPluginRuntime()
.loader.list()
.map((plugin) => plugin.id),
});
// The plugin is stopped first: dropping a table out from under a
// running plugin turns its next query into an error.
const { deletePluginData } = await import("../../plugins/manage.js");
const removed = await deletePluginData(pluginId, userId ?? null);
databaseLogger.warn(`Removed all data for plugin ${pluginId}`, {
operation: "plugin_remove_data",
@@ -721,6 +815,10 @@ router.delete(
tables: removed.tables.length,
kvKeys: removed.kvKeys,
});
await auditPluginAction(req, "delete_plugin_data", pluginId, {
tables: removed.tables.length,
kvKeys: removed.kvKeys,
});
res.json({ id: pluginId, removed });
} catch (error) {
@@ -1299,4 +1397,414 @@ router.put(
},
);
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
function readVersion(body: unknown): string | undefined | null {
const version = (body as { version?: unknown } | undefined)?.version;
if (version === undefined || version === null || version === "") {
return undefined;
}
return typeof version === "string" && version.length <= 64 ? version : null;
}
/**
* @openapi
* /plugins/registry:
* get:
* summary: List the plugins in the official registry
* description: >
* Fetches the official registry index (cached for 15 minutes unless
* refresh is set) and merges it with what is installed here: the
* installed version, the newest release this build can run, whether an
* update asks for new capabilities, and the pin and auto-update choices.
* tags:
* - Plugins
* parameters:
* - in: query
* name: refresh
* schema:
* type: boolean
* responses:
* 200:
* description: The registry listing.
* 403:
* description: The caller lacks admin.plugins.manage.
* 502:
* description: The registry could not be reached.
*/
router.get(
"/registry",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
try {
const { listRegistry } = await import("../../plugins/manage.js");
const force = req.query.refresh === "1" || req.query.refresh === "true";
res.json(await listRegistry({ force }));
} catch (error) {
databaseLogger.warn("Plugin registry fetch failed", {
operation: "plugin_registry",
error: error instanceof Error ? error.message : String(error),
});
res.status(502).json({
error: "Could not reach the plugin registry",
code: "REGISTRY_UNREACHABLE",
});
}
},
);
/**
* @openapi
* /plugins/update-all:
* post:
* summary: Update every plugin that has a newer release
* description: Skips pinned plugins and any update that asks for new capabilities, which are returned under needsReview for the admin to look at one by one.
* tags:
* - Plugins
* responses:
* 200:
* description: What was updated, what needs review and what failed.
* 403:
* description: The caller lacks admin.plugins.manage.
* 409:
* description: This desktop is linked to a server, which manages its plugins.
*/
router.post(
"/update-all",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
if (await refuseOnLinkedDesktop(res)) return;
try {
const { updateAllPlugins } = await import("../../plugins/manage.js");
const userId = (req as AuthenticatedRequest).userId as string;
const result = await updateAllPlugins({ userId });
for (const done of result.updated) {
await auditPluginAction(req, "update_plugin", done.id, {
version: done.version,
});
}
res.json(result);
} catch (error) {
sendManageError(res, error, "Failed to update plugins", "plugin_update");
}
},
);
/**
* @openapi
* /plugins/{id}/install:
* post:
* summary: Install a plugin from the official registry
* description: >
* Downloads the release, checks its sha256 and signature against a key
* this build trusts, loads it, grants the capabilities it declares and
* starts it along with any installed dependency that is off. Without a
* version the newest compatible release is used; naming an older one
* pins the plugin there. A bundled plugin at its shipped version needs
* no download.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* version:
* type: string
* responses:
* 200:
* description: The installed version and the plugin's state.
* 400:
* description: Invalid id or version.
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
* description: Not in the official registry.
* 409:
* description: Already installed, incompatible, or managed by a linked server.
* 502:
* description: The registry could not be reached.
*/
router.post(
"/:id/install",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
const version = readVersion(req.body);
if (!ID_PATTERN.test(pluginId) || version === null) {
res.status(400).json({ error: "Invalid plugin id or version" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
try {
const { installPlugin } = await import("../../plugins/manage.js");
const userId = (req as AuthenticatedRequest).userId as string;
const result = await installPlugin(pluginId, { version, userId });
await auditPluginAction(req, "install_plugin", pluginId, {
version: result.version,
});
res.json(result);
} catch (error) {
sendManageError(
res,
error,
"Failed to install the plugin",
"plugin_install",
);
}
},
);
/**
* @openapi
* /plugins/{id}/update:
* post:
* summary: Update or change the installed version of a plugin
* description: >
* Swaps in another release from the official registry, newer or older.
* If the release declares capabilities the installed one does not, the
* request must set acceptCapabilities, otherwise it answers 409 with
* code CAPABILITIES_ADDED and the list. A running plugin is started
* again on the new version. Naming a version that is not the newest
* pins the plugin to it.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* version:
* type: string
* acceptCapabilities:
* type: boolean
* responses:
* 200:
* description: The new version and the plugin's state.
* 400:
* description: Invalid id or version.
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
* description: Not installed, or the version is not in the registry.
* 409:
* description: Same version, new capabilities need consent, or managed by a linked server.
*/
router.post(
"/:id/update",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
const version = readVersion(req.body);
if (!ID_PATTERN.test(pluginId) || version === null) {
res.status(400).json({ error: "Invalid plugin id or version" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
try {
const { updatePlugin } = await import("../../plugins/manage.js");
const userId = (req as AuthenticatedRequest).userId as string;
const result = await updatePlugin(pluginId, {
version,
userId,
acceptCapabilities: req.body?.acceptCapabilities === true,
});
await auditPluginAction(req, "update_plugin", pluginId, {
version: result.version,
});
res.json(result);
} catch (error) {
sendManageError(
res,
error,
"Failed to update the plugin",
"plugin_update",
);
}
},
);
/**
* @openapi
* /plugins/{id}:
* delete:
* summary: Uninstall a plugin
* description: >
* Stops the plugin, drops its tables, settings, secrets, grants and
* files, and removes it. A plugin that ships with Termix keeps its
* files in the image but is marked uninstalled and never loads again
* until it is installed from the Plugins tab. Refused while a running
* plugin depends on it.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: What was removed.
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin.
* 409:
* description: Other running plugins depend on it, or managed by a linked server.
*/
router.delete(
"/:id",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
if (!ID_PATTERN.test(pluginId)) {
res.status(400).json({ error: "Invalid plugin id" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
try {
const { uninstallPlugin } = await import("../../plugins/manage.js");
const result = await uninstallPlugin(pluginId);
await auditPluginAction(req, "uninstall_plugin", pluginId, {
tables: result.removed.tables.length,
});
res.json(result);
} catch (error) {
sendManageError(
res,
error,
"Failed to uninstall the plugin",
"plugin_uninstall",
);
}
},
);
/**
* @openapi
* /plugins/{id}/options:
* patch:
* summary: Change a plugin's update choices
* description: autoUpdate lets the background check apply new releases that ask for nothing new. pinned holds the plugin at its installed version, which also stops auto-update.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* autoUpdate:
* type: boolean
* pinned:
* type: boolean
* responses:
* 200:
* description: The saved choices.
* 400:
* description: Invalid request body.
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin.
*/
router.patch(
"/:id/options",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
const { autoUpdate, pinned } = req.body ?? {};
if (
(autoUpdate !== undefined && typeof autoUpdate !== "boolean") ||
(pinned !== undefined && typeof pinned !== "boolean")
) {
res.status(400).json({ error: "autoUpdate and pinned must be booleans" });
return;
}
try {
const { setPluginOptions } = await import("../../plugins/manage.js");
const result = await setPluginOptions(pluginId, { autoUpdate, pinned });
await auditPluginAction(req, "update_plugin_options", pluginId, result);
res.json({ id: pluginId, ...result });
} catch (error) {
sendManageError(
res,
error,
"Failed to save plugin options",
"plugin_options",
);
}
},
);
/**
* @openapi
* /plugins/{id}/data:
* get:
* summary: Summarize what a plugin stores
* description: The plugin's tables with row counts, key/value count, stored settings per scope, applied migrations, capability grants and the size of its files folder. No values are returned.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The summary.
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin.
*/
router.get(
"/:id/data",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
try {
const { getPluginDataSummary } = await import("../../plugins/manage.js");
res.json({ id: pluginId, ...(await getPluginDataSummary(pluginId)) });
} catch (error) {
sendManageError(
res,
error,
"Failed to read the plugin's data",
"plugin_data",
);
}
},
);
export default router;
+64
View File
@@ -0,0 +1,64 @@
/**
* Applies plugin updates on a timer for plugins with auto-update on. An
* update that asks for a new capability is never applied here; admins get
* an alert and review it in the Plugins tab.
*/
import { pluginLogger } from "../utils/logger.js";
const FIRST_CHECK_MS = 2 * 60_000;
const CHECK_EVERY_MS = 6 * 60 * 60_000;
let timer: NodeJS.Timeout | null = null;
export async function runPluginAutoUpdate(): Promise<void> {
const { isLinkedDesktop, updateAllPlugins } = await import("./manage.js");
if (await isLinkedDesktop()) return;
const result = await updateAllPlugins({ userId: null, onlyAutoUpdate: true });
for (const done of result.updated) {
pluginLogger.info(`Auto-updated plugin ${done.id} to ${done.version}`, {
operation: "plugin_auto_update",
});
}
for (const failed of result.failed) {
pluginLogger.warn(`Auto-update of ${failed.id} failed: ${failed.error}`, {
operation: "plugin_auto_update",
});
}
if (result.needsReview.length === 0) return;
const { sendCoreAlert } = await import("../notify/core-notify.js");
for (const entry of result.needsReview) {
await sendCoreAlert({
title: `A plugin update needs review`,
body: `The new version of ${entry.id} asks for more access, so it was not installed on its own. Review it in Plugins.`,
severity: "info",
category: "termix.plugin-update",
dedupeKey: `plugin-review:${entry.id}:${entry.capabilities.join(",")}`,
audience: "admins",
});
}
}
export function startPluginAutoUpdate(): void {
if (timer) return;
const run = () =>
void runPluginAutoUpdate().catch((error) => {
pluginLogger.warn(
`Plugin auto-update check failed: ${
error instanceof Error ? error.message : String(error)
}`,
{ operation: "plugin_auto_update" },
);
});
setTimeout(run, FIRST_CHECK_MS).unref?.();
timer = setInterval(run, CHECK_EVERY_MS);
timer.unref?.();
}
export function stopPluginAutoUpdate(): void {
if (timer) clearInterval(timer);
timer = null;
}
+28
View File
@@ -263,6 +263,34 @@ export async function removePluginData(
return { tables: dropped, kvKeys, migrations };
}
/** The plugin's tables in the live database, with how many rows each holds. */
export async function describePluginTables(
pluginId: string,
options: { knownPluginIds?: string[]; dialect?: DatabaseDialect } = {},
): Promise<Array<{ name: string; rows: number | null }>> {
const dialect = options.dialect ?? resolveDatabaseDialect();
const tables = ownedTableNames(
pluginId,
await listDatabaseTables(dialect),
options.knownPluginIds ?? [],
).sort();
const { selectRows } = await import("../utils/crypto-migration/raw-rows.js");
const result: Array<{ name: string; rows: number | null }> = [];
for (const table of tables) {
const quoted = dialect === "mysql" ? "`" + table + "`" : `"${table}"`;
try {
const [row] = await selectRows<Record<string, unknown>>(
sql.raw(`SELECT COUNT(*) AS count FROM ${quoted}`),
);
result.push({ name: table, rows: Number(row?.count ?? row?.COUNT ?? 0) });
} catch {
result.push({ name: table, rows: null });
}
}
return result;
}
/**
* The tables in `all` that belong to this plugin: its prefix, minus any that
* sit under a longer prefix another plugin owns.
+42
View File
@@ -0,0 +1,42 @@
import fs from "node:fs";
// Windows refuses a rename while anything holds a file open, and antivirus
// or the search indexer often does for a moment right after files are
// written. These are worth waiting out.
const TRANSIENT = new Set(["EPERM", "EACCES", "EBUSY"]);
const DELAYS_MS = [50, 100, 200, 400, 800, 1600];
function isTransient(error: unknown): boolean {
return TRANSIENT.has((error as NodeJS.ErrnoException)?.code ?? "");
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Renames, retrying while the error looks like a passing lock. A directory
* that still cannot be renamed is copied and the source removed instead.
*/
export async function moveWithRetry(
from: string,
to: string,
delays: readonly number[] = DELAYS_MS,
): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt <= delays.length; attempt++) {
try {
await fs.promises.rename(from, to);
return;
} catch (error) {
if (!isTransient(error)) throw error;
lastError = error;
if (attempt < delays.length) await sleep(delays[attempt]);
}
}
const stat = await fs.promises.stat(from).catch(() => null);
if (!stat) throw lastError;
await fs.promises.cp(from, to, { recursive: true, force: true });
await fs.promises
.rm(from, { recursive: true, force: true, maxRetries: 5 })
.catch(() => {});
}
+11
View File
@@ -215,6 +215,9 @@ async function syncCapabilityGrants(loaded: LoadedPlugin[]): Promise<void> {
export async function initializePlugins(): Promise<LoadedPlugin[]> {
const { loader: pluginLoader } = getPluginRuntime();
const { applyStoredPluginChoices } = await import("./manage.js");
await applyStoredPluginChoices(pluginLoader);
const loaded = await pluginLoader.loadAll();
if (loaded.length === 0) return [];
@@ -567,6 +570,14 @@ export async function installPluginArtifact(
return plugin;
}
/** Gives freshly loaded plugins a row and their bundled grants. */
export async function registerLoadedPlugins(
loaded: LoadedPlugin[],
): Promise<void> {
await seedPlugins(loaded);
await syncCapabilityGrants(loaded);
}
/** Stops a plugin and forgets it, before a new version is installed. */
export async function unloadPlugin(pluginId: string): Promise<void> {
const { loader: pluginLoader } = getPluginRuntime();
+54 -4
View File
@@ -29,6 +29,7 @@ import {
getUnpackedPluginsDir,
} from "./paths.js";
import { requireSignedPlugins, verifyPluginArtifact } from "./trust.js";
import { moveWithRetry } from "./fs-retry.js";
import {
createPluginContext,
createPluginHandle,
@@ -90,6 +91,14 @@ export class PluginLoader {
private readonly bundledVersions = new Map<string, string>();
/** Activation order, so shutdown can run it backwards. */
private activationOrder: string[] = [];
/**
* Bundled plugins an admin uninstalled. The shipped copy is ignored as if
* it were not there, so a reinstall from the registry loads as a normal
* download and a recreated image cannot bring the old copy back.
*/
private uninstalled = new Set<string>();
/** Versions an admin pinned, which may sit below the bundled copy. */
private pinned = new Map<string, string>();
constructor(private readonly options: PluginLoaderOptions = {}) {}
@@ -111,6 +120,33 @@ export class PluginLoader {
]);
}
setUninstalled(ids: Iterable<string>): void {
this.uninstalled = new Set(ids);
for (const id of this.uninstalled) this.bundledVersions.delete(id);
}
setPinned(pins: Map<string, string>): void {
this.pinned = new Map(pins);
}
/** Version of the copy that ships in the image, loaded or not. */
bundledVersion(pluginId: string): string | undefined {
return this.bundledVersions.get(pluginId);
}
/** Loads the shipped copy of a bundled plugin, e.g. after a reinstall. */
async loadBundled(pluginId: string): Promise<LoadedPlugin> {
if (!PLUGIN_ID_PATTERN.test(pluginId)) {
throw new Error(`invalid plugin id "${pluginId}"`);
}
const plugin = await this.load(
path.join(getBundledPluginsDir(), pluginId),
"bundled",
);
this.bundledVersions.set(plugin.id, plugin.manifest.version);
return plugin;
}
/** Drops a stopped plugin from the list, so a new copy can load in its place. */
forget(pluginId: string): void {
const plugin = this.plugins.get(pluginId);
@@ -231,7 +267,10 @@ export class PluginLoader {
const entries = await fs.promises.readdir(root, { withFileTypes: true });
if (source === "bundled") {
for (const entry of entries) {
if (entry.isDirectory()) bundledIds.add(entry.name);
if (!entry.isDirectory() || this.uninstalled.has(entry.name)) {
continue;
}
bundledIds.add(entry.name);
}
}
@@ -244,6 +283,7 @@ export class PluginLoader {
if (entry.isDirectory() && entry.name.startsWith(".")) continue;
const dir = path.join(root, entry.name);
if (source === "bundled" && this.uninstalled.has(entry.name)) continue;
try {
if (isArtifact) {
const plugin = await this.loadArtifact(dir, bundledIds);
@@ -355,6 +395,7 @@ export class PluginLoader {
raw?.version,
signedBy,
this.bundledVersions.get(id) ?? replaced?.manifest.version,
this.pinned.get(id),
);
} else if (this.plugins.has(id)) {
throw new Error(`another plugin already uses the id "${id}"`);
@@ -364,8 +405,12 @@ export class PluginLoader {
}
const target = path.join(unpackedRoot, id);
await fs.promises.rm(target, { recursive: true, force: true });
await fs.promises.rename(staging, target);
await fs.promises.rm(target, {
recursive: true,
force: true,
maxRetries: 5,
});
await moveWithRetry(staging, target);
if (replaced) this.plugins.delete(id);
try {
@@ -547,8 +592,10 @@ export class PluginLoader {
// than leave it half-running against a schema that is not there. A
// throw here lands in the catch below, which fails this plugin only.
const { migratePlugin } = await import("./data.js");
// A release signed by a trusted key is official whether it shipped in
// the image or was downloaded, so it may adopt its 2.8 tables too.
const applied = await migratePlugin(plugin.id, plugin.dir, {
bundled: plugin.source === "bundled",
bundled: plugin.source === "bundled" || Boolean(plugin.signedBy),
});
// A new table may be where core data is waiting to move.
if (applied.length > 0) {
@@ -776,6 +823,7 @@ export function assertBundledUpdate(
version: unknown,
signedBy: string | undefined,
bundledVersion: string | undefined,
pinnedVersion?: string,
): void {
if (!signedBy) {
throw new Error(
@@ -785,6 +833,8 @@ export function assertBundledUpdate(
if (typeof version !== "string" || !semver.valid(version)) {
throw new Error(`update for "${id}" has no valid version`);
}
// An admin pinning an exact older release is the one allowed downgrade.
if (pinnedVersion && pinnedVersion === version) return;
if (bundledVersion && !semver.gt(version, bundledVersion)) {
throw new Error(
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -112,8 +112,9 @@ export function readMigrations(
*
* A plugin owns the p_<id>_ namespace and nothing else. Enforced here as well
* as in the CLI, because a plugin installed from a tarball never ran the CLI.
* A legacy core table is allowed only for the bundled plugin that adopts it:
* a user plugin with the same id gets no exemption.
* A legacy core table is allowed only for the official plugin that adopts
* it, bundled or signed by a trusted key: an unsigned plugin with the same id
* gets no exemption.
*/
export function assertOwnedTables(
pluginId: string,
+314
View File
@@ -0,0 +1,314 @@
/**
* The official plugin registry: fetching its index and downloading a release.
*
* The index is a plain JSON file in Termix-SSH/Termix-Registry. Nothing in it
* is trusted on its own: every download is checked against the sha256 and
* Ed25519 signature listed for it, and the signature must come from a key
* pinned in trust.ts. A tampered index can at most point at a file that then
* fails the check.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import semver from "semver";
import { SUPPORTED_PLUGIN_API_VERSION } from "@termix-ssh/plugin-sdk/manifest";
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
import { pluginLogger } from "../utils/logger.js";
import { verifyPluginArtifact } from "./trust.js";
import { getPluginsDir } from "./paths.js";
export const OFFICIAL_REGISTRY_ID = "official";
export const OFFICIAL_REGISTRY_URL =
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/index.json";
const CACHE_MS = 15 * 60_000;
const MAX_INDEX_BYTES = 5 * 1024 * 1024;
const MAX_ARTIFACT_BYTES = 200 * 1024 * 1024;
const MAX_REDIRECTS = 5;
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
export interface RegistryVersion {
version: string;
api: string;
url: string;
sha256: string;
signature: string;
size: number;
capabilities: string[];
releaseNotesUrl?: string;
publishedAt?: string;
}
export interface RegistryPlugin {
id: string;
name: string;
description: string;
author: string;
category: string;
repository?: string;
icon?: string;
/** Newest first. */
versions: RegistryVersion[];
}
export interface RegistryIndex {
registry: string;
name: string;
updatedAt?: string;
plugins: RegistryPlugin[];
}
export function getRegistryUrl(): string {
return process.env.TERMIX_PLUGIN_REGISTRY_URL || OFFICIAL_REGISTRY_URL;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function parseVersion(raw: unknown): RegistryVersion | null {
if (!raw || typeof raw !== "object") return null;
const entry = raw as Record<string, unknown>;
const version = asString(entry.version);
const url = asString(entry.url);
const sha256 = asString(entry.sha256);
const signature = asString(entry.signature);
if (!version || !semver.valid(version) || !url || !sha256 || !signature) {
return null;
}
if (!/^[a-f0-9]{64}$/i.test(sha256)) return null;
try {
if (new URL(url).protocol !== "https:") return null;
} catch {
return null;
}
return {
version,
api: asString(entry.api) ?? "",
url,
sha256: sha256.toLowerCase(),
signature,
size: typeof entry.size === "number" ? entry.size : 0,
capabilities: Array.isArray(entry.capabilities)
? entry.capabilities.filter((c): c is string => typeof c === "string")
: [],
releaseNotesUrl: asString(entry.releaseNotesUrl),
publishedAt: asString(entry.publishedAt),
};
}
/** Validates a fetched index. Malformed entries are dropped, not fatal. */
export function parseRegistryIndex(raw: unknown): RegistryIndex {
if (!raw || typeof raw !== "object") {
throw new Error("registry index is not an object");
}
const index = raw as Record<string, unknown>;
if (!Array.isArray(index.plugins)) {
throw new Error("registry index has no plugins list");
}
const plugins: RegistryPlugin[] = [];
for (const item of index.plugins) {
if (!item || typeof item !== "object") continue;
const entry = item as Record<string, unknown>;
const id = asString(entry.id);
if (!id || !ID_PATTERN.test(id)) continue;
const versions = (Array.isArray(entry.versions) ? entry.versions : [])
.map(parseVersion)
.filter((v): v is RegistryVersion => v !== null)
.sort((a, b) => semver.rcompare(a.version, b.version));
if (versions.length === 0) continue;
plugins.push({
id,
name: asString(entry.name) ?? id,
description: asString(entry.description) ?? "",
author: asString(entry.author) ?? "",
category: asString(entry.category) ?? "",
repository: asString(entry.repository),
icon: asString(entry.icon),
versions,
});
}
return {
registry: asString(index.registry) ?? OFFICIAL_REGISTRY_ID,
name: asString(index.name) ?? "",
updatedAt: asString(index.updatedAt),
plugins,
};
}
/** Whether this build can run a release built against `api`. */
export function isApiCompatible(api: string): boolean {
const major = semver.coerce(api)?.major;
return major !== undefined && String(major) === SUPPORTED_PLUGIN_API_VERSION;
}
/** Follows redirects by hand so each hop still gets the outbound checks. */
async function fetchFollowing(url: string): Promise<Response> {
let current = url;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const response = await safeOutboundFetch(current, {
method: "GET",
redirect: "manual",
headers: { "User-Agent": "Termix" },
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) throw new Error(`redirect without a location: ${current}`);
current = new URL(location, current).toString();
continue;
}
if (!response.ok) {
throw new Error(`${current} answered ${response.status}`);
}
return response;
}
throw new Error(`too many redirects fetching ${url}`);
}
async function readLimited(response: Response, limit: number): Promise<Buffer> {
const declared = Number(response.headers.get("content-length") ?? 0);
if (declared > limit) throw new Error("download is larger than allowed");
if (!response.body) return Buffer.alloc(0);
const chunks: Buffer[] = [];
let total = 0;
const reader = response.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > limit) {
await reader.cancel().catch(() => {});
throw new Error("download is larger than allowed");
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks);
}
let cache: { index: RegistryIndex; at: number; hash: string } | null = null;
let inflight: Promise<RegistryIndex> | null = null;
export interface RegistryStatus {
url: string;
lastCheckedAt: string | null;
error: string | null;
}
let lastError: string | null = null;
export function getRegistryStatus(): RegistryStatus {
return {
url: getRegistryUrl(),
lastCheckedAt: cache ? new Date(cache.at).toISOString() : null,
error: lastError,
};
}
export async function fetchRegistryIndex(
options: { force?: boolean } = {},
): Promise<RegistryIndex> {
if (!options.force && cache && Date.now() - cache.at < CACHE_MS) {
return cache.index;
}
if (inflight) return inflight;
inflight = (async () => {
try {
const response = await fetchFollowing(getRegistryUrl());
const body = await readLimited(response, MAX_INDEX_BYTES);
const index = parseRegistryIndex(JSON.parse(body.toString("utf8")));
cache = {
index,
at: Date.now(),
hash: crypto.createHash("sha256").update(body).digest("hex"),
};
lastError = null;
return index;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
// A stale index beats none when the registry is briefly unreachable.
if (cache) {
pluginLogger.warn(`Plugin registry check failed: ${lastError}`, {
operation: "plugin_registry",
});
return cache.index;
}
throw error;
} finally {
inflight = null;
}
})();
return inflight;
}
/** Test seam. */
export function resetRegistryCache(): void {
cache = null;
inflight = null;
lastError = null;
}
export function findRelease(
index: RegistryIndex,
pluginId: string,
version?: string,
): { plugin: RegistryPlugin; release: RegistryVersion } | null {
const plugin = index.plugins.find((entry) => entry.id === pluginId);
if (!plugin) return null;
const release = version
? plugin.versions.find((entry) => entry.version === version)
: plugin.versions.find((entry) => isApiCompatible(entry.api));
if (!release) return null;
return { plugin, release };
}
/**
* Downloads a release, verifies it, and leaves it in a staging folder the
* loader never scans. The caller moves it into place.
*/
export async function downloadRelease(
pluginId: string,
release: RegistryVersion,
): Promise<{ file: string; signatureFile: string }> {
if (!isApiCompatible(release.api)) {
throw new Error(
`${pluginId} ${release.version} needs plugin API ${release.api}, this build runs ${SUPPORTED_PLUGIN_API_VERSION}`,
);
}
const limit =
release.size > 0 ? release.size + 1024 * 1024 : MAX_ARTIFACT_BYTES;
const response = await fetchFollowing(release.url);
const buffer = await readLimited(
response,
Math.min(limit, MAX_ARTIFACT_BYTES),
);
const verified = verifyPluginArtifact(
buffer,
release.sha256,
release.signature,
);
if (verified.ok === false) {
throw new Error(`${pluginId} ${release.version}: ${verified.reason}`);
}
const staging = getDownloadsDir();
await fs.promises.mkdir(staging, { recursive: true });
const base = path.join(
staging,
`${pluginId}-${release.version}-${crypto.randomBytes(4).toString("hex")}.tmxplug`,
);
await fs.promises.writeFile(base, buffer);
await fs.promises.writeFile(`${base}.sig`, release.signature.trim());
return { file: base, signatureFile: `${base}.sig` };
}
/** A dot folder inside the plugins dir, which the loader's scan skips. */
export function getDownloadsDir(): string {
return path.join(getPluginsDir(), ".downloads");
}
+4
View File
@@ -248,6 +248,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runPluginDataMigrations } =
await import("./upgrade/boot-migrations.js");
await runPluginDataMigrations();
const { startPluginAutoUpdate } =
await import("./plugins/auto-update.js");
startPluginAutoUpdate();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -6,6 +6,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginManageError } from "../../../plugins/manage.js";
import express from "express";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
@@ -161,6 +162,32 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
}));
const manageMock = vi.hoisted(() => ({
listRegistry: vi.fn(),
installPlugin: vi.fn(),
updatePlugin: vi.fn(),
updateAllPlugins: vi.fn(),
uninstallPlugin: vi.fn(),
setPluginOptions: vi.fn(),
getPluginDataSummary: vi.fn(),
deletePluginData: vi.fn(),
planStateChange: vi.fn(),
setPluginState: vi.fn(),
isLinkedDesktop: vi.fn(async () => false),
}));
const auditMock = vi.hoisted(() => ({ logAudit: vi.fn(async () => {}) }));
vi.mock("../../../plugins/manage.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../plugins/manage.js")>()),
...manageMock,
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: auditMock.logAudit,
getAuditUsername: async () => "admin",
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
.default;
@@ -583,4 +610,139 @@ describe("plugins route", () => {
expect(state.grants).toHaveLength(1);
});
});
describe("install, update, uninstall", () => {
const post = (path: string, body: unknown = {}, user = "admin-1") =>
fetch(`${baseUrl}${path}`, {
method: "POST",
headers: { "content-type": "application/json", "x-test-user-id": user },
body: JSON.stringify(body),
});
beforeEach(() => {
for (const fn of Object.values(manageMock)) fn.mockReset();
manageMock.isLinkedDesktop.mockResolvedValue(false);
auditMock.logAudit.mockClear();
});
it("installs a plugin as the caller and audits it", async () => {
manageMock.installPlugin.mockResolvedValue({
id: "docker",
version: "1.0.0",
state: "active",
});
const res = await post("/plugins/docker/install", { version: "1.0.0" });
expect(res.status).toBe(200);
expect(manageMock.installPlugin).toHaveBeenCalledWith("docker", {
version: "1.0.0",
userId: "admin-1",
});
expect(auditMock.logAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "install_plugin",
resourceId: "docker",
}),
);
});
it("rejects a bad id or version before touching anything", async () => {
expect((await post("/plugins/..%2Fx/install")).status).toBe(400);
expect(
(await post("/plugins/docker/install", { version: 7 })).status,
).toBe(400);
expect(manageMock.installPlugin).not.toHaveBeenCalled();
});
it("passes a refusal through with its code and details", async () => {
manageMock.updatePlugin.mockRejectedValue(
new PluginManageError("new caps", 409, "CAPABILITIES_ADDED", {
capabilities: ["notify:send"],
}),
);
const res = await post("/plugins/docker/update", {});
expect(res.status).toBe(409);
expect(await res.json()).toMatchObject({
code: "CAPABILITIES_ADDED",
capabilities: ["notify:send"],
});
expect(manageMock.updatePlugin).toHaveBeenCalledWith("docker", {
version: undefined,
userId: "admin-1",
acceptCapabilities: false,
});
});
it("uninstalls", async () => {
manageMock.uninstallPlugin.mockResolvedValue({
id: "docker",
removed: { tables: ["p_docker_x"], kvKeys: 0 },
});
const res = await fetch(`${baseUrl}/plugins/docker`, {
method: "DELETE",
});
expect(res.status).toBe(200);
expect(manageMock.uninstallPlugin).toHaveBeenCalledWith("docker");
});
it("refuses changes on a linked desktop", async () => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
const res = await post("/plugins/docker/install");
expect(res.status).toBe(409);
expect((await res.json()).code).toBe("MANAGED_BY_SERVER");
expect(manageMock.installPlugin).not.toHaveBeenCalled();
});
it("previews a state change without making it", async () => {
state.plugins.set("sample-plugin", makePlugin());
manageMock.planStateChange.mockResolvedValue({
enable: [],
disable: ["addon"],
missing: [],
});
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/state?dryRun=1`,
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ enabled: false }),
},
);
expect(await res.json()).toMatchObject({ disable: ["addon"] });
expect(manageMock.setPluginState).not.toHaveBeenCalled();
});
it("answers 502 when the registry is unreachable", async () => {
manageMock.listRegistry.mockRejectedValue(new Error("offline"));
const res = await fetch(`${baseUrl}/plugins/registry`);
expect(res.status).toBe(502);
});
it("403s a non-admin on every management route", async () => {
const user = "regular-user";
const headers = {
"content-type": "application/json",
"x-test-user-id": user,
};
const calls = [
fetch(`${baseUrl}/plugins/registry`, { headers }),
post("/plugins/docker/install", {}, user),
post("/plugins/docker/update", {}, user),
post("/plugins/update-all", {}, user),
fetch(`${baseUrl}/plugins/docker`, { method: "DELETE", headers }),
fetch(`${baseUrl}/plugins/docker/options`, {
method: "PATCH",
headers,
body: JSON.stringify({ autoUpdate: true }),
}),
fetch(`${baseUrl}/plugins/docker/data`, { headers }),
];
for (const res of await Promise.all(calls)) {
expect(res.status).toBe(403);
}
for (const fn of Object.values(manageMock)) {
if (fn !== manageMock.isLinkedDesktop) {
expect(fn).not.toHaveBeenCalled();
}
}
});
});
});
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const manage = vi.hoisted(() => ({
isLinkedDesktop: vi.fn(async () => false),
updateAllPlugins: vi.fn(),
}));
const notify = vi.hoisted(() => ({ sendCoreAlert: vi.fn(async () => {}) }));
vi.mock("../../plugins/manage.js", () => manage);
vi.mock("../../notify/core-notify.js", () => notify);
import { runPluginAutoUpdate } from "../../plugins/auto-update.js";
beforeEach(() => {
manage.isLinkedDesktop.mockResolvedValue(false);
manage.updateAllPlugins.mockReset();
notify.sendCoreAlert.mockClear();
});
describe("runPluginAutoUpdate", () => {
it("only touches plugins with auto-update on", async () => {
manage.updateAllPlugins.mockResolvedValue({
updated: [{ id: "docker", version: "1.1.0" }],
needsReview: [],
failed: [],
});
await runPluginAutoUpdate();
expect(manage.updateAllPlugins).toHaveBeenCalledWith({
userId: null,
onlyAutoUpdate: true,
});
expect(notify.sendCoreAlert).not.toHaveBeenCalled();
});
it("alerts admins about an update that asks for more", async () => {
manage.updateAllPlugins.mockResolvedValue({
updated: [],
needsReview: [{ id: "docker", capabilities: ["notify:send"] }],
failed: [],
});
await runPluginAutoUpdate();
expect(notify.sendCoreAlert).toHaveBeenCalledWith(
expect.objectContaining({
audience: "admins",
dedupeKey: "plugin-review:docker:notify:send",
}),
);
});
it("does nothing on a linked desktop", async () => {
manage.isLinkedDesktop.mockResolvedValue(true);
await runPluginAutoUpdate();
expect(manage.updateAllPlugins).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { moveWithRetry } from "../../plugins/fs-retry.js";
const roots: string[] = [];
function tempDir(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-move-"));
roots.push(root);
return root;
}
function locked(): NodeJS.ErrnoException {
return Object.assign(new Error("locked"), { code: "EPERM" });
}
afterEach(() => {
vi.restoreAllMocks();
while (roots.length) {
fs.rmSync(roots.pop()!, { recursive: true, force: true });
}
});
describe("moveWithRetry", () => {
it("waits out a passing lock", async () => {
const root = tempDir();
fs.mkdirSync(path.join(root, "from"));
const real = fs.promises.rename;
const rename = vi
.spyOn(fs.promises, "rename")
.mockRejectedValueOnce(locked())
.mockImplementation(real);
await moveWithRetry(path.join(root, "from"), path.join(root, "to"), [1, 1]);
expect(rename).toHaveBeenCalledTimes(2);
expect(fs.existsSync(path.join(root, "to"))).toBe(true);
});
it("copies a folder that never unlocks", async () => {
const root = tempDir();
fs.mkdirSync(path.join(root, "from", "nested"), { recursive: true });
fs.writeFileSync(path.join(root, "from", "nested", "a.js"), "x");
vi.spyOn(fs.promises, "rename").mockRejectedValue(locked());
await moveWithRetry(path.join(root, "from"), path.join(root, "to"), [1]);
expect(
fs.readFileSync(path.join(root, "to", "nested", "a.js"), "utf8"),
).toBe("x");
expect(fs.existsSync(path.join(root, "from"))).toBe(false);
});
it("does not retry a real error", async () => {
const root = tempDir();
await expect(
moveWithRetry(path.join(root, "missing"), path.join(root, "to"), [1]),
).rejects.toMatchObject({ code: "ENOENT" });
});
});
+66 -1
View File
@@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { PluginLoader } from "../../plugins/loader.js";
import { PluginLoader, assertBundledUpdate } from "../../plugins/loader.js";
import { createFixturePlugin } from "./fixture-plugin.js";
vi.mock("../../database/repositories/factory.js", () => ({
@@ -484,3 +484,68 @@ describe("PluginLoader error budget", () => {
expect(loader.get("sample-plugin")?.lastError).toBeNull();
});
});
describe("PluginLoader uninstall and pins", () => {
it("ignores the shipped copy of a bundled plugin an admin uninstalled", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({ id: "ssh-terminal", root: bundled });
createFixturePlugin({ id: "docker", root: bundled });
setRoots(bundled, user);
const loader = new PluginLoader();
loader.setUninstalled(["docker"]);
const loaded = await loader.loadAll();
expect(loaded.map((plugin) => plugin.id)).toEqual(["ssh-terminal"]);
expect(loader.bundledVersion("docker")).toBeUndefined();
expect(loader.bundledIds().has("docker")).toBe(false);
});
it("loads a downloaded copy of an uninstalled bundled id as a normal plugin", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
const userPlugins = path.join(user, "plugins");
fs.mkdirSync(userPlugins, { recursive: true });
createFixturePlugin({ id: "docker", root: bundled });
createFixturePlugin({ id: "docker", root: userPlugins });
setRoots(bundled, user);
const loader = new PluginLoader();
loader.setUninstalled(["docker"]);
const loaded = await loader.loadAll();
expect(loaded).toHaveLength(1);
expect(loaded[0].source).toBe("user");
});
it("loads the shipped copy again on reinstall", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({ id: "docker", root: bundled });
setRoots(bundled, user);
const loader = new PluginLoader();
loader.setUninstalled(["docker"]);
await loader.loadAll();
const plugin = await loader.loadBundled("docker");
expect(plugin.source).toBe("bundled");
expect(loader.get("docker")).toBe(plugin);
});
it("refuses an older signed update unless that exact version is pinned", () => {
expect(() =>
assertBundledUpdate("docker", "1.0.0", "key", "1.1.0"),
).toThrow(/not newer/);
expect(() =>
assertBundledUpdate("docker", "1.0.0", "key", "1.1.0", "0.9.0"),
).toThrow(/not newer/);
expect(() =>
assertBundledUpdate("docker", "1.0.0", "key", "1.1.0", "1.0.0"),
).not.toThrow();
expect(() =>
assertBundledUpdate("docker", "1.0.0", undefined, "1.1.0", "1.0.0"),
).toThrow(/signed/);
});
});
+431
View File
@@ -0,0 +1,431 @@
/**
* Install, update, uninstall and switching, against a real PluginLoader and
* fixture plugins on disk. The database and the registry are faked.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import * as tar from "tar";
import { createFixturePlugin } from "./fixture-plugin.js";
import type { LoadedPlugin } from "../../plugins/loader.js";
interface Row {
id: string;
name: string;
version: string;
tier: string;
source: string;
state: string;
lastError: string | null;
registryId: string | null;
autoUpdate: boolean;
pinnedVersion: string | null;
manifestJson: string;
}
const db = vi.hoisted(() => ({
rows: new Map<string, Record<string, unknown>>(),
settings: new Map<string, string>(),
grants: [] as Array<{ pluginId: string; capability: string }>,
}));
const registry = vi.hoisted(() => ({
index: { registry: "official", name: "", plugins: [] as unknown[] },
download: vi.fn(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
listAll: async () => [...db.rows.values()],
findById: async (id: string) => db.rows.get(id) ?? null,
create: async (input: Record<string, unknown>) => {
const row = {
registryId: null,
lastError: null,
autoUpdate: false,
pinnedVersion: null,
...input,
};
db.rows.set(input.id as string, row);
return row;
},
update: async (id: string, input: Record<string, unknown>) => {
const row = db.rows.get(id);
if (!row) return null;
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) row[key] = value;
}
return row;
},
delete: async (id: string) => db.rows.delete(id),
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => db.settings.get(key) ?? null,
set: async (key: string, value: string) => void db.settings.set(key, value),
}),
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (id: string) =>
db.grants.filter((g) => g.pluginId === id),
grant: async (input: { pluginId: string; capability: string }) =>
void db.grants.push(input),
}),
createCurrentPluginStorageRepository: () => ({
get: async () => null,
set: async () => {},
delete: async () => false,
listKeys: async () => [],
countKeys: async () => 0,
}),
}));
vi.mock("../../plugins/data.js", () => ({
migratePlugin: async () => [],
removePluginData: async () => ({ tables: [], kvKeys: 0, migrations: 0 }),
describePluginTables: async () => [],
registerTable: () => ({}),
forgetTables: () => {},
}));
vi.mock("../../plugins/permissions.js", () => ({
invalidatePluginPermissionCache: () => {},
}));
vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../plugins/registry-index.js")>();
return {
...actual,
fetchRegistryIndex: async () => actual.parseRegistryIndex(registry.index),
downloadRelease: registry.download,
};
});
vi.mock("../../plugins/index.js", async () => {
const { PluginLoader } = await import("../../plugins/loader.js");
const loader = new PluginLoader();
const seed = async (plugins: LoadedPlugin[]) => {
for (const plugin of plugins) {
if (db.rows.has(plugin.id)) {
Object.assign(db.rows.get(plugin.id)!, {
version: plugin.manifest.version,
manifestJson: JSON.stringify(plugin.manifest),
});
continue;
}
db.rows.set(plugin.id, {
id: plugin.id,
name: plugin.manifest.name,
version: plugin.manifest.version,
tier: plugin.source === "bundled" ? "bundled" : "community",
source: plugin.source,
state: plugin.source === "bundled" ? "enabled" : "disabled",
lastError: null,
registryId: null,
autoUpdate: false,
pinnedVersion: null,
manifestJson: JSON.stringify(plugin.manifest),
} satisfies Row);
}
};
return {
getPluginRuntime: () => ({ loader }),
activatePlugin: (id: string) => loader.activate(id),
deactivatePlugin: (id: string) => loader.deactivate(id),
registerLoadedPlugins: seed,
unloadPlugin: async (id: string) => {
if (!loader.get(id)) return;
await loader.deactivate(id);
loader.forget(id);
},
};
});
const { getPluginRuntime } = await import("../../plugins/index.js");
const manage = await import("../../plugins/manage.js");
const roots: string[] = [];
let bundled: string;
let data: string;
function tempRoot(prefix: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
roots.push(root);
return root;
}
function registryEntry(
id: string,
versions: Array<{ version: string; capabilities?: string[] }>,
) {
return {
id,
name: id,
versions: versions.map((v) => ({
version: v.version,
api: "1",
url: `https://example.com/${id}-${v.version}.tmxplug`,
sha256: "a".repeat(64),
signature: "sig",
size: 1,
capabilities: v.capabilities ?? ["hosts:read", "kv:own"],
})),
};
}
/** A .tmxplug of a fixture plugin, standing in for a verified download. */
async function stageArtifact(
id: string,
manifestOverrides: Record<string, unknown> = {},
) {
const build = tempRoot("termix-build-");
createFixturePlugin({ id, root: build, manifestOverrides });
const staging = path.join(data, "plugins", ".downloads");
fs.mkdirSync(staging, { recursive: true });
const file = path.join(staging, `${id}-staged.tmxplug`);
await tar.c({ gzip: true, file, cwd: path.join(build, id) }, ["."]);
return { file, signatureFile: `${file}.sig` };
}
async function boot() {
const { loader } = getPluginRuntime();
await manage.applyStoredPluginChoices(loader);
const loaded = await loader.loadAll();
const { registerLoadedPlugins } = await import("../../plugins/index.js");
await registerLoadedPlugins(loaded);
for (const plugin of loaded) {
if (db.rows.get(plugin.id)?.state === "enabled") {
await loader.activate(plugin.id);
}
}
return loader;
}
beforeEach(() => {
bundled = tempRoot("termix-bundled-");
data = tempRoot("termix-data-");
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = data;
db.rows.clear();
db.settings.clear();
db.grants.length = 0;
registry.index.plugins = [];
registry.download.mockReset();
});
afterEach(async () => {
const { loader } = getPluginRuntime();
await loader.shutdown();
for (const plugin of loader.list()) loader.forget(plugin.id);
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
while (roots.length) {
fs.rmSync(roots.pop()!, { recursive: true, force: true });
}
});
describe("uninstall and reinstall of a bundled plugin", () => {
it("deletes it, keeps it gone after a restart, and downloads it again", async () => {
createFixturePlugin({ id: "docker", root: bundled });
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
let loader = await boot();
expect(loader.get("docker")?.state).toBe("active");
await manage.uninstallPlugin("docker");
expect(loader.get("docker")).toBeUndefined();
expect(db.rows.has("docker")).toBe(false);
expect(fs.existsSync(path.join(bundled, "docker"))).toBe(false);
expect(JSON.parse(db.settings.get("plugins_uninstalled")!)).toEqual([
"docker",
]);
await loader.shutdown();
for (const plugin of loader.list()) loader.forget(plugin.id);
// A recreated container brings the image's copy back; it stays ignored.
createFixturePlugin({ id: "docker", root: bundled });
loader = await boot();
expect(loader.get("docker")).toBeUndefined();
registry.download.mockImplementation(() => stageArtifact("docker"));
const result = await manage.installPlugin("docker", { userId: "admin" });
expect(result.state).toBe("active");
expect(registry.download).toHaveBeenCalledOnce();
expect(loader.get("docker")?.source).toBe("user");
expect(fs.existsSync(path.join(data, "plugins", "docker.tmxplug"))).toBe(
true,
);
expect(db.rows.get("docker")).toMatchObject({
registryId: "official",
tier: "official",
});
expect(db.grants.map((g) => g.capability)).toEqual([
"hosts:read",
"kv:own",
]);
await loader.shutdown();
for (const plugin of loader.list()) loader.forget(plugin.id);
loader = await boot();
expect(loader.get("docker")?.source).toBe("user");
});
it("deletes a plugin dropped in as a plain folder", async () => {
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
const loader = await boot();
expect(loader.get("notes")?.source).toBe("user");
await manage.uninstallPlugin("notes");
expect(fs.existsSync(path.join(data, "plugins", "notes"))).toBe(false);
expect(fs.existsSync(path.join(data, "plugins"))).toBe(true);
expect(db.settings.get("plugins_uninstalled")).toBeUndefined();
});
it("refuses to install something already installed", async () => {
createFixturePlugin({ id: "docker", root: bundled });
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
await boot();
await expect(
manage.installPlugin("docker", { userId: "admin" }),
).rejects.toMatchObject({ code: "ALREADY_INSTALLED" });
});
});
describe("installing from the registry", () => {
it("puts the artifact in place, grants what it declares and starts it", async () => {
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
registry.download.mockImplementation(() =>
stageArtifact("notes", { capabilities: ["kv:own"] }),
);
const loader = await boot();
const result = await manage.installPlugin("notes", { userId: "admin" });
expect(result).toMatchObject({ id: "notes", state: "active" });
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
true,
);
expect(loader.get("notes")?.source).toBe("user");
expect(db.rows.get("notes")?.tier).toBe("official");
expect(db.grants).toEqual([
expect.objectContaining({ pluginId: "notes", capability: "kv:own" }),
]);
});
it("refuses a plugin that is not in the registry", async () => {
await boot();
await expect(
manage.installPlugin("nope", { userId: "admin" }),
).rejects.toMatchObject({ code: "NOT_IN_REGISTRY" });
});
it("pins an explicitly picked older version", async () => {
registry.index.plugins = [
registryEntry("notes", [{ version: "1.1.0" }, { version: "1.0.0" }]),
];
registry.download.mockImplementation(() => stageArtifact("notes"));
await boot();
await manage.installPlugin("notes", { userId: "admin", version: "1.0.0" });
expect(db.rows.get("notes")?.pinnedVersion).toBe("1.0.0");
});
it("uninstalls a registry plugin and its files", async () => {
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
registry.download.mockImplementation(() => stageArtifact("notes"));
await boot();
await manage.installPlugin("notes", { userId: "admin" });
await manage.uninstallPlugin("notes");
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
false,
);
expect(db.rows.has("notes")).toBe(false);
expect(db.settings.get("plugins_uninstalled")).toBeUndefined();
});
});
describe("updates", () => {
it("waits for consent when a release asks for new capabilities", async () => {
createFixturePlugin({ id: "docker", root: bundled });
registry.index.plugins = [
registryEntry("docker", [
{
version: "1.1.0",
capabilities: ["hosts:read", "kv:own", "notify:send"],
},
{ version: "1.0.0" },
]),
];
await boot();
await expect(
manage.updatePlugin("docker", { userId: "admin" }),
).rejects.toMatchObject({
code: "CAPABILITIES_ADDED",
details: { capabilities: ["notify:send"] },
});
expect(registry.download).not.toHaveBeenCalled();
});
it("lists an update and skips it in update-all when pinned", async () => {
createFixturePlugin({ id: "docker", root: bundled });
registry.index.plugins = [
registryEntry("docker", [{ version: "1.1.0" }, { version: "1.0.0" }]),
];
await boot();
const listing = await manage.listRegistry();
expect(listing.plugins[0]).toMatchObject({
installed: true,
installedVersion: "1.0.0",
latestVersion: "1.1.0",
updateAvailable: true,
addedCapabilities: [],
bundled: true,
});
await manage.setPluginOptions("docker", { pinned: true });
const result = await manage.updateAllPlugins({ userId: "admin" });
expect(result.updated).toEqual([]);
expect(registry.download).not.toHaveBeenCalled();
});
});
describe("dependencies", () => {
it("starts a dependency first and stops dependents first", async () => {
createFixturePlugin({ id: "base", root: bundled });
createFixturePlugin({
id: "addon",
root: bundled,
manifestOverrides: { dependencies: { base: "^1.0.0" } },
});
const loader = await boot();
const off = await manage.setPluginState("base", false);
expect(off.disable).toEqual(["addon"]);
expect(loader.get("addon")?.state).toBe("stopped");
expect(loader.get("base")?.state).toBe("stopped");
const plan = await manage.planStateChange("addon", true);
expect(plan.enable).toEqual(["base"]);
const on = await manage.setPluginState("addon", true);
expect(on.state).toBe("active");
expect(loader.get("base")?.state).toBe("active");
});
it("refuses to uninstall a plugin a running one depends on", async () => {
createFixturePlugin({ id: "base", root: bundled });
createFixturePlugin({
id: "addon",
root: bundled,
manifestOverrides: { dependencies: { base: "^1.0.0" } },
});
await boot();
await expect(manage.uninstallPlugin("base")).rejects.toMatchObject({
code: "HAS_DEPENDENTS",
details: { dependents: ["addon"] },
});
});
});
@@ -0,0 +1,231 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const fetchMock = vi.hoisted(() => vi.fn());
const testKey = vi.hoisted(() => ({ value: null as null | object }));
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch: fetchMock,
}));
vi.mock("../../plugins/trust.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../plugins/trust.js")>();
return {
...actual,
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
actual.verifyPluginArtifact(buffer, sha256, signature, [
testKey.value as never,
]),
};
});
import {
downloadRelease,
fetchRegistryIndex,
findRelease,
isApiCompatible,
parseRegistryIndex,
resetRegistryCache,
type RegistryVersion,
} from "../../plugins/registry-index.js";
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
testKey.value = {
id: "test",
publicKey: publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64"),
addedIn: "26.10.0",
};
function sign(buffer: Buffer) {
const digest = crypto.createHash("sha256").update(buffer).digest();
return {
sha256: digest.toString("hex"),
signature: crypto.sign(null, digest, privateKey).toString("base64"),
};
}
function response(body: string | Buffer, status = 200, headers = {}) {
return new Response(status >= 300 && status < 400 ? null : body, {
status,
headers,
});
}
function version(overrides: Partial<RegistryVersion> = {}) {
return {
version: "1.0.0",
api: "1",
url: "https://example.com/p-1.0.0.tmxplug",
sha256: "a".repeat(64),
signature: "sig",
size: 10,
capabilities: ["kv:own"],
publishedAt: "2026-10-01T00:00:00Z",
...overrides,
};
}
let dataDir: string;
beforeEach(() => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-registry-"));
process.env.DATA_DIR = dataDir;
fetchMock.mockReset();
resetRegistryCache();
});
afterEach(() => {
delete process.env.DATA_DIR;
fs.rmSync(dataDir, { recursive: true, force: true });
});
describe("parseRegistryIndex", () => {
it("keeps valid entries and sorts versions newest first", () => {
const index = parseRegistryIndex({
registry: "official",
plugins: [
{
id: "docker",
name: "Docker",
versions: [version(), version({ version: "1.2.0" })],
},
],
});
expect(index.plugins).toHaveLength(1);
expect(index.plugins[0].versions.map((v) => v.version)).toEqual([
"1.2.0",
"1.0.0",
]);
});
it("drops entries with a bad id, no https url or a malformed sha256", () => {
const index = parseRegistryIndex({
plugins: [
{ id: "../evil", versions: [version()] },
{ id: "plain", versions: [version({ url: "http://x/y.tmxplug" })] },
{ id: "short", versions: [version({ sha256: "abc" })] },
{ id: "ok", versions: [version()] },
],
});
expect(index.plugins.map((p) => p.id)).toEqual(["ok"]);
});
it("refuses something that is not an index", () => {
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
});
});
describe("findRelease", () => {
const index = parseRegistryIndex({
plugins: [
{
id: "docker",
versions: [
version({ version: "2.0.0", api: "2" }),
version({ version: "1.1.0" }),
version(),
],
},
],
});
it("picks the newest release this build can run", () => {
expect(isApiCompatible("1")).toBe(true);
expect(isApiCompatible("2")).toBe(false);
expect(findRelease(index, "docker")?.release.version).toBe("1.1.0");
});
it("finds an exact version", () => {
expect(findRelease(index, "docker", "1.0.0")?.release.version).toBe(
"1.0.0",
);
expect(findRelease(index, "docker", "9.9.9")).toBeNull();
expect(findRelease(index, "missing")).toBeNull();
});
});
describe("fetchRegistryIndex", () => {
const body = JSON.stringify({
plugins: [{ id: "docker", versions: [version()] }],
});
it("follows redirects and caches the result", async () => {
fetchMock
.mockResolvedValueOnce(
response("", 302, { location: "https://cdn.example.com/index.json" }),
)
.mockResolvedValueOnce(response(body));
const first = await fetchRegistryIndex();
const second = await fetchRegistryIndex();
expect(first.plugins[0].id).toBe("docker");
expect(second).toBe(first);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][0]).toBe(
"https://cdn.example.com/index.json",
);
});
it("serves the stale copy when a refresh fails", async () => {
fetchMock.mockResolvedValueOnce(response(body));
const first = await fetchRegistryIndex();
fetchMock.mockResolvedValueOnce(response("down", 503));
expect(await fetchRegistryIndex({ force: true })).toBe(first);
});
it("throws when there is nothing cached", async () => {
fetchMock.mockResolvedValueOnce(response("down", 503));
await expect(fetchRegistryIndex()).rejects.toThrow(/503/);
});
});
describe("downloadRelease", () => {
const artifact = Buffer.from("plugin archive bytes");
it("writes a verified artifact and its signature to staging", async () => {
const signed = sign(artifact);
fetchMock.mockResolvedValueOnce(response(artifact));
const result = await downloadRelease(
"docker",
version({ ...signed, size: artifact.length }),
);
expect(fs.readFileSync(result.file)).toEqual(artifact);
expect(fs.readFileSync(result.signatureFile, "utf8")).toBe(
signed.signature,
);
expect(
result.file.startsWith(path.join(dataDir, "plugins", ".downloads")),
).toBe(true);
});
it("refuses a file that does not match its signature", async () => {
const signed = sign(artifact);
fetchMock.mockResolvedValueOnce(response(Buffer.from("something else!!!")));
await expect(
downloadRelease("docker", version({ ...signed, size: 0 })),
).rejects.toThrow(/sha256/);
});
it("refuses a release built for another plugin API", async () => {
await expect(
downloadRelease("docker", version({ api: "2" })),
).rejects.toThrow(/plugin API/);
expect(fetchMock).not.toHaveBeenCalled();
});
it("refuses a download larger than the index says", async () => {
const signed = sign(artifact);
fetchMock.mockResolvedValueOnce(response(Buffer.alloc(3 * 1024 * 1024)));
await expect(
downloadRelease("docker", version({ ...signed, size: 10 })),
).rejects.toThrow(/larger than allowed/);
});
});