feat: load machine-scoped remote plugins

This commit is contained in:
Federico Jaramillo Martinez
2026-06-05 09:29:48 +02:00
parent 9c3dafc4d4
commit b9be7de206
13 changed files with 466 additions and 24 deletions
+32
View File
@@ -12,6 +12,7 @@ import { MachineService } from "./machines/machineService.js";
import { MachineStore } from "./machines/machineStore.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { Project, Workspace } from "./types.js";
@@ -311,6 +312,37 @@ describe("buildApp", () => {
expect(missingResponse.statusCode).toBe(404);
});
it("rewrites and proxies remote machine plugin manifests and assets", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local" }] },
}));
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/javascript", "set-cookie": "secret=1" },
body: Readable.from(["export default {};"]),
}));
remoteClient = fakeRemoteClient({ requestJson, request });
const manifestResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` });
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({
plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local" }],
});
expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 });
const assetResponse = await app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123` });
expect(assetResponse.statusCode).toBe(200);
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
expect(assetResponse.headers["set-cookie"]).toBeUndefined();
expect(assetResponse.body).toBe("export default {};");
expect(request).toHaveBeenCalledWith("GET", "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123");
});
it("returns stable errors for invalid project requests", async () => {
const addResponse = await app.inject({
method: "POST",
+4
View File
@@ -21,6 +21,7 @@ import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js";
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
import { proxyMachinePluginAsset, registerMachinePluginProxyRoutes } from "./machines/machinePluginProxyRoutes.js";
export interface AppDependencies {
projects?: ProjectService;
@@ -96,6 +97,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
app.get<{ Params: { pluginId: string; "*": string } }>("/pi-web-plugins/:pluginId/*", async (request, reply) => {
if (await proxyMachinePluginAsset(machines, request.params.pluginId, request.params["*"], request.url, reply)) return;
const asset = await piWebPlugins.readAsset(request.params.pluginId, request.params["*"]);
if (asset === undefined) return reply.code(404).send({ error: "Plugin asset not found" });
return reply.type(asset.contentType).send(asset.content);
@@ -107,6 +110,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
registerConfigRoutes(app, deps.config);
registerMachineRoutes(app, machines);
registerMachinePluginProxyRoutes(app, machines);
registerLocalProjectRoutes(app, projects, workspaces, "/api");
registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local");
@@ -0,0 +1,153 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import { machineScopedPluginId, parseMachineScopedPluginId, type MachineScopedPluginIdParts } from "../../shared/machinePluginIds.js";
import { isPiWebPluginId } from "../../shared/pluginIds.js";
import { RemoteMachineRequestError, type MachineClient } from "./machineClient.js";
import { MachineService } from "./machineService.js";
interface RemotePluginManifestEntry {
id: string;
module: string;
source?: string;
scope?: string;
}
interface RemotePluginManifest {
plugins: RemotePluginManifestEntry[];
}
interface MachinePluginProxyMachines {
remoteClient(id: string): Promise<MachineClient | undefined>;
}
const MACHINE_PLUGIN_MANIFEST_TIMEOUT_MS = 10_000;
const SAFE_RESPONSE_HEADERS = new Set([
"content-type",
"content-length",
"cache-control",
"last-modified",
"etag",
"content-security-policy",
"x-content-type-options",
]);
export function registerMachinePluginProxyRoutes(app: FastifyInstance, machines: MachinePluginProxyMachines = new MachineService()): void {
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/pi-web-plugins/manifest.json", async (request, reply) => {
if (request.params.machineId === "local") return { plugins: [] };
const client = await machines.remoteClient(request.params.machineId);
if (client === undefined) return reply.code(404).send({ error: "Machine not found" });
try {
const response = await client.requestJson("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: MACHINE_PLUGIN_MANIFEST_TIMEOUT_MS });
if (response.statusCode === 404) return { plugins: [] };
if (response.statusCode < 200 || response.statusCode >= 300) return await reply.code(response.statusCode).send(response.body);
return rewriteRemotePluginManifest(request.params.machineId, parseRemoteManifest(response.body));
} catch (error) {
return sendGatewayError(reply, request.params.machineId, error);
}
});
}
export async function proxyMachinePluginAsset(machines: MachinePluginProxyMachines, scopedPluginId: string, assetPath: string, requestUrl: string, reply: FastifyReply): Promise<boolean> {
const remotePlugin = parseMachineScopedPluginId(scopedPluginId);
if (remotePlugin === undefined) return false;
const client = await machines.remoteClient(remotePlugin.machineId);
if (client === undefined) {
await reply.code(404).send({ error: "Machine not found" });
return true;
}
try {
const upstream = await client.request("GET", remotePluginAssetRequestPath(remotePlugin, assetPath, requestUrl));
reply.code(upstream.statusCode);
applySafeHeaders(reply, upstream.headers);
if (upstream.body === undefined) await reply.send();
else await reply.send(upstream.body);
return true;
} catch (error) {
sendGatewayError(reply, remotePlugin.machineId, error);
return true;
}
}
function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginManifest): RemotePluginManifest {
return {
plugins: manifest.plugins.flatMap((plugin) => {
const modulePath = remotePluginModulePath(plugin.id, plugin.module);
if (modulePath === undefined) return [];
return [{
...plugin,
module: `/pi-web-plugins/${encodeURIComponent(machineScopedPluginId(machineId, plugin.id))}/${modulePath.path}${modulePath.query}`,
}];
}),
};
}
function remotePluginModulePath(pluginId: string, module: string): { path: string; query: string } | undefined {
if (!isPiWebPluginId(pluginId)) return undefined;
try {
const url = new URL(module, "http://pi-web.local");
const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`;
if (url.pathname.startsWith(prefix)) {
return { path: url.pathname.slice(prefix.length), query: url.search };
}
if (!module.startsWith("/") && !/^https?:\/\//iu.test(module)) {
const [path, query = ""] = module.split("?", 2);
if (path !== undefined && path !== "") return { path, query: query === "" ? "" : `?${query}` };
}
} catch {
return undefined;
}
return undefined;
}
function remotePluginAssetRequestPath(remotePlugin: MachineScopedPluginIdParts, assetPath: string, requestUrl: string): string {
const query = requestUrl.includes("?") ? requestUrl.slice(requestUrl.indexOf("?")) : "";
return `/pi-web-plugins/${encodeURIComponent(remotePlugin.pluginId)}/${encodePathSegments(assetPath)}${query}`;
}
function encodePathSegments(path: string): string {
return path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
}
function parseRemoteManifest(value: unknown): RemotePluginManifest {
if (!isRecord(value) || !Array.isArray(value["plugins"])) throw new Error("Invalid remote PI WEB plugin manifest");
return {
plugins: value["plugins"].map((entry) => {
if (!isRecord(entry) || typeof entry["id"] !== "string" || !isPiWebPluginId(entry["id"]) || typeof entry["module"] !== "string" || entry["module"] === "") {
throw new Error("Invalid remote PI WEB plugin manifest entry");
}
return {
id: entry["id"],
module: entry["module"],
...(typeof entry["source"] === "string" ? { source: entry["source"] } : {}),
...(typeof entry["scope"] === "string" ? { scope: entry["scope"] } : {}),
};
}),
};
}
function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void {
for (const [name, value] of Object.entries(headers)) {
if (value === undefined) continue;
if (!SAFE_RESPONSE_HEADERS.has(name.toLowerCase())) continue;
reply.header(name, value);
}
}
function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown): FastifyReply {
const statusCode = error instanceof RemoteMachineRequestError ? error.statusCode : 502;
const label = statusCode === 504 ? "Remote machine timeout" : "Remote machine unavailable";
return reply.code(statusCode).send({
error: label,
machineId,
statusCode,
detail: error instanceof Error ? error.message : String(error),
});
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}