diff --git a/src/client/src/plugins/external.test.ts b/src/client/src/plugins/external.test.ts index bd3c224..592f17b 100644 --- a/src/client/src/plugins/external.test.ts +++ b/src/client/src/plugins/external.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { loadExternalPlugins } from "./external"; +import { loadExternalPlugins, resolvePluginModuleUrl } from "./external"; beforeEach(() => { vi.stubGlobal("document", { baseURI: "https://pi.example.test/" }); @@ -18,4 +18,35 @@ describe("external plugin manifests", () => { expect(fetchMock).toHaveBeenCalledWith("https://pi.example.test/pi-web-plugins/manifest.json", { cache: "no-store" }); }); + + it("loads manifest-relative modules from a nested deployment", async () => { + const manifestUrl = "https://pi.example.test/test/ai/pi-web-plugins/manifest.json"; + const fetchMock = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ + plugins: [{ id: "info", module: "./info/pi-web-plugin.js?v=1", machineSpecific: false }], + })))); + const moduleLoader = vi.fn(() => Promise.resolve({ + default: { apiVersion: 1, name: "Info", activate: () => ({ contributions: {} }) }, + })); + vi.stubGlobal("fetch", fetchMock); + + const registrations = await loadExternalPlugins(manifestUrl, { moduleLoader }); + + expect(fetchMock).toHaveBeenCalledWith(manifestUrl, { cache: "no-store" }); + expect(moduleLoader).toHaveBeenCalledWith("https://pi.example.test/test/ai/pi-web-plugins/info/pi-web-plugin.js?v=1"); + expect(registrations).toMatchObject([{ id: "info", machineSpecific: false, plugin: { apiVersion: 1, name: "Info" } }]); + }); + + it("treats root-style modules from existing manifests as application-root paths", () => { + const rootManifestUrl = "https://pi.example.test/pi-web-plugins/manifest.json"; + const nestedManifestUrl = "https://pi.example.test/test/ai/pi-web-plugins/manifest.json"; + + expect(resolvePluginModuleUrl("/pi-web-plugins/info/pi-web-plugin.js?v=1", rootManifestUrl, { + viteBaseUrl: "/", + documentBaseUrl: "https://pi.example.test/", + })).toBe("https://pi.example.test/pi-web-plugins/info/pi-web-plugin.js?v=1"); + expect(resolvePluginModuleUrl("/pi-web-plugins/info/pi-web-plugin.js?v=1", nestedManifestUrl, { + viteBaseUrl: "./", + documentBaseUrl: "https://pi.example.test/test/ai/", + })).toBe("https://pi.example.test/test/ai/pi-web-plugins/info/pi-web-plugin.js?v=1"); + }); }); diff --git a/src/client/src/plugins/external.ts b/src/client/src/plugins/external.ts index 9b598b2..1c8a014 100644 --- a/src/client/src/plugins/external.ts +++ b/src/client/src/plugins/external.ts @@ -1,5 +1,5 @@ import { machineScopedPluginId } from "../../../shared/machinePluginIds"; -import { resolveAppUrl } from "../appUrl"; +import { resolveAppUrl, type AppUrlContext } from "../appUrl"; import type { PiWebPlugin, PiWebPluginRegistration } from "./types"; export interface PluginManifestEntry { @@ -15,6 +15,7 @@ interface PluginManifest { export interface LoadExternalPluginsOptions { machineId?: string; shouldLoadPlugin?: (entry: PluginManifestEntry) => boolean; + moduleLoader?: (moduleUrl: string) => Promise; } export async function loadExternalPlugins(manifestUrl = "pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise { @@ -26,8 +27,8 @@ export async function loadExternalPlugins(manifestUrl = "pi-web-plugins/manifest for (const entry of manifest.plugins) { if (options.shouldLoadPlugin?.(entry) === false) continue; try { - const moduleUrl = new URL(entry.module, resolvedManifestUrl).toString(); - const module: unknown = await import(/* @vite-ignore */ moduleUrl); + const moduleUrl = resolvePluginModuleUrl(entry.module, resolvedManifestUrl); + const module = await (options.moduleLoader ?? importPluginModule)(moduleUrl); const plugin = parsePluginModule(module, moduleUrl); registrations.push({ id: options.machineId === undefined ? entry.id : machineScopedPluginId(options.machineId, entry.id), @@ -42,6 +43,15 @@ export async function loadExternalPlugins(manifestUrl = "pi-web-plugins/manifest return registrations; } +export function resolvePluginModuleUrl(moduleReference: string, manifestUrl: string, appUrlContext?: AppUrlContext): string { + if (!moduleReference.startsWith("/")) return new URL(moduleReference, manifestUrl).toString(); + return appUrlContext === undefined ? resolveAppUrl(moduleReference) : resolveAppUrl(moduleReference, appUrlContext); +} + +async function importPluginModule(moduleUrl: string): Promise { + return import(/* @vite-ignore */ moduleUrl); +} + async function fetchPluginManifest(manifestUrl: string): Promise { const response = await fetch(manifestUrl, { cache: "no-store" }); if (response.status === 404) return undefined; diff --git a/src/server/app.plugins.test.ts b/src/server/app.plugins.test.ts index d4e4d8e..5a1ff1e 100644 --- a/src/server/app.plugins.test.ts +++ b/src/server/app.plugins.test.ts @@ -9,15 +9,15 @@ describe("buildApp PI WEB plugin routes", () => { it("serves the PI WEB plugin manifest and plugin assets", async () => { const manifestResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" }); expect(manifestResponse.statusCode).toBe(200); - expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }); + expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "./fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }); const pluginsResponse = await appTestContext.app.inject({ method: "GET", url: "/api/plugins" }); expect(pluginsResponse.statusCode).toBe(200); - expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }); + expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "./fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }); const localMachinePluginsResponse = await appTestContext.app.inject({ method: "GET", url: "/api/machines/local/plugins" }); expect(localMachinePluginsResponse.statusCode).toBe(200); - expect(localMachinePluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }); + expect(localMachinePluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "./fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }); const assetResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" }); expect(assetResponse.statusCode).toBe(200); @@ -51,7 +51,7 @@ describe("buildApp PI WEB plugin routes", () => { expect(request).toHaveBeenCalledWith("GET", "/api/plugins", undefined); }); - it("rewrites and proxies remote machine plugin manifests and assets", async () => { + it("rewrites existing root-style remote plugin manifests and proxies their assets", async () => { const addResponse = await appTestContext.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({ @@ -68,10 +68,15 @@ describe("buildApp PI WEB plugin routes", () => { const manifestResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` }); const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools"); + const rewrittenModule = `../../../../pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`; 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", machineSpecific: true }], + plugins: [{ id: "remote-tools", module: rewrittenModule, source: "local", scope: "local", machineSpecific: true }], }); + expect(new URL(rewrittenModule, `https://gateway.example.test/api/machines/${remote.id}/pi-web-plugins/manifest.json`).toString()) + .toBe(`https://gateway.example.test/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`); + expect(new URL(rewrittenModule, `https://gateway.example.test/test/ai/api/machines/${remote.id}/pi-web-plugins/manifest.json`).toString()) + .toBe(`https://gateway.example.test/test/ai/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`); expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 }); const assetResponse = await appTestContext.app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123` }); @@ -82,7 +87,7 @@ describe("buildApp PI WEB plugin routes", () => { expect(request).toHaveBeenCalledWith("GET", "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123"); }); - it("drops unsafe remote machine plugin manifest modules", async () => { + it("accepts safe manifest-relative modules and drops unsafe remote modules", async () => { const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const remote = addResponse.json<{ id: string }>(); appTestContext.remoteClient = fakeRemoteClient({ @@ -91,8 +96,8 @@ describe("buildApp PI WEB plugin routes", () => { headers: { "content-type": "application/json" }, body: { plugins: [ - { id: "safe-tools", module: "nested/pi-web-plugin.js?v=1", source: "local", scope: "local" }, - { id: "traversal-tools", module: "..%2F..%2Fapi%2Fconfig", source: "local", scope: "local" }, + { id: "safe-tools", module: "./safe-tools/nested/pi-web-plugin.js?v=1", source: "local", scope: "local" }, + { id: "traversal-tools", module: "./traversal-tools/..%2F..%2Fapi%2Fconfig", source: "local", scope: "local" }, { id: "wrong-root", module: "/pi-web-plugins/other/pi-web-plugin.js", source: "local", scope: "local" }, ], }, @@ -103,7 +108,7 @@ describe("buildApp PI WEB plugin routes", () => { expect(manifestResponse.statusCode).toBe(200); expect(manifestResponse.json()).toEqual({ - plugins: [{ id: "safe-tools", module: `/pi-web-plugins/${machineScopedPluginId(remote.id, "safe-tools")}/nested/pi-web-plugin.js?v=1`, source: "local", scope: "local" }], + plugins: [{ id: "safe-tools", module: `../../../../pi-web-plugins/${machineScopedPluginId(remote.id, "safe-tools")}/nested/pi-web-plugin.js?v=1`, source: "local", scope: "local" }], }); }); diff --git a/src/server/app.testSupport.ts b/src/server/app.testSupport.ts index e863fb4..5844301 100644 --- a/src/server/app.testSupport.ts +++ b/src/server/app.testSupport.ts @@ -98,8 +98,8 @@ export function registerAppTestHooks(): void { config: fakeConfigService(), piPackages: fakePiPackageService(), piWebPlugins: { - manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }), - plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }), + manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "./fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }), + plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "./fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }), readAsset: fakePiWebPluginAsset, }, clientDist: false, diff --git a/src/server/machines/machinePluginProxyRoutes.ts b/src/server/machines/machinePluginProxyRoutes.ts index d4e0b8a..ba4a6ab 100644 --- a/src/server/machines/machinePluginProxyRoutes.ts +++ b/src/server/machines/machinePluginProxyRoutes.ts @@ -86,7 +86,7 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa if (modulePath === undefined) return []; return [{ ...plugin, - module: `${piWebBasePath()}/pi-web-plugins/${encodeURIComponent(machineScopedPluginId(machineId, plugin.id))}/${modulePath.path}${modulePath.query}`, + module: `../../../../pi-web-plugins/${encodeURIComponent(machineScopedPluginId(machineId, plugin.id))}/${modulePath.path}${modulePath.query}`, }]; }), }; @@ -95,10 +95,10 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa function remotePluginModulePath(pluginId: string, module: string): { path: string; query: string } | undefined { if (!isPiWebPluginId(pluginId)) return undefined; const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`; - const base = new URL(prefix, "http://pi-web.local"); + const manifestUrl = new URL("/pi-web-plugins/manifest.json", "http://pi-web.local"); try { - const url = new URL(module, base); - if (url.origin !== base.origin || !url.pathname.startsWith(prefix)) return undefined; + const url = new URL(module, manifestUrl); + if (url.origin !== manifestUrl.origin || !url.pathname.startsWith(prefix)) return undefined; const path = safeRemotePluginAssetPath(url.pathname.slice(prefix.length)); return path === undefined ? undefined : { path, query: url.search }; } catch { @@ -190,11 +190,6 @@ function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown }); } -function piWebBasePath(): string { - const basePath = process.env["PI_WEB_BASE_PATH"] ?? ""; - return basePath.replace(/\/$/u, ""); -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 6b00d34..1f650fe 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -37,7 +37,7 @@ describe("PiWebPluginService", () => { plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })], }); const manifest = await service.manifest(); - expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/info\/pi-web-plugin\.js\?v=\d+$/u); + expect(manifest.plugins[0]?.module).toMatch(/^\.\/info\/pi-web-plugin\.js\?v=\d+$/u); const asset = await service.readAsset("info", "pi-web-plugin.js"); expect(asset?.contentType).toBe("application/javascript; charset=utf-8"); @@ -105,7 +105,7 @@ describe("PiWebPluginService", () => { const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); const manifest = await service.manifest(); - const moduleUrl = new URL(manifest.plugins[0]?.module ?? "", "http://pi-web.test"); + const moduleUrl = new URL(manifest.plugins[0]?.module ?? "", "http://pi-web.test/pi-web-plugins/manifest.json"); expect(moduleUrl.pathname).toBe("/pi-web-plugins/updates/pi-web-plugin.js"); expect(moduleUrl.searchParams.get("v")).toMatch(/^\d+$/u); expect(moduleUrl.searchParams.get("piWebDockerMode")).toBe("dev"); @@ -127,7 +127,7 @@ describe("PiWebPluginService", () => { const manifest = await service.manifest(); expect(manifest.plugins).toHaveLength(1); expect(manifest.plugins[0]).toMatchObject({ id: "review", source: "npm:@acme/review", scope: "user" }); - expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u); + expect(manifest.plugins[0]?.module).toMatch(/^\.\/review\/dist\/review\.js\?v=\d+$/u); }); it("refreshes Pi package plugin discovery after Pi package settings change", async () => { @@ -236,7 +236,7 @@ describe("PiWebPluginService", () => { expect(manifest.plugins).toEqual([ expect.objectContaining({ id: "duplicate", source: "first", machineSpecific: false }), ]); - expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/duplicate\/first\.js\?v=\d+$/u); + expect(manifest.plugins[0]?.module).toMatch(/^\.\/duplicate\/first\.js\?v=\d+$/u); }); it("skips legacy metadata shortcuts and unsafe module paths", async () => { diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 61b40dd..7769b91 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -50,11 +50,6 @@ interface PiWebPluginServiceOptions { configProvider?: () => PiWebConfig; } -function piWebBasePath(): string { - const basePath = process.env["PI_WEB_BASE_PATH"] ?? ""; - return basePath.replace(/\/$/u, ""); -} - interface LocalPluginRoot { path: string; source: string; @@ -140,7 +135,7 @@ export class PiWebPluginService { private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo { return { id: plugin.id, - module: `${piWebBasePath()}/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?${pluginModuleQuery(plugin)}`, + module: `./${encodeURIComponent(plugin.id)}/${plugin.entryFile}?${pluginModuleQuery(plugin)}`, source: plugin.source, scope: plugin.scope, machineSpecific: plugin.machineSpecific,