feat: move web endpoint into a first-party plugin

This commit is contained in:
LukeGus committed 2026-09-22 12:30:35 -05:00
1 parent 6f0dff58a3
commit 6e058f6e9e
26 files changed
+496 -121

No files matched your search

@@ -415,3 +415,54 @@ describe("bundled workspaces plugin", () => {
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled web-endpoint plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "web-endpoint");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("web-endpoint"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("web-endpoint");
expect(manifest?.category).toBe("Networking");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("web-endpoint"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("web-endpoint")).toBe(true);
expect(runsInProcess("web-endpoint", manifest!.permissions)).toBe(true);
});
// The column this points at stays on core's ssh_data table, so a typo here
// would silently detach the host-editor checkbox from its storage.
it("contributes the enableWebUi host capability", () => {
const { manifest } = parseManifest(readBundledManifest("web-endpoint"));
expect(manifest?.contributes?.hostCapability).toMatchObject({
key: "enableWebUi",
});
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"web-endpoint",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
+20 -2
View File
@@ -39,7 +39,7 @@ function manifest(overrides: Record<string, unknown> = {}) {
}
describe("first-party allowlist", () => {
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale and workspaces", () => {
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint", () => {
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
"ai",
"automations",
@@ -51,11 +51,12 @@ describe("first-party allowlist", () => {
"remote-desktop",
"ssh-terminal",
"tailscale",
"web-endpoint",
"workspaces",
]);
});
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale and workspaces and nothing else", () => {
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint and nothing else", () => {
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(isFirstParty("docker")).toBe(true);
expect(isFirstParty("host-metrics")).toBe(true);
@@ -67,6 +68,7 @@ describe("first-party allowlist", () => {
expect(isFirstParty("network-topology")).toBe(true);
expect(isFirstParty("tailscale")).toBe(true);
expect(isFirstParty("workspaces")).toBe(true);
expect(isFirstParty("web-endpoint")).toBe(true);
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
expect(isFirstParty("community-plugin")).toBe(false);
expect(isFirstParty("")).toBe(false);
@@ -98,6 +100,9 @@ describe("runsInProcess", () => {
expect(runsInProcess("workspaces", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("web-endpoint", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
// On the list but not asking for it: stays in a worker, so the manifest
// remains an honest description of what the plugin does.
@@ -112,6 +117,7 @@ describe("runsInProcess", () => {
expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false);
expect(runsInProcess("tailscale", ["hosts.read"])).toBe(false);
expect(runsInProcess("workspaces", ["hosts.read"])).toBe(false);
expect(runsInProcess("web-endpoint", ["hosts.read"])).toBe(false);
// Asking for it but not on the list.
expect(
@@ -251,6 +257,18 @@ describe("manifest gate on the reserved capability", () => {
expect(parsed?.id).toBe("workspaces");
});
it("allows it for web-endpoint", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "web-endpoint",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("web-endpoint");
});
it("still accepts an ordinary manifest that never mentions it", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: ["hosts.read"] }),
@@ -1,8 +1,9 @@
import type express from "express";
import { readFile } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const resolveHostById = vi.hoisted(() => vi.fn());
vi.mock("../../hosts/host-resolver.js", () => ({ resolveHostById }));
vi.mock("../../../hosts/host-resolver.js", () => ({ resolveHostById }));
const manager = vi.hoisted(() => ({
activeTunnelRuntimes: new Map<string, unknown>(),
@@ -11,12 +12,12 @@ const manager = vi.hoisted(() => ({
connectSSHTunnel: vi.fn(),
cleanupTunnelResources: vi.fn(async () => undefined),
}));
vi.mock("../../hosts/tunnel/manager.js", () => manager);
vi.mock("../../../hosts/tunnel/manager.js", () => manager);
const forwardOut = vi.hoisted(() => vi.fn(async () => ({ end: vi.fn() })));
vi.mock("../../hosts/tunnel/ssh-primitives.js", () => ({ forwardOut }));
vi.mock("../../../hosts/tunnel/ssh-primitives.js", () => ({ forwardOut }));
vi.mock("../../utils/auth-manager.js", () => ({
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
}));
@@ -102,7 +103,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("refuses a host the user cannot resolve, without opening anything", async () => {
resolveHostById.mockResolvedValue(null);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
@@ -115,7 +116,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
// stored enable_web_ui is still 0. The UI reads as off in that state.
resolveHostById.mockResolvedValue(host({ enableWebUi: false }));
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
@@ -125,7 +126,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("refuses an endpoint id that matches nothing", async () => {
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "nope" }),
@@ -137,7 +138,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("returns the real bound port", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
@@ -151,7 +152,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
// select a different strategy and the forward would never bind locally.
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
@@ -170,7 +171,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("binds loopback by default and the endpoint's choice when given", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
@@ -199,7 +200,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("reuses a live tunnel instead of opening a second one", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const first = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
@@ -218,7 +219,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("reopens when the endpoint's target changed under a live tunnel", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
@@ -250,7 +251,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
new Error("Channel open failure: connect failed"),
);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
@@ -275,7 +276,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
},
);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
@@ -285,7 +286,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("rejects a malformed request before touching the database", async () => {
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
for (const body of [
{},
{ hostId: "7", endpointId: "e1" },
@@ -302,25 +303,48 @@ describe("POST /tunnel/web-endpoint/open", () => {
/**
* The client resolves "/tunnel/web-endpoint/open" against a baseURL that
* already ends in /ssh, and nginx proxies /ssh through with the path intact --
* so the server must register the FULL "/ssh/tunnel/..." path, as every route
* in routes.ts does. Registering the unprefixed form 404s every call, and no
* handler unit test notices because they call the handler directly.
* so the server must serve the FULL "/ssh/tunnel/web-endpoint/open" path.
* Serving the unprefixed form 404s every call, and no handler unit test
* notices because they call the handler directly.
*
* This was caught only by opening a real tunnel against a deployed build.
*
* Since the plugin migration that path is composed of two halves: the mount
* point in src/backend/hosts/tunnel/index.ts and the route on the plugin's own
* router. Either half drifting breaks the URL, so both are asserted here.
*/
describe("route registration", () => {
it("registers the /ssh-prefixed path the client actually calls", async () => {
const posts: string[] = [];
const app = {
post: (path: string) => {
posts.push(path);
it("registers /open on the router the dispatcher forwards to", async () => {
const routes =
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const dispatch =
await import("../../../hosts/tunnel/web-endpoint-dispatch.js");
const registered: unknown[] = [];
vi.spyOn(dispatch, "registerWebEndpointRouter").mockImplementation(
(router) => {
registered.push(router);
},
} as unknown as express.Express;
);
const { registerWebEndpointRoutes } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
registerWebEndpointRoutes(app);
routes.startWebEndpointService();
expect(posts).toContain("/ssh/tunnel/web-endpoint/open");
expect(registered).toHaveLength(1);
const paths = (
routes.router as unknown as {
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
}
).stack
.filter((layer) => layer.route?.methods.post)
.map((layer) => layer.route?.path);
expect(paths).toContain("/open");
});
it("is mounted so the full client path resolves", async () => {
const source = await readFile(
new URL("../../../hosts/tunnel/index.ts", import.meta.url),
"utf8",
);
expect(source).toContain('app.use("/ssh/tunnel/web-endpoint"');
});
});