* feat: AI translations at release, 7 new languages and regional fallback

* fix: proper plural forms for count strings and full translations

* fix: plural forms for selected, hidden and active counts

* fix: synced plugin updates failing on desktop and server-only plugins shown as stopped

* fix: restore rounded corners on app icons and favicon

* feat: desktop app auto updates with automatic, ask first or never modes

* feat: community and custom plugin registries

* feat: plugin guide for AI tools in the SDK with a lint check

* fix: keep the terminal WebSocket open past nginx's 60s default (#1588)

* chore: update changelog

* feat: show plugin download counts only and stop CI builds inflating them

* feat: hide installed plugins in the browse tab by default

* chore: pin bundled plugins to latest releases

* chore: update translations

* fix: focusing a saved password field and saving no longer erases it

* fix: tell desktop users when their 2.8 data needs 2.9 first

* fix: point windows update info at the x64 installer

* fix: keep desktop client tunnel sockets alive behind proxies

* chore: pin bundled plugins to latest releases

* fix: cover client tunnel keepalive in the c2s runtime tests

* fix: typing after opening a host no longer goes to the host search

* fix: accept tab and = separators in SSH config import (#1598)

* chore: update changelog

* chore: drop plugin versions from core changelog

* chore: update translations

* chore: lint, format, and bump version to 26.10.1

---------

Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com>
Co-authored-by: Iain <Iain1970@protonmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 11:35:48 -05:00
1 parent 6873bc92bb
commit 62ac931468
174 files changed
+94334 -39995

No files matched your search

@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
stats: null as null | { plugins: Map<string, number> },
fail: false,
sources: [{ id: "official", tier: "official" }] as Array<{
id: string;
tier: string;
}>,
statsBySource: {} as Record<string, { plugins: Map<string, number> } | null>,
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
rows: [] as Array<{ pluginId: string; count: number }>,
}));
vi.mock("../../plugins/registry-index.js", () => ({
OFFICIAL_REGISTRY_ID: "official",
officialSource: () => ({ id: "official", tier: "official" }),
fetchRegistryStats: async (source: { id: string }) => {
if (state.fail) throw new Error("offline");
return state.statsBySource[source.id] ?? state.stats;
},
}));
vi.mock("../../plugins/registries.js", () => ({
listSources: async () => state.sources,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginInstallCountRepository: () => ({
upsertMany: async (registryId: string, entries: unknown[]) => {
state.upserted.push({ registryId, entries });
return entries.length;
},
listByRegistry: async () => state.rows,
}),
}));
const {
readDownloadCounts,
refreshDownloadCounts,
resetDownloadCountRefresh,
syncDownloadCounts,
} = await import("../../plugins/download-counts.js");
beforeEach(() => {
state.stats = null;
state.fail = false;
state.sources = [{ id: "official", tier: "official" }];
state.statsBySource = {};
state.upserted = [];
state.rows = [];
resetDownloadCountRefresh();
});
describe("refreshDownloadCounts", () => {
const docker = () => ({ plugins: new Map([["docker", 5]]) });
it("syncs on the first look and not again while fresh", async () => {
state.stats = docker();
await refreshDownloadCounts();
await refreshDownloadCounts();
expect(state.upserted).toHaveLength(1);
});
it("does not sync again right away even when forced", async () => {
state.stats = docker();
await refreshDownloadCounts();
await refreshDownloadCounts(true);
expect(state.upserted).toHaveLength(1);
});
it("syncs again when forced after a minute", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
state.stats = docker();
await refreshDownloadCounts();
vi.setSystemTime(Date.now() + 61_000);
await refreshDownloadCounts();
expect(state.upserted).toHaveLength(1);
await refreshDownloadCounts(true);
expect(state.upserted).toHaveLength(2);
} finally {
vi.useRealTimers();
}
});
});
describe("syncDownloadCounts", () => {
it("writes one row per plugin", async () => {
state.stats = {
plugins: new Map([
["docker", 50],
["tunnels", 8],
]),
};
expect(await syncDownloadCounts()).toBe(2);
expect(state.upserted).toEqual([
{
registryId: "official",
entries: [
{ pluginId: "docker", count: 50 },
{ pluginId: "tunnels", count: 8 },
],
},
]);
});
it("stores counts per registry and skips custom ones", async () => {
state.sources = [
{ id: "official", tier: "official" },
{ id: "community", tier: "community" },
{ id: "mine", tier: "custom" },
];
state.statsBySource = {
official: { plugins: new Map([["docker", 5]]) },
community: { plugins: new Map([["neat", 3]]) },
mine: null,
};
expect(await syncDownloadCounts()).toBe(2);
expect(state.upserted.map((entry) => entry.registryId)).toEqual([
"official",
"community",
]);
});
it("keeps the old numbers when the registry is unreachable", async () => {
state.fail = true;
expect(await syncDownloadCounts()).toBe(0);
expect(state.upserted).toEqual([]);
});
it("does nothing for a registry with no stats file", async () => {
expect(await syncDownloadCounts()).toBe(0);
expect(state.upserted).toEqual([]);
});
});
describe("readDownloadCounts", () => {
it("maps the stored rows by plugin id", async () => {
state.rows = [{ pluginId: "docker", count: 20 }];
const counts = await readDownloadCounts();
expect(counts.get("docker")).toBe(20);
});
});
@@ -0,0 +1,99 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
isBareSpecifier,
isUserPluginFile,
} from "../../plugins/host-modules.js";
describe("isBareSpecifier", () => {
it("accepts package names", () => {
expect(isBareSpecifier("drizzle-orm")).toBe(true);
expect(isBareSpecifier("drizzle-orm/sqlite-core")).toBe(true);
expect(isBareSpecifier("@termix-ssh/plugin-sdk/backend")).toBe(true);
});
it("refuses paths, urls and builtins", () => {
expect(isBareSpecifier("./local.js")).toBe(false);
expect(isBareSpecifier("../up.js")).toBe(false);
expect(isBareSpecifier("/abs/file.js")).toBe(false);
expect(isBareSpecifier("file:///x.js")).toBe(false);
expect(isBareSpecifier("node:fs")).toBe(false);
expect(isBareSpecifier("C:\\x\\y.js")).toBe(false);
expect(isBareSpecifier("#internal")).toBe(false);
});
});
describe("isUserPluginFile", () => {
const root = path.resolve(os.tmpdir(), "termix-data", "plugins");
it("matches files under the plugins dir, query included", () => {
const file = path.join(root, ".unpacked", "demo", "dist", "backend.js");
expect(isUserPluginFile(`${pathToFileURL(file).href}?v=1`, root)).toBe(
true,
);
});
it("ignores everything else", () => {
const outside = path.resolve(os.tmpdir(), "termix-data", "other.js");
expect(isUserPluginFile(pathToFileURL(outside).href, root)).toBe(false);
expect(isUserPluginFile(pathToFileURL(root).href, root)).toBe(false);
expect(isUserPluginFile(undefined, root)).toBe(false);
expect(isUserPluginFile("data:text/javascript,1", root)).toBe(false);
});
});
describe("installHostModuleFallback", () => {
let dataDir: string | null = null;
afterEach(() => {
if (dataDir) fs.rmSync(dataDir, { recursive: true, force: true });
dataDir = null;
});
// A real Node process: vitest resolves imports on its own and would hide
// what plain Node does.
function importFromPlugin(withFallback: boolean) {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-host-modules-"));
const dir = path.join(dataDir, "plugins", ".unpacked", "demo");
fs.mkdirSync(dir, { recursive: true });
const entry = path.join(dir, "backend.mjs");
fs.writeFileSync(
entry,
'import { sql } from "drizzle-orm";\nconsole.log(typeof sql);\n',
);
const hook = pathToFileURL(
path.resolve(__dirname, "../../plugins/host-modules.ts"),
).href;
const script = [
`const { installHostModuleFallback } = await import(${JSON.stringify(hook)});`,
withFallback ? "installHostModuleFallback();" : "",
`await import(${JSON.stringify(`${pathToFileURL(entry).href}?v=1`)});`,
].join("\n");
return spawnSync(
process.execPath,
["--import", "tsx", "--input-type=module", "-e", script],
{
cwd: path.resolve(__dirname, "../../../.."),
env: { ...process.env, DATA_DIR: dataDir },
encoding: "utf8",
timeout: 30_000,
},
);
}
it("lets a plugin outside the app import core's packages", () => {
const result = importFromPlugin(true);
expect(result.stderr).toBe("");
expect(result.stdout.trim()).toBe("function");
});
it("fails the same import without it", () => {
const result = importFromPlugin(false);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("Cannot find package 'drizzle-orm'");
});
});
@@ -1,103 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
stats: null as null | {
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
},
fail: false,
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
rows: [] as Array<{ pluginId: string; count: number; source: string }>,
}));
vi.mock("../../plugins/registry-index.js", () => ({
OFFICIAL_REGISTRY_ID: "official",
fetchRegistryStats: async () => {
if (state.fail) throw new Error("offline");
return state.stats;
},
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginInstallCountRepository: () => ({
upsertMany: async (registryId: string, entries: unknown[]) => {
state.upserted.push({ registryId, entries });
return entries.length;
},
listByRegistry: async () => state.rows,
}),
}));
const {
DOWNLOADS_SOURCE,
TELEMETRY_SOURCE,
preferredCount,
readInstallCounts,
syncInstallCounts,
} = await import("../../plugins/install-counts.js");
beforeEach(() => {
state.stats = null;
state.fail = false;
state.upserted = [];
state.rows = [];
});
describe("preferredCount", () => {
it("prefers active installs and falls back to downloads", () => {
expect(preferredCount({ downloads: 90, activeInstalls: 12 })).toEqual({
count: 12,
source: TELEMETRY_SOURCE,
});
expect(preferredCount({ downloads: 90, activeInstalls: null })).toEqual({
count: 90,
source: DOWNLOADS_SOURCE,
});
expect(preferredCount({ downloads: 90, activeInstalls: 0 })).toEqual({
count: 90,
source: DOWNLOADS_SOURCE,
});
});
});
describe("syncInstallCounts", () => {
it("writes one row per plugin with its preferred source", async () => {
state.stats = {
plugins: new Map([
["docker", { downloads: 50, activeInstalls: 20 }],
["tunnels", { downloads: 8, activeInstalls: null }],
]),
};
expect(await syncInstallCounts()).toBe(2);
expect(state.upserted).toEqual([
{
registryId: "official",
entries: [
{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE },
{ pluginId: "tunnels", count: 8, source: DOWNLOADS_SOURCE },
],
},
]);
});
it("keeps the old numbers when the registry is unreachable", async () => {
state.fail = true;
expect(await syncInstallCounts()).toBe(0);
expect(state.upserted).toEqual([]);
});
it("does nothing for a registry with no stats file", async () => {
expect(await syncInstallCounts()).toBe(0);
expect(state.upserted).toEqual([]);
});
});
describe("readInstallCounts", () => {
it("maps the stored rows by plugin id", async () => {
state.rows = [{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE }];
const counts = await readInstallCounts();
expect(counts.get("docker")).toEqual({
count: 20,
source: TELEMETRY_SOURCE,
});
});
});
+229 -1
View File
@@ -4,6 +4,7 @@
*/
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";
@@ -33,6 +34,8 @@ const db = vi.hoisted(() => ({
const registry = vi.hoisted(() => ({
index: { registry: "official", name: "", plugins: [] as unknown[] },
/** Indexes of the other registries, by id. Missing means unreachable. */
others: {} as Record<string, Record<string, unknown>>,
download: vi.fn(),
}));
@@ -97,7 +100,14 @@ vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
await importOriginal<typeof import("../../plugins/registry-index.js")>();
return {
...actual,
fetchRegistryIndex: async () => actual.parseRegistryIndex(registry.index),
fetchRegistryIndex: async (source?: { id: string }) => {
if (!source || source.id === "official") {
return actual.parseRegistryIndex(registry.index);
}
const other = registry.others[source.id];
if (!other) throw new Error(`${source.id} is offline`);
return actual.parseRegistryIndex(other);
},
downloadRelease: registry.download,
};
});
@@ -216,6 +226,7 @@ beforeEach(() => {
db.settings.clear();
db.grants.length = 0;
registry.index.plugins = [];
registry.others = {};
registry.download.mockReset();
});
@@ -856,3 +867,220 @@ describe("release notes", () => {
expect(manage.readReleaseNotes(undefined)).toBeUndefined();
});
});
describe("more registries", () => {
const customKey = (() => {
const { publicKey } = crypto.generateKeyPairSync("ed25519");
return publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64");
})();
function turnOnCommunity(): void {
db.settings.set(
"plugin_registries",
JSON.stringify({ community: { enabled: true }, custom: [] }),
);
}
it("lists community plugins only when it is on, never official ids", async () => {
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
registry.others.community = {
registry: "community",
name: "Community",
plugins: [
registryEntry("docker", [{ version: "9.0.0" }]),
registryEntry("neat", [{ version: "1.0.0" }]),
],
};
await boot();
let listing = await manage.listRegistry();
expect(listing.plugins.map((p) => p.id)).toEqual(["docker"]);
expect(listing.registries.map((r) => r.id)).toEqual(["official"]);
turnOnCommunity();
listing = await manage.listRegistry();
expect(
listing.plugins.map((p) => [p.id, p.registryId, p.tier, p.latestVersion]),
).toEqual([
["docker", "official", "official", "1.0.0"],
["neat", "community", "community", "1.0.0"],
]);
expect(listing.registries.find((r) => r.id === "community")).toMatchObject({
tier: "community",
plugins: 1,
error: null,
});
});
it("keeps listing official plugins when another registry is down", async () => {
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
turnOnCommunity();
await boot();
const listing = await manage.listRegistry();
expect(listing.plugins.map((p) => p.id)).toEqual(["docker"]);
expect(listing.registries.find((r) => r.id === "community")?.error).toMatch(
/offline/,
);
});
it("installs from the community registry with its keys and records it", async () => {
registry.others.community = {
registry: "community",
plugins: [registryEntry("neat", [{ version: "1.0.0" }])],
};
turnOnCommunity();
await boot();
registry.download.mockImplementation(() => stageArtifact("neat"));
const result = await manage.installPlugin("neat", {
userId: "admin",
registryId: "community",
capabilities: DEFAULT_CAPS,
});
expect(result.state).toBe("active");
const keys = registry.download.mock.calls[0][2] as Array<{ id: string }>;
expect(keys.map((key) => key.id)).toEqual(["be5b24ea07b36624"]);
expect(db.rows.get("neat")).toMatchObject({
registryId: "community",
tier: "community",
});
});
it("updates a plugin from the registry it came from", async () => {
registry.others.community = {
registry: "community",
plugins: [
registryEntry("neat", [{ version: "1.1.0" }, { version: "1.0.0" }]),
],
};
turnOnCommunity();
await boot();
registry.download.mockImplementation(() =>
stageArtifact("neat", { version: "1.0.0" }),
);
await manage.installPlugin("neat", {
userId: "admin",
registryId: "community",
version: "1.0.0",
capabilities: DEFAULT_CAPS,
});
await manage.setPluginOptions("neat", { pinned: false });
registry.download.mockReset();
registry.download.mockImplementation(() =>
stageArtifact("neat", { version: "1.1.0" }),
);
const result = await manage.updatePlugin("neat", { userId: "admin" });
expect(result.version).toBe("1.1.0");
const [, release, keys] = registry.download.mock.calls[0];
expect(release.url).toContain("neat-1.1.0");
expect(keys.map((key: { id: string }) => key.id)).toEqual([
"be5b24ea07b36624",
]);
expect(db.rows.get("neat")).toMatchObject({ registryId: "community" });
});
it("stops updating a community plugin once the official registry lists its id", async () => {
registry.others.community = {
registry: "community",
plugins: [
registryEntry("neat", [{ version: "1.1.0" }, { version: "1.0.0" }]),
],
};
turnOnCommunity();
await boot();
registry.download.mockImplementation(() => stageArtifact("neat"));
await manage.installPlugin("neat", {
userId: "admin",
registryId: "community",
version: "1.0.0",
capabilities: DEFAULT_CAPS,
});
registry.index.plugins = [registryEntry("neat", [{ version: "5.0.0" }])];
await expect(
manage.updatePlugin("neat", { userId: "admin", version: "1.1.0" }),
).rejects.toMatchObject({ code: "RESERVED_ID" });
});
it("refuses an official id from another registry", async () => {
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
registry.others.community = {
registry: "community",
plugins: [registryEntry("docker", [{ version: "9.0.0" }])],
};
turnOnCommunity();
await boot();
await expect(
manage.installPlugin("docker", {
userId: "admin",
registryId: "community",
capabilities: DEFAULT_CAPS,
}),
).rejects.toMatchObject({ code: "RESERVED_ID" });
expect(registry.download).not.toHaveBeenCalled();
});
it("refuses a registry that is off", async () => {
registry.others.community = {
registry: "community",
plugins: [registryEntry("neat", [{ version: "1.0.0" }])],
};
await boot();
await expect(
manage.installPlugin("neat", {
userId: "admin",
registryId: "community",
capabilities: DEFAULT_CAPS,
}),
).rejects.toMatchObject({ code: "REGISTRY_OFF" });
});
it("stops trusting a custom registry whose index names a new key", async () => {
const other = crypto
.generateKeyPairSync("ed25519")
.publicKey.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64");
db.settings.set(
"plugin_registries",
JSON.stringify({
community: { enabled: false },
custom: [
{
id: "mine",
name: "Mine",
url: "https://example.com/index.json",
publicKey: customKey,
enabled: true,
addedAt: "2026-10-11T00:00:00Z",
},
],
}),
);
registry.others.mine = {
registry: "mine",
publicKey: customKey,
plugins: [registryEntry("tool", [{ version: "1.0.0" }])],
};
await boot();
let listing = await manage.listRegistry();
expect(listing.plugins.find((p) => p.id === "tool")?.tier).toBe("custom");
registry.others.mine.publicKey = other;
listing = await manage.listRegistry();
expect(listing.plugins.find((p) => p.id === "tool")).toBeUndefined();
expect(listing.registries.find((r) => r.id === "mine")?.error).toMatch(
/different signing key/,
);
await expect(
manage.installPlugin("tool", {
userId: "admin",
registryId: "mine",
capabilities: DEFAULT_CAPS,
}),
).rejects.toMatchObject({ code: "KEY_CHANGED" });
});
});
@@ -0,0 +1,259 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import crypto from "node:crypto";
const state = vi.hoisted(() => ({
settings: new Map<string, string>(),
plugins: [] as Array<{ id: string; registryId: string | null }>,
indexes: new Map<string, unknown>(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings.get(key) ?? null,
set: async (key: string, value: string) =>
void state.settings.set(key, value),
}),
createCurrentPluginRepository: () => ({
listAll: async () => state.plugins,
}),
}));
vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../plugins/registry-index.js")>();
return {
...actual,
fetchIndexAt: async (url: string) => {
const raw = state.indexes.get(url);
if (!raw) throw new Error("answered 404");
return actual.parseRegistryIndex(raw);
},
};
});
const registries = await import("../../plugins/registries.js");
const { getCustomPluginKeys, pluginKeyId } =
await import("../../plugins/trust.js");
function newKey(): string {
return crypto
.generateKeyPairSync("ed25519")
.publicKey.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64");
}
const URL_A = "https://example.com/a/index.json";
function serve(url: string, index: Record<string, unknown>): void {
state.indexes.set(url, { plugins: [], ...index });
}
beforeEach(async () => {
state.settings.clear();
state.plugins = [];
state.indexes.clear();
await registries.loadCustomRegistryKeys();
});
describe("registry sources", () => {
it("is official only by default", async () => {
const sources = await registries.listSources();
expect(sources.map((source) => source.id)).toEqual(["official"]);
});
it("adds community when it is turned on", async () => {
await registries.setRegistryEnabled("community", true);
const sources = await registries.listSources();
expect(sources.map((source) => [source.id, source.tier])).toEqual([
["official", "official"],
["community", "community"],
]);
await registries.setRegistryEnabled("community", false);
expect((await registries.listSources()).map((s) => s.id)).toEqual([
"official",
]);
});
it("never turns the official registry off", async () => {
await expect(
registries.setRegistryEnabled("official", false),
).rejects.toMatchObject({ code: "OFFICIAL" });
});
it("reads a broken stored value as official only", () => {
expect(registries.parseRegistriesConfig("{nope")).toEqual({
community: { enabled: false },
custom: [],
});
});
});
describe("adding a custom registry", () => {
it("previews the name, plugin count and key, then pins that key", async () => {
const key = newKey();
serve(URL_A, {
registry: "acme",
name: "Acme Plugins",
publicKey: key,
plugins: [
{
id: "tool",
versions: [
{
version: "1.0.0",
api: "1",
url: "https://example.com/tool.tmxplug",
sha256: "a".repeat(64),
signature: "sig",
},
],
},
],
});
const preview = await registries.previewRegistry(URL_A);
expect(preview).toEqual({
id: "acme",
name: "Acme Plugins",
url: URL_A,
keyId: pluginKeyId(key),
plugins: 1,
});
const added = await registries.addRegistry(URL_A, preview.keyId);
expect(added).toMatchObject({ id: "acme", enabled: true, publicKey: key });
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
const sources = await registries.listSources();
expect(sources.at(-1)).toMatchObject({
id: "acme",
tier: "custom",
url: URL_A,
});
expect(sources.at(-1)?.keys.map((k) => k.publicKey)).toEqual([key]);
});
it("refuses when the key changed after the preview", async () => {
serve(URL_A, { registry: "acme", publicKey: newKey() });
const preview = await registries.previewRegistry(URL_A);
serve(URL_A, { registry: "acme", publicKey: newKey() });
await expect(
registries.addRegistry(URL_A, preview.keyId),
).rejects.toMatchObject({ code: "KEY_MISMATCH" });
expect((await registries.readRegistriesConfig()).custom).toEqual([]);
});
it("refuses an index without a key", async () => {
serve(URL_A, { registry: "acme" });
await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({
code: "NO_PUBLIC_KEY",
});
});
it("refuses plain http and junk", async () => {
await expect(
registries.previewRegistry("http://example.com/index.json"),
).rejects.toMatchObject({ code: "INVALID_URL" });
await expect(registries.previewRegistry("nope")).rejects.toMatchObject({
code: "INVALID_URL",
});
await expect(registries.previewRegistry(42)).rejects.toMatchObject({
code: "INVALID_URL",
});
});
it("refuses an index it cannot read", async () => {
await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({
code: "REGISTRY_UNREACHABLE",
});
});
it("refuses the same URL twice and the built in ones", async () => {
const key = newKey();
serve(URL_A, { registry: "acme", publicKey: key });
await registries.addRegistry(URL_A, pluginKeyId(key));
await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({
code: "ALREADY_ADDED",
});
await expect(
registries.previewRegistry(
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/index.json",
),
).rejects.toMatchObject({ code: "ALREADY_ADDED" });
});
it("never takes a reserved or used id", async () => {
const urlB = "https://example.com/b/index.json";
const urlC = "https://example.com/c/index.json";
const keyA = newKey();
const keyB = newKey();
const keyC = newKey();
serve(URL_A, { registry: "official", publicKey: keyA });
serve(urlB, { registry: "community", publicKey: keyB });
serve(urlC, { registry: "Official", publicKey: keyC });
expect((await registries.addRegistry(URL_A, pluginKeyId(keyA))).id).toBe(
"official-2",
);
expect((await registries.addRegistry(urlB, pluginKeyId(keyB))).id).toBe(
"community-2",
);
expect((await registries.addRegistry(urlC, pluginKeyId(keyC))).id).toBe(
"official-3",
);
});
});
describe("turning off and removing a custom registry", () => {
async function addAcme(): Promise<string> {
const key = newKey();
serve(URL_A, { registry: "acme", publicKey: key });
await registries.addRegistry(URL_A, pluginKeyId(key));
return key;
}
it("keeps trusting the key of a registry that is off", async () => {
const key = await addAcme();
await registries.setRegistryEnabled("acme", false);
expect((await registries.listSources()).map((s) => s.id)).toEqual([
"official",
]);
expect(await registries.findSource("acme")).toBeNull();
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
});
it("refuses to remove a registry with installed plugins", async () => {
await addAcme();
state.plugins = [{ id: "tool", registryId: "acme" }];
await expect(registries.removeRegistry("acme")).rejects.toMatchObject({
code: "REGISTRY_IN_USE",
details: { plugins: ["tool"] },
});
});
it("removes it and stops trusting its key", async () => {
await addAcme();
await registries.removeRegistry("acme");
expect((await registries.readRegistriesConfig()).custom).toEqual([]);
expect(getCustomPluginKeys()).toEqual([]);
});
it("only turns built in registries off", async () => {
await expect(registries.removeRegistry("community")).rejects.toMatchObject({
code: "BUILT_IN",
});
await expect(registries.removeRegistry("nope")).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
});
describe("keys from a linked server", () => {
it("trusts them next to the local ones and survives a reload", async () => {
const key = newKey();
await registries.setSyncedRegistryKeys([key, "junk", 42]);
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
await registries.loadCustomRegistryKeys();
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
await registries.setSyncedRegistryKeys([]);
expect(getCustomPluginKeys()).toEqual([]);
});
});
+113 -14
View File
@@ -17,14 +17,27 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
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,
]),
// The built in official keys are swapped for the test key. Keys a
// caller passes for another registry are used as they are.
verifyPluginArtifact: (
buffer: Buffer,
sha256: string,
signature: string,
keys?: readonly unknown[],
) =>
actual.verifyPluginArtifact(
buffer,
sha256,
signature,
keys && keys !== actual.TRUSTED_PLUGIN_KEYS
? (keys as never)
: [testKey.value as never],
),
};
});
import {
communitySource,
downloadRelease,
fetchRegistryIndex,
fetchRegistryStats,
@@ -35,6 +48,7 @@ import {
parseRegistryIndex,
parseRegistryStats,
resetRegistryCache,
type RegistrySource,
type RegistryVersion,
} from "../../plugins/registry-index.js";
@@ -91,6 +105,25 @@ afterEach(() => {
fs.rmSync(dataDir, { recursive: true, force: true });
});
describe("parseRegistryIndex publicKey", () => {
const key = publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64");
it("keeps a valid key and drops anything else", () => {
expect(parseRegistryIndex({ publicKey: key, plugins: [] }).publicKey).toBe(
key,
);
expect(
parseRegistryIndex({ publicKey: "not a key", plugins: [] }).publicKey,
).toBeUndefined();
expect(
parseRegistryIndex({ publicKey: 42, plugins: [] }).publicKey,
).toBeUndefined();
});
});
describe("parseRegistryIndex", () => {
it("keeps valid entries and sorts versions newest first", () => {
const index = parseRegistryIndex({
@@ -289,7 +322,29 @@ describe("fetchRegistryIndex", () => {
fetchMock.mockResolvedValueOnce(response(body));
const first = await fetchRegistryIndex();
fetchMock.mockResolvedValueOnce(response("down", 503));
expect(await fetchRegistryIndex({ force: true })).toBe(first);
expect(await fetchRegistryIndex(undefined, { force: true })).toBe(first);
});
it("caches each registry on its own", async () => {
const other: RegistrySource = {
id: "mine",
name: "Mine",
url: "https://example.com/mine/index.json",
tier: "custom",
keys: [],
};
fetchMock
.mockResolvedValueOnce(response(body))
.mockResolvedValueOnce(
response(JSON.stringify({ registry: "mine", plugins: [] })),
);
const official = await fetchRegistryIndex();
const mine = await fetchRegistryIndex(other);
expect(official.plugins).toHaveLength(1);
expect(mine.plugins).toHaveLength(0);
expect(fetchMock.mock.calls[1][0]).toBe(other.url);
expect(await fetchRegistryIndex(other)).toBe(mine);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("throws when there is nothing cached", async () => {
@@ -384,6 +439,38 @@ describe("downloadRelease", () => {
).rejects.toThrow(/sha256/);
});
it("checks against the keys of the registry it came from", async () => {
const signed = sign(artifact);
const own = crypto.generateKeyPairSync("ed25519");
const ownKey = {
id: "own",
publicKey: own.publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64"),
addedIn: "admin",
};
fetchMock.mockResolvedValueOnce(response(artifact));
await expect(
downloadRelease("docker", version({ ...signed, size: artifact.length }), [
ownKey,
]),
).rejects.toThrow(/trusted key/);
const digest = crypto.createHash("sha256").update(artifact).digest();
fetchMock.mockResolvedValueOnce(response(artifact));
const result = await downloadRelease(
"docker",
version({
sha256: digest.toString("hex"),
signature: crypto.sign(null, digest, own.privateKey).toString("base64"),
size: artifact.length,
}),
[ownKey],
);
expect(fs.readFileSync(result.file)).toEqual(artifact);
});
it("refuses a release built for another plugin API", async () => {
await expect(
downloadRelease("docker", version({ api: "2" })),
@@ -407,19 +494,34 @@ describe("registry stats", () => {
);
});
it("has a community stats file and none for custom registries", () => {
expect(getStatsUrl(communitySource())).toBe(
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/stats.json",
);
expect(
getStatsUrl({
id: "mine",
name: "Mine",
url: "https://example.com/index.json",
tier: "custom",
keys: [],
}),
).toBeNull();
});
it("keeps valid counts and drops the rest", () => {
const stats = parseRegistryStats({
plugins: {
docker: { downloads: 120, activeInstalls: 40 },
docker: { downloads: 120 },
tunnels: { downloads: 7 },
"../bad": { downloads: 1 },
broken: { downloads: -3, activeInstalls: "many" },
broken: { downloads: -3 },
},
});
expect(Object.fromEntries(stats.plugins)).toEqual({
docker: { downloads: 120, activeInstalls: 40 },
tunnels: { downloads: 7, activeInstalls: null },
broken: { downloads: 0, activeInstalls: null },
docker: 120,
tunnels: 7,
broken: 0,
});
});
@@ -428,9 +530,6 @@ describe("registry stats", () => {
response(JSON.stringify({ plugins: { docker: { downloads: 3 } } })),
);
const stats = await fetchRegistryStats();
expect(stats?.plugins.get("docker")).toEqual({
downloads: 3,
activeInstalls: null,
});
expect(stats?.plugins.get("docker")).toBe(3);
});
});
@@ -15,6 +15,8 @@ import { PluginLoader } from "../../plugins/loader.js";
const testKey = vi.hoisted(() => ({
privateKey: null as crypto.KeyObject | null,
key: { id: "test", publicKey: "", addedIn: "2.9.0" },
communityPrivateKey: null as crypto.KeyObject | null,
community: { id: "community", publicKey: "", addedIn: "26.10.1" },
}));
vi.mock("../../plugins/trust.js", async (importActual) => {
@@ -23,6 +25,17 @@ vi.mock("../../plugins/trust.js", async (importActual) => {
...actual,
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
actual.verifyPluginArtifact(buffer, sha256, signature, [testKey.key]),
verifyAnyTrusted: (buffer: Buffer, sha256: string, signature: string) => {
const official = actual.verifyPluginArtifact(buffer, sha256, signature, [
testKey.key,
]);
if (official.ok === true) return { ...official, tier: "official" };
const community = actual.verifyPluginArtifact(buffer, sha256, signature, [
testKey.community,
]);
if (community.ok === true) return { ...community, tier: "community" };
return community;
},
};
});
@@ -42,6 +55,12 @@ vi.mock("../../database/repositories/factory.js", () => ({
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64");
const community = crypto.generateKeyPairSync("ed25519");
testKey.communityPrivateKey = community.privateKey;
testKey.community.publicKey = community.publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64");
}
const cleanups: Array<() => void> = [];
@@ -68,7 +87,7 @@ function setup() {
async function writeArtifact(
dir: string,
id: string,
sig: "valid" | "wrong-key" | "none",
sig: "valid" | "community" | "wrong-key" | "none",
version = "1.0.0",
): Promise<string> {
const build = tempRoot("termix-build-");
@@ -82,7 +101,9 @@ async function writeArtifact(
const signer =
sig === "valid"
? testKey.privateKey
: crypto.generateKeyPairSync("ed25519").privateKey;
: sig === "community"
? testKey.communityPrivateKey
: crypto.generateKeyPairSync("ed25519").privateKey;
const digest = crypto
.createHash("sha256")
.update(fs.readFileSync(file))
@@ -171,6 +192,27 @@ describe("updating a bundled plugin", () => {
expect(plugin?.source).toBe("bundled");
expect(plugin?.artifact).toBe(file);
expect(plugin?.signedBy).toBe("test");
expect(plugin?.signedTier).toBe("official");
});
it("keeps the bundled copy for a community signed newer archive", async () => {
const { userDir } = setup();
await writeArtifact(userDir, "bundled-one", "community", "2.0.0");
const loader = new PluginLoader();
await loader.loadAll();
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
});
it("loads a community signed plugin and records its tier", async () => {
const { userDir } = setup();
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
await writeArtifact(userDir, "community-one", "community");
const loader = new PluginLoader();
await loader.loadAll();
const plugin = loader.get("community-one");
expect(plugin?.source).toBe("user");
expect(plugin?.signedBy).toBe("community");
expect(plugin?.signedTier).toBe("community");
});
it("keeps the bundled copy for an unsigned newer archive", async () => {
+61
View File
@@ -1,7 +1,13 @@
import { describe, expect, it } from "vitest";
import crypto from "node:crypto";
import {
COMMUNITY_PLUGIN_KEYS,
TRUSTED_PLUGIN_KEYS,
isPluginPublicKey,
pluginKeyId,
requireSignedPlugins,
setCustomPluginKeys,
verifyAnyTrusted,
verifyPluginArtifact,
type TrustedPluginKey,
} from "../../plugins/trust.js";
@@ -97,6 +103,61 @@ describe("verifyPluginArtifact", () => {
});
});
describe("key tiers", () => {
const artifact = Buffer.from("a plugin archive");
it("derives key ids the same way the built in keys were made", () => {
for (const key of [...TRUSTED_PLUGIN_KEYS, ...COMMUNITY_PLUGIN_KEYS]) {
expect(pluginKeyId(key.publicKey)).toBe(key.id);
expect(isPluginPublicKey(key.publicKey)).toBe(true);
}
expect(isPluginPublicKey("bm9wZQ==")).toBe(false);
expect(isPluginPublicKey(42)).toBe(false);
});
it("keeps the community key apart from the official ones", () => {
const official = new Set(TRUSTED_PLUGIN_KEYS.map((key) => key.id));
for (const key of COMMUNITY_PLUGIN_KEYS) {
expect(official.has(key.id)).toBe(false);
}
});
it("verifies a custom key only while it is set, as the custom tier", () => {
const { key, privateKey } = keyPair();
const { sha256, signature } = signed(artifact, privateKey);
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
setCustomPluginKeys([key]);
expect(verifyAnyTrusted(artifact, sha256, signature)).toEqual({
ok: true,
keyId: "test",
tier: "custom",
});
setCustomPluginKeys([]);
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
});
it("drops a custom key that is not a valid public key", () => {
setCustomPluginKeys([{ id: "bad", publicKey: "bm9wZQ==", addedIn: "x" }]);
const { privateKey } = keyPair();
const { sha256, signature } = signed(artifact, privateKey);
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
setCustomPluginKeys([]);
});
it("stops at a sha256 mismatch", () => {
const { key, privateKey } = keyPair();
setCustomPluginKeys([key]);
const { signature } = signed(artifact, privateKey);
expect(verifyAnyTrusted(artifact, "0".repeat(64), signature)).toEqual({
ok: false,
reason: "sha256 does not match the file",
});
setCustomPluginKeys([]);
});
});
describe("requireSignedPlugins", () => {
it("is on only for the exact value true", () => {
const before = process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;