* 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

+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" });
});
});