* 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

@@ -92,6 +92,13 @@ Host alias-only
expect(result).toHaveLength(0);
});
it("accepts tabs and = between keywords and values", () => {
const config = "Host\tweb\n\tHostName=10.0.0.5\n\tUser = deploy\n";
expect(parseSSHConfig(config)).toEqual([
{ name: "web", hostname: "10.0.0.5", user: "deploy" },
]);
});
it("defaults port to undefined when not specified", () => {
const config = `
Host server
@@ -7,6 +7,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginManageError } from "../../../plugins/manage.js";
import { RegistryError } from "../../../plugins/registries.js";
import express from "express";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
@@ -194,6 +195,19 @@ vi.mock("../../../utils/audit-logger.js", () => ({
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
const registriesMock = vi.hoisted(() => ({
readRegistriesConfig: vi.fn(),
previewRegistry: vi.fn(),
addRegistry: vi.fn(),
setRegistryEnabled: vi.fn(),
removeRegistry: vi.fn(),
}));
vi.mock("../../../plugins/registries.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../plugins/registries.js")>()),
...registriesMock,
}));
const onboardingMock = vi.hoisted(() => ({
describeOnboardingPlugins: vi.fn(),
applyOnboardingChoices: vi.fn(),
@@ -443,6 +457,28 @@ describe("plugins route", () => {
]);
});
it("marks server-only plugins on a linked desktop", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({ manifestJson: JSON.stringify({ desktop: "server" }) }),
);
const read = async () =>
(
(await (await fetch(`${baseUrl}/plugins`)).json()) as Array<{
runsOnServer: boolean;
}>
)[0].runsOnServer;
expect(await read()).toBe(false);
manageMock.isLinkedDesktop.mockResolvedValue(true);
try {
expect(await read()).toBe(true);
} finally {
manageMock.isLinkedDesktop.mockResolvedValue(false);
}
});
it("grants a declared capability", async () => {
state.plugins.set("sample-plugin", makePlugin());
@@ -577,6 +613,7 @@ describe("plugins route", () => {
version: "1.0.0",
enabled: true,
state: "enabled",
runsOnServer: false,
contributes: null,
dependencies: {},
optionalDependencies: {},
@@ -744,6 +781,190 @@ describe("plugins route", () => {
});
});
describe("plugin registries", () => {
const call = (
method: string,
path: string,
body?: unknown,
user = "admin-1",
) =>
fetch(`${baseUrl}${path}`, {
method,
headers: { "content-type": "application/json", "x-test-user-id": user },
body: body === undefined ? undefined : JSON.stringify(body),
});
beforeEach(async () => {
for (const fn of Object.values(registriesMock)) fn.mockReset();
manageMock.isLinkedDesktop.mockReset();
manageMock.isLinkedDesktop.mockResolvedValue(false);
auditMock.logAudit.mockClear();
});
it("is admin only", async () => {
for (const [method, path, body] of [
["GET", "/plugins/registries", undefined],
[
"POST",
"/plugins/registries/preview",
{ url: "https://x.com/i.json" },
],
["POST", "/plugins/registries", { url: "https://x.com/i.json" }],
["PATCH", "/plugins/registries/community", { enabled: true }],
["DELETE", "/plugins/registries/acme", undefined],
] as const) {
const res = await call(method, path, body, "user-2");
expect(res.status).toBe(403);
}
expect(registriesMock.setRegistryEnabled).not.toHaveBeenCalled();
expect(registriesMock.removeRegistry).not.toHaveBeenCalled();
});
it("lists the registries without the full public keys", async () => {
registriesMock.readRegistriesConfig.mockResolvedValue({
community: { enabled: true },
custom: [
{
id: "acme",
name: "Acme",
url: "https://acme.example/index.json",
publicKey: "secret-looking",
keyId: "abcd",
enabled: true,
addedAt: "2026-10-11T00:00:00Z",
},
],
});
const res = await call("GET", "/plugins/registries");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.community.enabled).toBe(true);
expect(body.custom[0]).toEqual({
id: "acme",
name: "Acme",
url: "https://acme.example/index.json",
keyId: "abcd",
enabled: true,
addedAt: "2026-10-11T00:00:00Z",
});
});
it("previews and adds a registry and audits the add", async () => {
registriesMock.previewRegistry.mockResolvedValue({
id: "acme",
name: "Acme",
url: "https://acme.example/index.json",
keyId: "abcd",
plugins: 2,
});
const preview = await call("POST", "/plugins/registries/preview", {
url: "https://acme.example/index.json",
});
expect(preview.status).toBe(200);
expect((await preview.json()).keyId).toBe("abcd");
registriesMock.addRegistry.mockResolvedValue({
id: "acme",
name: "Acme",
url: "https://acme.example/index.json",
publicKey: "k",
keyId: "abcd",
enabled: true,
addedAt: "2026-10-11T00:00:00Z",
});
const added = await call("POST", "/plugins/registries", {
url: "https://acme.example/index.json",
keyId: "abcd",
});
expect(added.status).toBe(200);
expect(registriesMock.addRegistry).toHaveBeenCalledWith(
"https://acme.example/index.json",
"abcd",
);
expect(auditMock.logAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "add_plugin_registry",
resourceType: "plugin_registry",
resourceId: "acme",
}),
);
});
it("passes registry errors through with their code", async () => {
registriesMock.addRegistry.mockRejectedValue(
new RegistryError("changed", 409, "KEY_MISMATCH", { keyId: "new" }),
);
const res = await call("POST", "/plugins/registries", {
url: "https://acme.example/index.json",
keyId: "old",
});
expect(res.status).toBe(409);
expect(await res.json()).toEqual({
error: "changed",
code: "KEY_MISMATCH",
keyId: "new",
});
});
it("turns a registry on and off and checks the body", async () => {
expect(
(await call("PATCH", "/plugins/registries/community", { enabled: "x" }))
.status,
).toBe(400);
const res = await call("PATCH", "/plugins/registries/community", {
enabled: true,
});
expect(res.status).toBe(200);
expect(registriesMock.setRegistryEnabled).toHaveBeenCalledWith(
"community",
true,
);
});
it("refuses to remove a registry still in use", async () => {
registriesMock.removeRegistry.mockRejectedValue(
new RegistryError("in use", 409, "REGISTRY_IN_USE", {
plugins: ["tool"],
}),
);
const res = await call("DELETE", "/plugins/registries/acme");
expect(res.status).toBe(409);
expect((await res.json()).plugins).toEqual(["tool"]);
});
it("leaves registries to the server on a linked desktop", async () => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
const res = await call("PATCH", "/plugins/registries/community", {
enabled: true,
});
expect(res.status).toBe(409);
expect(registriesMock.setRegistryEnabled).not.toHaveBeenCalled();
});
it("installs from the registry the caller names", async () => {
manageMock.installPlugin.mockReset();
manageMock.installPlugin.mockResolvedValue({
id: "neat",
version: "1.0.0",
state: "active",
});
const res = await call("POST", "/plugins/neat/install", {
registryId: "community",
capabilities: [],
});
expect(res.status).toBe(200);
expect(manageMock.installPlugin).toHaveBeenCalledWith(
"neat",
expect.objectContaining({ registryId: "community" }),
);
const bad = await call("POST", "/plugins/neat/install", {
registryId: "../x",
capabilities: [],
});
expect(bad.status).toBe(400);
});
});
describe("install, update, uninstall", () => {
const post = (path: string, body: unknown = {}, user = "admin-1") =>
fetch(`${baseUrl}${path}`, {