mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-07 11:05:24 +00:00
v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
577 files changed
+76479
-101831
No files matched your search
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* ctx.desktop.local: desktop:local is checked and audited, and nothing
|
||||
* touches the machine unless the backend runs embedded in the desktop app.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
const bridge = vi.hoisted(() => ({
|
||||
requestFromElectronMain: vi.fn(async () => ({ success: true })),
|
||||
}));
|
||||
vi.mock("../../utils/electron-ipc-bridge.js", () => ({
|
||||
isElectronIpcAvailable: () => process.env.ELECTRON_EMBEDDED === "true",
|
||||
requestFromElectronMain: bridge.requestFromElectronMain,
|
||||
}));
|
||||
|
||||
vi.mock("../../sync/client/link-store.js", () => ({
|
||||
getLink: async () => ({
|
||||
serverUrl: "https://termix.example.test",
|
||||
sessionToken: "token",
|
||||
}),
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(pluginId: string, capabilities: string[]) {
|
||||
const manifest = {
|
||||
id: pluginId,
|
||||
name: pluginId,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=26.10.3", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle(pluginId, { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
const previous = process.env.ELECTRON_EMBEDDED;
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
bridge.requestFromElectronMain.mockClear();
|
||||
process.env.ELECTRON_EMBEDDED = "true";
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-local-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (previous === undefined) delete process.env.ELECTRON_EMBEDDED;
|
||||
else process.env.ELECTRON_EMBEDDED = previous;
|
||||
});
|
||||
|
||||
describe("ctx.desktop.local", () => {
|
||||
it("refuses without desktop:local and audits the refusal", async () => {
|
||||
grants.set("demo", []);
|
||||
const ctx = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow(
|
||||
/desktop:local/,
|
||||
);
|
||||
expect(auditEntries.at(-1)).toMatchObject({ success: false });
|
||||
});
|
||||
|
||||
it("refuses when granted but not declared", async () => {
|
||||
grants.set("demo", ["desktop:local"]);
|
||||
const ctx = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow(
|
||||
/desktop:local/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses outside the desktop app even with the capability", async () => {
|
||||
grants.set("demo", ["desktop:local"]);
|
||||
process.env.ELECTRON_EMBEDDED = "false";
|
||||
const ctx = contextFor("demo", ["desktop:local"]);
|
||||
|
||||
expect(ctx.desktop.local.available()).toBe(false);
|
||||
await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow(/desktop app/);
|
||||
});
|
||||
|
||||
it("lists and audits once per action", async () => {
|
||||
grants.set("demo", ["desktop:local"]);
|
||||
fs.writeFileSync(path.join(dir, "a.txt"), "hi");
|
||||
const ctx = contextFor("demo", ["desktop:local"]);
|
||||
|
||||
const first = await ctx.desktop.local.fs.list(dir);
|
||||
await ctx.desktop.local.fs.list(dir);
|
||||
|
||||
expect(first.entries.map((entry) => entry.name)).toEqual(["a.txt"]);
|
||||
expect(
|
||||
auditEntries.filter(
|
||||
(entry) => entry.action === "plugin_desktop_local_fs",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects relative paths and unsafe names", async () => {
|
||||
grants.set("demo", ["desktop:local"]);
|
||||
const ctx = contextFor("demo", ["desktop:local"]);
|
||||
|
||||
await expect(ctx.desktop.local.fs.list("relative/path")).rejects.toThrow(
|
||||
/absolute/,
|
||||
);
|
||||
await expect(ctx.desktop.local.fs.mkdir(dir, "../evil")).rejects.toThrow(
|
||||
/Invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
it("never renames onto an existing name", async () => {
|
||||
grants.set("demo", ["desktop:local"]);
|
||||
fs.writeFileSync(path.join(dir, "a"), "1");
|
||||
fs.writeFileSync(path.join(dir, "b"), "2");
|
||||
const ctx = contextFor("demo", ["desktop:local"]);
|
||||
|
||||
await expect(
|
||||
ctx.desktop.local.fs.rename(path.join(dir, "a"), "b"),
|
||||
).rejects.toThrow(/already exists/);
|
||||
expect(fs.readFileSync(path.join(dir, "b"), "utf8")).toBe("2");
|
||||
});
|
||||
|
||||
it("keeps downloads inside the chosen folder and publishes on success", async () => {
|
||||
grants.set("demo", ["desktop:local"]);
|
||||
const ctx = contextFor("demo", ["desktop:local"]);
|
||||
|
||||
await expect(
|
||||
ctx.desktop.local.fs.openDownload({
|
||||
rootPath: dir,
|
||||
destPath: path.join(dir, "..", "escape.txt"),
|
||||
transferId: "t1",
|
||||
}),
|
||||
).rejects.toThrow(/outside/);
|
||||
|
||||
const download = await ctx.desktop.local.fs.openDownload({
|
||||
rootPath: dir,
|
||||
destPath: path.join(dir, "ok.txt"),
|
||||
transferId: "t2",
|
||||
});
|
||||
download.stream.write("data");
|
||||
expect(await download.publish()).toBe(path.join(dir, "ok.txt"));
|
||||
expect(fs.readFileSync(path.join(dir, "ok.txt"), "utf8")).toBe("data");
|
||||
expect(fs.readdirSync(dir)).toEqual(["ok.txt"]);
|
||||
});
|
||||
|
||||
it("removes the partial file when a download is aborted", async () => {
|
||||
grants.set("demo", ["desktop:local"]);
|
||||
const ctx = contextFor("demo", ["desktop:local"]);
|
||||
|
||||
const download = await ctx.desktop.local.fs.openDownload({
|
||||
rootPath: dir,
|
||||
destPath: path.join(dir, "gone.txt"),
|
||||
transferId: "t3",
|
||||
});
|
||||
download.stream.write("partial");
|
||||
await download.abort();
|
||||
expect(fs.readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends trash and open through Electron main and audits each call", async () => {
|
||||
grants.set("demo", ["desktop:local"]);
|
||||
const ctx = contextFor("demo", ["desktop:local"]);
|
||||
const target = path.join(dir, "x.txt");
|
||||
fs.writeFileSync(target, "x");
|
||||
|
||||
await expect(ctx.desktop.local.fs.trash([target])).resolves.toEqual({
|
||||
trashed: 1,
|
||||
failed: [],
|
||||
});
|
||||
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
|
||||
"shell-trash-item",
|
||||
{ path: target },
|
||||
);
|
||||
|
||||
await ctx.desktop.local.fs.open(target);
|
||||
await ctx.desktop.local.fs.open(target);
|
||||
expect(
|
||||
auditEntries.filter(
|
||||
(entry) => entry.action === "plugin_desktop_local_open",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("only calls the plugin's own routes", async () => {
|
||||
grants.set("demo", ["desktop:local"]);
|
||||
const ctx = contextFor("demo", ["desktop:local"]);
|
||||
|
||||
for (const bad of [
|
||||
"relative",
|
||||
"//evil.example/x",
|
||||
"/../other",
|
||||
"/a/../../b",
|
||||
String.raw`/a\b`,
|
||||
]) {
|
||||
await expect(ctx.desktop.local.pluginFetch("local", bad)).rejects.toThrow(
|
||||
/Invalid plugin route path/,
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
ctx.desktop.local.pluginFetch("elsewhere" as never, "/ok"),
|
||||
).rejects.toThrow(/Unknown origin|No acting user/);
|
||||
});
|
||||
|
||||
it("reports the link and the embedded backend port", async () => {
|
||||
grants.set("demo", ["desktop:local"]);
|
||||
process.env.EMBEDDED_BACKEND_PORT = "31234";
|
||||
const ctx = contextFor("demo", ["desktop:local"]);
|
||||
|
||||
const info = await ctx.desktop.local.info();
|
||||
expect(info.backendPort).toBe(31234);
|
||||
expect(info.link).toMatchObject({
|
||||
serverUrl: "https://termix.example.test",
|
||||
token: "token",
|
||||
allowInvalidCertificate: false,
|
||||
});
|
||||
expect(info.link?.headers.Cookie).toBe("jwt=token");
|
||||
delete process.env.EMBEDDED_BACKEND_PORT;
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user