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
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Load trusted PI WEB plugins from selected federated machines with machine-scoped actions, workspace panels, labels, and proxied plugin assets.
+30 -3
View File
@@ -69,6 +69,7 @@
<a href="#example">Canonical example</a>
<a href="#built-in-plugins">Built-in plugins</a>
<a href="#manage-plugins">Manage plugins</a>
<a href="#remote-machine-plugins">Remote machine plugins</a>
<a href="#production">Production usage</a>
<a href="#agent-docs">AI-friendly docs</a>
<a href="#develop">Develop and debug</a>
@@ -258,9 +259,10 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
<section id="manage-plugins">
<h2>Manage plugins</h2>
<p>
Open <strong>Settings → Plugins</strong> to review discovered bundled, local, dev, and Pi package plugins.
PI WEB can disable any discovered plugin before the browser imports it. Core app contributions such as
the command palette, base workspace tools, and themes are not managed through this plugin list.
Open <strong>Settings → Plugins</strong> to review discovered bundled, local, dev, and Pi package plugins
for the PI WEB gateway you opened. PI WEB can disable any discovered plugin before the browser imports
it. Core app contributions such as the command palette, base workspace tools, and themes are not managed
through this plugin list.
</p>
<div class="code-card">
<div class="copy-row">
@@ -290,6 +292,31 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
</p>
</section>
<section id="remote-machine-plugins">
<h2>Remote machine plugins</h2>
<p>
With machine federation, PI WEB also loads discovered plugins from the selected remote machine. Remote
plugins are trusted browser-side code like local plugins, but their actions, workspace panels, and
workspace labels only appear while that machine is selected.
</p>
<ul>
<li>File and terminal helpers run against the selected remote machine.</li>
<li>Remote plugin code is loaded best-effort through the current gateway and cached for the page lifetime.</li>
<li>Remote theme contributions are ignored for now because themes are app-wide.</li>
<li>Mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible.</li>
</ul>
<p>
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable
one, open that machine directly or update its config file.
</p>
<p>
For portable plugin assets, prefer URLs relative to the plugin module, such as
<code>new URL("./asset.json", import.meta.url)</code>. If a remote plugin constructs absolute asset URLs,
it should use the <code>pluginId</code> from <code>activate()</code>; hard-coded
<code>/pi-web-plugins/&lt;original-id&gt;/...</code> URLs may point at the gateway instead of the remote machine.
</p>
</section>
<section id="production">
<h2>Production usage</h2>
<p>
+24 -2
View File
@@ -129,9 +129,29 @@ ln -s /path/to/plugin-folder ~/.pi-web/plugins/plugin-id
Reload the PI WEB browser tab. PI WEB serves plugin modules with an mtime-based `?v=` cache buster. After editing a plugin, hard reload the browser if you do not see changes.
## Remote machine plugins
When machine federation is enabled, PI WEB also loads discovered plugins from the selected remote machine. Remote plugins are trusted browser-side code like local plugins, but their contributions are machine-scoped:
- actions, workspace panels, and workspace labels only appear while that machine is selected;
- plugin file and terminal helpers run against that machine;
- plugin code is loaded best-effort through the current gateway and cached for the browser page lifetime;
- remote theme contributions are ignored for now because themes are app-wide;
- mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible.
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable a remote machine plugin, open that machine directly or update its config file.
For portable plugin assets, prefer URLs relative to the plugin module, for example:
```js
const url = new URL("./asset.json", import.meta.url);
```
If a remote plugin constructs absolute asset URLs, it should use the `pluginId` from `activate()` because PI WEB gives remote plugins a gateway-scoped runtime id. Hard-coded `/pi-web-plugins/<original-id>/...` URLs may point at the gateway instead of the remote machine.
## Manage plugins
Open **Settings → Plugins** to review discovered bundled, local, dev, and Pi package plugins. PI WEB can disable any discovered plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list.
Open **Settings → Plugins** to review discovered bundled, local, dev, and Pi package plugins for the PI WEB gateway you opened. PI WEB can disable any discovered gateway plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list.
Plugin preferences are stored under the top-level `plugins` config key in the PI WEB config file:
@@ -231,7 +251,7 @@ Review task configs before running them, especially in shared projects. Workspac
## Discovery and packaging
PI WEB builds `/pi-web-plugins/manifest.json` from these sources:
PI WEB builds the gateway `/pi-web-plugins/manifest.json` from these sources:
1. Bundled plugins in the PI WEB package:
@@ -249,6 +269,8 @@ PI WEB builds `/pi-web-plugins/manifest.json` from these sources:
3. Installed Pi packages that expose PI WEB plugin metadata. Pi packages may be user or project scoped.
Remote machines expose their own manifests through the gateway at `/api/machines/<machine-id>/pi-web-plugins/manifest.json`. Those plugin modules are rewritten to gateway-scoped asset URLs and registered under machine-scoped runtime ids so duplicate plugin ids on different machines do not collide.
Plugin package directory names and plugin ids must be valid identifiers:
```text
+32 -3
View File
@@ -19,7 +19,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types";
import { RealtimeSocket } from "../sessionSocket";
import type { PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
import type { PiWebPluginRegistration, PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
import { corePlugin } from "../plugins/core";
import { themePackPlugin } from "../plugins/themes";
@@ -145,6 +145,8 @@ export class PiWebApp extends LitElement {
private routeRestoreDepth = 0;
private restoringRouteTerminalId: string | undefined;
private readonly plugins = createPluginRegistry();
private readonly loadedMachinePluginIds = new Set<string>();
private readonly machinePluginLoadPromises = new Map<string, Promise<void>>();
private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE;
@state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID;
@state() private isRefreshingApp = false;
@@ -331,6 +333,8 @@ export class PiWebApp extends LitElement {
this.restoringRouteTerminalId = routeSurface.selectedTerminalId;
try {
await this.restoreRouteMachine(route, false);
const selectedMachinePluginLoad = this.loadPluginsForSelectedMachine();
if (route.tool?.startsWith("machine.") === true) await selectedMachinePluginLoad;
if (!this.isCurrentRouteRestore(restoreSeq)) return;
this.setState({
workspaceTool: route.tool ?? this.state.workspaceTool,
@@ -710,6 +714,7 @@ export class PiWebApp extends LitElement {
this.connectRealtime();
this.activeTerminalIds.clear();
this.git.updatePolling();
void this.loadPluginsForSelectedMachine();
}
private refreshSelectedWorkspaceTool(tool: QualifiedContributionId): void {
@@ -945,8 +950,30 @@ export class PiWebApp extends LitElement {
}
private async loadExternalPlugins(): Promise<void> {
await this.registerExternalPlugins("PI WEB plugins", () => loadExternalPlugins());
}
private async loadPluginsForSelectedMachine(): Promise<void> {
const machine = this.state.selectedMachine;
if (machine?.kind !== "remote") return;
await this.loadPluginsForMachine(machine);
}
private async loadPluginsForMachine(machine: Machine): Promise<void> {
if (machine.kind !== "remote" || this.loadedMachinePluginIds.has(machine.id)) return;
const existing = this.machinePluginLoadPromises.get(machine.id);
if (existing !== undefined) return existing;
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, { machineId: machine.id }))
.then((loaded) => { if (loaded) this.loadedMachinePluginIds.add(machine.id); })
.finally(() => { this.machinePluginLoadPromises.delete(machine.id); });
this.machinePluginLoadPromises.set(machine.id, load);
await load;
}
private async registerExternalPlugins(label: string, load: () => Promise<PiWebPluginRegistration[]>): Promise<boolean> {
try {
const registrations = await loadExternalPlugins();
const registrations = await load();
for (const registration of registrations) {
try {
this.plugins.register(registration);
@@ -956,8 +983,10 @@ export class PiWebApp extends LitElement {
}
this.applyPreferredTheme(false);
this.requestUpdate();
return true;
} catch (error) {
console.warn("Failed to load external PI WEB plugins", error);
console.warn(`Failed to load ${label}`, error);
return false;
}
}
+11 -2
View File
@@ -1,3 +1,4 @@
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
interface PluginManifestEntry {
@@ -9,7 +10,11 @@ interface PluginManifest {
plugins: PluginManifestEntry[];
}
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json"): Promise<PiWebPluginRegistration[]> {
export interface LoadExternalPluginsOptions {
machineId?: string;
}
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
const manifest = await fetchPluginManifest(manifestUrl);
if (manifest === undefined) return [];
@@ -19,7 +24,11 @@ export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifes
const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).toString();
const module: unknown = await import(/* @vite-ignore */ moduleUrl);
const plugin = parsePluginModule(module, moduleUrl);
registrations.push({ id: entry.id, plugin });
registrations.push({
id: options.machineId === undefined ? entry.id : machineScopedPluginId(options.machineId, entry.id),
plugin,
...(options.machineId === undefined ? {} : { machineId: options.machineId, sourcePluginId: entry.id }),
});
} catch (error) {
console.warn(`Failed to load PI WEB plugin ${entry.module}`, error);
}
+72 -1
View File
@@ -1,11 +1,13 @@
import { html } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { SessionInfo, Workspace } from "../api";
import { initialAppState, type AppState } from "../appState";
import { markCachedNewSessionInfo } from "../cachedNewSessions";
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
import { corePlugin } from "./core";
import { PluginRegistry } from "./registry";
import { themePackPlugin } from "./themes";
import type { PluginRuntimeContext, ThemeTokens } from "./types";
import type { PluginRuntimeContext, ThemeTokens, WorkspacePanelContext } from "./types";
function createContext(statePatch: Partial<AppState> = {}) {
const calls: string[] = [];
@@ -289,12 +291,81 @@ describe("PluginRegistry", () => {
{ type: "text", text: "last" },
]);
});
it("only exposes machine-scoped plugin contributions for their machine", () => {
const registry = new PluginRegistry();
const pluginId = machineScopedPluginId("remote-1", "project-tools");
const workspace = testWorkspace();
registry.register({
id: pluginId,
machineId: "remote-1",
sourcePluginId: "project-tools",
plugin: {
apiVersion: 1,
name: "Project Tools",
activate: () => ({
contributions: {
actions: [{ id: "do-thing", title: "Do Thing", run: () => undefined }],
workspacePanels: [{ id: "workspace.tools", title: "Tools", render: () => html`<p>Tools</p>` }],
workspaceLabels: [{ id: "badge", items: () => [{ type: "text", text: "remote" }] }],
themes: [{ id: "remote-theme", name: "Remote Theme", colorScheme: "dark", tokens: testThemeTokens() }],
},
}),
},
});
expect(registry.getActions(createContext().context).map((action) => action.id)).not.toContain(`${pluginId}:do-thing`);
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).toContain(`${pluginId}:do-thing`);
const panel = registry.getWorkspacePanels().find((candidate) => candidate.id === `${pluginId}:workspace.tools`);
expect(panel?.visible?.(createWorkspacePanelContext("local"))).toBe(false);
expect(panel?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
expect(registry.getWorkspaceLabelItems(initialAppState(), workspace)).toEqual([]);
expect(registry.getWorkspaceLabelItems({ ...initialAppState(), selectedMachine: testMachine("remote-1") }, workspace)).toEqual([{ type: "text", text: "remote" }]);
expect(registry.getThemes()).toEqual([]);
});
});
function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false, ...patch };
}
function createWorkspacePanelContext(machineId: string): WorkspacePanelContext {
const workspace = testWorkspace();
return {
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
workspace,
state: { ...initialAppState(), selectedMachine: testMachine(machineId) },
files: { readFile: vi.fn() },
terminal: { open: vi.fn(), runCommand: vi.fn() },
host: { requestRender: vi.fn() },
fileTree: [],
expandedDirs: {},
selectedFilePath: undefined,
selectedFileContent: undefined,
fileTreeStale: false,
gitStatus: undefined,
selectedDiffPath: undefined,
selectedDiff: undefined,
selectedStagedDiff: undefined,
gitStale: false,
activeTerminalCount: 0,
selectedTerminalId: undefined,
terminalAutoStart: false,
onRefreshFiles: vi.fn(),
onExpandDir: vi.fn(),
onSelectFile: vi.fn(),
onRefreshGit: vi.fn(),
onSelectDiff: vi.fn(),
onSelectTerminal: vi.fn(),
};
}
function testMachine(id: string) {
return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" };
}
function testSession(patch: Partial<SessionInfo> = {}): SessionInfo {
return {
id: "s1",
+36 -13
View File
@@ -12,6 +12,7 @@ type RegisteredPluginAction = Omit<PluginAction, "id"> & {
id: QualifiedContributionId;
pluginId: string;
localId: string;
machineId?: string;
};
export class PluginRegistry {
@@ -33,21 +34,24 @@ export class PluginRegistry {
if (apiVersion !== 1) throw new Error(`Unsupported plugin API version for ${id}: ${String(apiVersion)}`);
const result = plugin.activate({ apiVersion: 1, pluginId: id, html, svg });
const contributions = result.contributions;
for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action));
for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel));
for (const contribution of contributions.workspaceLabels ?? []) this.workspaceLabels.push(this.qualifyWorkspaceLabelContribution(id, contribution));
for (const theme of contributions.themes ?? []) this.themes.push(this.qualifyTheme(id, theme));
for (const pair of contributions.themePairs ?? []) this.themePairs.push(this.qualifyThemePair(id, pair));
for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action, registration.machineId));
for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel, registration.machineId));
for (const contribution of contributions.workspaceLabels ?? []) this.workspaceLabels.push(this.qualifyWorkspaceLabelContribution(id, contribution, registration.machineId));
if (registration.machineId === undefined) {
for (const theme of contributions.themes ?? []) this.themes.push(this.qualifyTheme(id, theme));
for (const pair of contributions.themePairs ?? []) this.themePairs.push(this.qualifyThemePair(id, pair));
}
}
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
return this.actions.map((action) => {
return this.actions.filter((action) => isActiveForMachine(action.machineId, runtimeContextMachineId(context))).map((action) => {
const scopedContext = pluginRuntimeContextFor(context, action.pluginId);
const enabled = action.enabled?.(scopedContext);
const qualified: QualifiedPluginAction = {
id: action.id,
pluginId: action.pluginId,
localId: action.localId,
...(action.machineId === undefined ? {} : { machineId: action.machineId }),
title: action.title,
run: () => action.run(scopedContext),
};
@@ -81,12 +85,12 @@ export class PluginRegistry {
});
}
private qualifyAction(pluginId: string, action: PluginAction): RegisteredPluginAction {
private qualifyAction(pluginId: string, action: PluginAction, machineId: string | undefined): RegisteredPluginAction {
const id = this.qualify(pluginId, action.id);
return { ...action, id, pluginId, localId: action.id };
return { ...action, id, pluginId, localId: action.id, ...(machineId === undefined ? {} : { machineId }) };
}
private qualifyWorkspacePanel(pluginId: string, panel: WorkspacePanelContribution): QualifiedWorkspacePanelContribution {
private qualifyWorkspacePanel(pluginId: string, panel: WorkspacePanelContribution, machineId: string | undefined): QualifiedWorkspacePanelContribution {
const id = this.qualify(pluginId, panel.id);
const badge = panel.badge;
const visible = panel.visible;
@@ -95,15 +99,26 @@ export class PluginRegistry {
id,
pluginId,
localId: panel.id,
...(visible === undefined ? {} : { visible: (context: WorkspacePanelContext) => visible(workspacePanelContextFor(context, pluginId)) }),
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => badge(workspacePanelContextFor(context, pluginId)) }),
...(machineId === undefined ? {} : { machineId }),
visible: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id) && (visible?.(workspacePanelContextFor(context, pluginId)) ?? true),
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id) ? badge(workspacePanelContextFor(context, pluginId)) : undefined }),
render: (context: WorkspacePanelContext) => panel.render(workspacePanelContextFor(context, pluginId)),
};
}
private qualifyWorkspaceLabelContribution(pluginId: string, contribution: WorkspaceLabelContribution): QualifiedWorkspaceLabelContribution {
private qualifyWorkspaceLabelContribution(pluginId: string, contribution: WorkspaceLabelContribution, machineId: string | undefined): QualifiedWorkspaceLabelContribution {
const id = this.qualify(pluginId, contribution.id);
return { ...contribution, id, pluginId, localId: contribution.id };
const visible = contribution.visible;
const items = contribution.items;
return {
...contribution,
id,
pluginId,
localId: contribution.id,
...(machineId === undefined ? {} : { machineId }),
visible: (context) => isActiveForMachine(machineId, context.machine.id) && (visible?.(context) ?? true),
items: (context) => isActiveForMachine(machineId, context.machine.id) ? items(context) : [],
};
}
private qualifyTheme(pluginId: string, theme: ThemeContribution): QualifiedThemeContribution {
@@ -163,6 +178,14 @@ export function installWorkspacePanelScope(context: WorkspacePanelContext, scope
return context;
}
function isActiveForMachine(machineId: string | undefined, selectedMachineId: string): boolean {
return machineId === undefined || machineId === selectedMachineId;
}
function runtimeContextMachineId(context: PluginRuntimeContext): string {
return context.state.selectedMachine?.id ?? "local";
}
function pluginMachineFromState(state: Pick<AppState, "selectedMachine">): PluginMachine {
const machine = state.selectedMachine;
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
+5
View File
@@ -12,6 +12,8 @@ export type SvgTemplateTag = (strings: TemplateStringsArray, ...values: unknown[
export interface PiWebPluginRegistration {
id: PluginId;
plugin: PiWebPlugin;
machineId?: string;
sourcePluginId?: PluginId;
}
export interface PiWebPlugin {
@@ -112,6 +114,7 @@ export interface PluginAction {
export interface QualifiedPluginAction extends AppAction {
pluginId: PluginId;
localId: LocalContributionId;
machineId?: string;
}
export interface WorkspacePanelContext {
@@ -159,6 +162,7 @@ export interface QualifiedWorkspacePanelContribution extends WorkspacePanelContr
id: QualifiedContributionId;
pluginId: PluginId;
localId: LocalContributionId;
machineId?: string;
}
export interface WorkspaceLabelContext {
@@ -272,4 +276,5 @@ export interface QualifiedWorkspaceLabelContribution extends WorkspaceLabelContr
id: QualifiedContributionId;
pluginId: PluginId;
localId: LocalContributionId;
machineId?: string;
}
+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);
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { machineScopedPluginId, parseMachineScopedPluginId } from "./machinePluginIds";
describe("machine-scoped plugin ids", () => {
it("encodes machine ids into valid plugin ids and decodes them", () => {
const scoped = machineScopedPluginId("550e8400-e29b-41d4-a716-446655440000", "project-tools");
expect(scoped).toMatch(/^machine\.[0-9a-f]+\.project-tools$/u);
expect(parseMachineScopedPluginId(scoped)).toEqual({ machineId: "550e8400-e29b-41d4-a716-446655440000", pluginId: "project-tools" });
});
it("leaves normal plugin ids unparsed", () => {
expect(parseMachineScopedPluginId("project-tools")).toBeUndefined();
});
});
+47
View File
@@ -0,0 +1,47 @@
import { isPiWebPluginId } from "./pluginIds.js";
const MACHINE_PLUGIN_ID_PREFIX = "machine.";
export interface MachineScopedPluginIdParts {
machineId: string;
pluginId: string;
}
export function machineScopedPluginId(machineId: string, pluginId: string): string {
if (machineId === "") throw new Error("Machine id is required");
if (!isPiWebPluginId(pluginId)) throw new Error(`Invalid PI WEB plugin id: ${pluginId}`);
return `${MACHINE_PLUGIN_ID_PREFIX}${stringToHex(machineId)}.${pluginId}`;
}
export function parseMachineScopedPluginId(pluginId: string): MachineScopedPluginIdParts | undefined {
if (!pluginId.startsWith(MACHINE_PLUGIN_ID_PREFIX)) return undefined;
const rest = pluginId.slice(MACHINE_PLUGIN_ID_PREFIX.length);
const separator = rest.indexOf(".");
if (separator < 1) return undefined;
const encodedMachineId = rest.slice(0, separator);
const sourcePluginId = rest.slice(separator + 1);
if (!isHexString(encodedMachineId) || !isPiWebPluginId(sourcePluginId)) return undefined;
const machineId = hexToString(encodedMachineId);
if (machineId === "") return undefined;
return { machineId, pluginId: sourcePluginId };
}
function stringToHex(value: string): string {
return [...new TextEncoder().encode(value)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function hexToString(value: string): string {
const bytes = new Uint8Array(value.length / 2);
for (let index = 0; index < value.length; index += 2) {
bytes[index / 2] = Number.parseInt(value.slice(index, index + 2), 16);
}
return new TextDecoder().decode(bytes);
}
function isHexString(value: string): boolean {
return value.length > 0 && value.length % 2 === 0 && /^[0-9a-f]+$/u.test(value);
}