Archived
Merge branch 'main' into review-issue-48
This commit is contained in:
@@ -5,9 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>PI WEB</title>
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="%BASE_URL%apple-touch-icon.png" />
|
||||
<link rel="manifest" href="%BASE_URL%manifest.webmanifest" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
"name": "PI WEB",
|
||||
"short_name": "PI WEB",
|
||||
"description": "Remote web UI and browser control plane for persistent Pi Coding Agent sessions.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#0d1117",
|
||||
"theme_color": "#0d1117",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pwa-icon-192.png",
|
||||
"src": "./pwa-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-icon-512.png",
|
||||
"src": "./pwa-icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import type { PiWebConfigValues, TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||
import { configApi, filesApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
@@ -40,6 +40,10 @@ const commandRun: TerminalCommandRun = {
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -51,7 +55,7 @@ describe("machine-scoped runtime API", () => {
|
||||
await piWebApi.piWebStatus("remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/pi-web/status");
|
||||
});
|
||||
|
||||
it("requests an uncached update check through the local status route", async () => {
|
||||
@@ -60,7 +64,7 @@ describe("machine-scoped runtime API", () => {
|
||||
await piWebApi.checkForUpdates();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/pi-web/status?refresh=1");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/pi-web/status?refresh=1");
|
||||
expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store");
|
||||
});
|
||||
|
||||
@@ -70,7 +74,7 @@ describe("machine-scoped runtime API", () => {
|
||||
await piWebApi.checkForUpdates("remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status?refresh=1");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/pi-web/status?refresh=1");
|
||||
expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store");
|
||||
});
|
||||
|
||||
@@ -80,7 +84,7 @@ describe("machine-scoped runtime API", () => {
|
||||
await machinesApi.runtime("remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/runtime");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,9 +101,9 @@ describe("settings config and plugin APIs", () => {
|
||||
await expect(pluginsApi.plugins()).resolves.toEqual(piWebPluginsResponse());
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/config",
|
||||
"/api/config",
|
||||
"/api/plugins",
|
||||
"https://pi.example.test/api/config",
|
||||
"https://pi.example.test/api/config",
|
||||
"https://pi.example.test/api/plugins",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
|
||||
@@ -117,9 +121,9 @@ describe("settings config and plugin APIs", () => {
|
||||
await expect(pluginsApi.plugins("remote a")).resolves.toEqual(piWebPluginsResponse());
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/machines/remote%20a/config",
|
||||
"/api/machines/remote%20a/config",
|
||||
"/api/machines/remote%20a/plugins",
|
||||
"https://pi.example.test/api/machines/remote%20a/config",
|
||||
"https://pi.example.test/api/machines/remote%20a/config",
|
||||
"https://pi.example.test/api/machines/remote%20a/plugins",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
|
||||
@@ -144,11 +148,11 @@ describe("Pi package API", () => {
|
||||
await piPackagesApi.update();
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/pi-packages",
|
||||
"/api/pi-packages/install",
|
||||
"/api/pi-packages/remove",
|
||||
"/api/pi-packages/update",
|
||||
"/api/pi-packages/update",
|
||||
"https://pi.example.test/api/pi-packages",
|
||||
"https://pi.example.test/api/pi-packages/install",
|
||||
"https://pi.example.test/api/pi-packages/remove",
|
||||
"https://pi.example.test/api/pi-packages/update",
|
||||
"https://pi.example.test/api/pi-packages/update",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ source: "npm:@acme/new-tools" });
|
||||
@@ -174,11 +178,11 @@ describe("Pi package API", () => {
|
||||
await piPackagesApi.update(undefined, "remote a");
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/machines/local/pi-packages",
|
||||
"/api/machines/remote%20a/pi-packages",
|
||||
"/api/machines/remote%20a/pi-packages/install",
|
||||
"/api/machines/remote%20a/pi-packages/remove",
|
||||
"/api/machines/remote%20a/pi-packages/update",
|
||||
"https://pi.example.test/api/machines/local/pi-packages",
|
||||
"https://pi.example.test/api/machines/remote%20a/pi-packages",
|
||||
"https://pi.example.test/api/machines/remote%20a/pi-packages/install",
|
||||
"https://pi.example.test/api/machines/remote%20a/pi-packages/remove",
|
||||
"https://pi.example.test/api/machines/remote%20a/pi-packages/update",
|
||||
]);
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "npm:@acme/new-tools" });
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "../project-tools" });
|
||||
@@ -196,10 +200,10 @@ describe("session API compatibility", () => {
|
||||
await expect(sessionsApi.cleanup({ archiveIdleDays: 7, projectCwds: ["/repo"] }, "remote a")).resolves.toEqual(executed);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/sessions/cleanup/preview");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/cleanup/preview");
|
||||
expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ archiveIdleDays: 7, deleteArchivedDays: null });
|
||||
expect(fetchCall(fetchMock, 1)[0]).toBe("/api/machines/remote%20a/sessions/cleanup");
|
||||
expect(fetchCall(fetchMock, 1)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/cleanup");
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ archiveIdleDays: 7, projectCwds: ["/repo"] });
|
||||
});
|
||||
@@ -213,10 +217,10 @@ describe("session API compatibility", () => {
|
||||
await expect(sessionsApi.deleteArchivedMany([{ id: "s 1", cwd: "/repo" }], "remote a")).resolves.toEqual(deleted);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/sessions/bulk/archive");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/bulk/archive");
|
||||
expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }, { id: "s 2" }] });
|
||||
expect(fetchCall(fetchMock, 1)[0]).toBe("/api/machines/remote%20a/sessions/bulk/delete-archived");
|
||||
expect(fetchCall(fetchMock, 1)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/bulk/delete-archived");
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] });
|
||||
});
|
||||
@@ -228,7 +232,7 @@ describe("session API compatibility", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/sessions/s%201/prompt");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt");
|
||||
expect(JSON.parse(requestBody(init))).toEqual({ text: "hello", streamingBehavior: "followUp" });
|
||||
});
|
||||
|
||||
@@ -239,7 +243,7 @@ describe("session API compatibility", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/sessions/s%201/prompt");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt");
|
||||
expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo", text: "hello" });
|
||||
});
|
||||
});
|
||||
@@ -251,7 +255,7 @@ describe("machine-scoped file suggestion API", () => {
|
||||
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a", workspaceScoped: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked");
|
||||
});
|
||||
|
||||
it("falls back to the legacy cwd route when workspace-scoped suggestions are not enabled", async () => {
|
||||
@@ -260,7 +264,18 @@ describe("machine-scoped file suggestion API", () => {
|
||||
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("machine-scoped workspace API", () => {
|
||||
it("keeps project ids in one encoded route segment when listing workspaces", async () => {
|
||||
const fetchMock = stubJsonFetch([]);
|
||||
|
||||
await workspacesApi.workspaces("../p /?", "remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/projects/..%2Fp%20%2F%3F/workspaces");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,7 +287,7 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1");
|
||||
expect(init?.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
@@ -283,7 +298,7 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(init))).toEqual({ origin: "core", title: "Build", command: "npm test", metadata: {} });
|
||||
});
|
||||
@@ -295,7 +310,7 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals");
|
||||
expect(init?.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
@@ -311,9 +326,9 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
await terminalsApi.cancelCommandRun("run 1", "remote a");
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D",
|
||||
"/api/machines/remote%20a/terminal-command-runs/run%201",
|
||||
"/api/machines/remote%20a/terminal-command-runs/run%201/cancel",
|
||||
"https://pi.example.test/api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D",
|
||||
"https://pi.example.test/api/machines/remote%20a/terminal-command-runs/run%201",
|
||||
"https://pi.example.test/api/machines/remote%20a/terminal-command-runs/run%201/cancel",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 2)[1]?.method).toBe("POST");
|
||||
});
|
||||
@@ -323,7 +338,7 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
|
||||
await expect(terminalsApi.getCommandRun("missing", "remote-a")).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote-a/terminal-command-runs/missing");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote-a/terminal-command-runs/missing");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -335,7 +350,7 @@ describe("workspace file write API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt");
|
||||
expect(init?.method).toBe("PUT");
|
||||
expect(new Headers(init?.headers).get("content-type")).toBe("text/plain");
|
||||
});
|
||||
@@ -348,7 +363,7 @@ describe("workspace file write API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png");
|
||||
expect(init?.method).toBe("PUT");
|
||||
expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream");
|
||||
});
|
||||
@@ -386,7 +401,7 @@ describe("workspace file write API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url] = fetchCall(fetchMock, 0);
|
||||
expect(url).toContain("/api/machines/remote%20a/");
|
||||
expect(url).toContain("api/machines/remote%20a/");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
|
||||
import { resolveAppUrl } from "../appUrl";
|
||||
import { request } from "./http";
|
||||
import {
|
||||
arrayOf,
|
||||
@@ -49,9 +50,9 @@ import {
|
||||
parseWorkspace,
|
||||
parseWorkspaceActivityResponse,
|
||||
} from "./parsers";
|
||||
import { machineGitDiffUrl, messageUrl } from "./urls";
|
||||
import { machineGitDiffPath, messagePath } from "./urls";
|
||||
|
||||
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
|
||||
const machinePrefix = (machineId = "local") => `api/machines/${encodeURIComponent(machineId)}`;
|
||||
|
||||
type SessionLookup = SessionRef | string;
|
||||
|
||||
@@ -63,20 +64,20 @@ function sessionCwd(session: SessionLookup): string | undefined {
|
||||
return typeof session === "string" ? undefined : session.cwd;
|
||||
}
|
||||
|
||||
function sessionBaseUrl(session: SessionLookup, machineId = "local"): string {
|
||||
function sessionBasePath(session: SessionLookup, machineId = "local"): string {
|
||||
return `${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId(session))}`;
|
||||
}
|
||||
|
||||
function sessionUrl(session: SessionLookup, endpoint: string, machineId = "local"): string {
|
||||
return `${sessionBaseUrl(session, machineId)}/${endpoint}`;
|
||||
function sessionPath(session: SessionLookup, endpoint: string, machineId = "local"): string {
|
||||
return `${sessionBasePath(session, machineId)}/${endpoint}`;
|
||||
}
|
||||
|
||||
function sessionQueryUrl(session: SessionLookup, endpoint: string, machineId = "local"): string {
|
||||
return `${sessionUrl(session, endpoint, machineId)}${sessionQuery(session)}`;
|
||||
function sessionQueryPath(session: SessionLookup, endpoint: string, machineId = "local"): string {
|
||||
return `${sessionPath(session, endpoint, machineId)}${sessionQuery(session)}`;
|
||||
}
|
||||
|
||||
function sessionBaseQueryUrl(session: SessionLookup, machineId = "local"): string {
|
||||
return `${sessionBaseUrl(session, machineId)}${sessionQuery(session)}`;
|
||||
function sessionBaseQueryPath(session: SessionLookup, machineId = "local"): string {
|
||||
return `${sessionBasePath(session, machineId)}${sessionQuery(session)}`;
|
||||
}
|
||||
|
||||
function sessionQuery(session: SessionLookup): string {
|
||||
@@ -99,59 +100,59 @@ function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef
|
||||
return cwd === undefined || cwd === "" ? { id } : { id, cwd };
|
||||
}
|
||||
|
||||
function piWebStatusUrl(machineId: string): string {
|
||||
return machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`;
|
||||
function piWebStatusPath(machineId: string): string {
|
||||
return machineId === "local" ? "api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`;
|
||||
}
|
||||
|
||||
export const piWebApi = {
|
||||
piWebStatus: (machineId = "local") => request(piWebStatusUrl(machineId), parsePiWebStatusResponse),
|
||||
checkForUpdates: (machineId = "local") => request(`${piWebStatusUrl(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }),
|
||||
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
|
||||
piWebStatus: (machineId = "local") => request(piWebStatusPath(machineId), parsePiWebStatusResponse),
|
||||
checkForUpdates: (machineId = "local") => request(`${piWebStatusPath(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }),
|
||||
piWebRuntime: () => request("api/pi-web/runtime", parsePiWebRuntimeResponse),
|
||||
};
|
||||
|
||||
export const machinesApi = {
|
||||
machines: () => request("/api/machines", parseMachinesResponse),
|
||||
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("/api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
|
||||
deleteMachine: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
|
||||
health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
|
||||
runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
|
||||
machines: () => request("api/machines", parseMachinesResponse),
|
||||
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
|
||||
deleteMachine: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
|
||||
health: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
|
||||
runtime: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
|
||||
};
|
||||
|
||||
function configUrl(machineId?: string): string {
|
||||
return machineId === undefined ? "/api/config" : `${machinePrefix(machineId)}/config`;
|
||||
function configPath(machineId?: string): string {
|
||||
return machineId === undefined ? "api/config" : `${machinePrefix(machineId)}/config`;
|
||||
}
|
||||
|
||||
function pluginsUrl(machineId?: string): string {
|
||||
return machineId === undefined ? "/api/plugins" : `${machinePrefix(machineId)}/plugins`;
|
||||
function pluginsPath(machineId?: string): string {
|
||||
return machineId === undefined ? "api/plugins" : `${machinePrefix(machineId)}/plugins`;
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
config: (machineId?: string) => request(configUrl(machineId), parsePiWebConfigResponse),
|
||||
saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configUrl(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
|
||||
config: (machineId?: string) => request(configPath(machineId), parsePiWebConfigResponse),
|
||||
saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configPath(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
|
||||
};
|
||||
|
||||
export const pluginsApi = {
|
||||
plugins: (machineId?: string) => request(pluginsUrl(machineId), parsePiWebPluginsResponse),
|
||||
plugins: (machineId?: string) => request(pluginsPath(machineId), parsePiWebPluginsResponse),
|
||||
};
|
||||
|
||||
function piPackageUrl(endpoint = "", machineId?: string): string {
|
||||
const baseUrl = machineId === undefined ? "/api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`;
|
||||
return endpoint === "" ? baseUrl : `${baseUrl}/${endpoint}`;
|
||||
function piPackagePath(endpoint = "", machineId?: string): string {
|
||||
const basePath = machineId === undefined ? "api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`;
|
||||
return endpoint === "" ? basePath : `${basePath}/${endpoint}`;
|
||||
}
|
||||
|
||||
export const piPackagesApi = {
|
||||
packages: (machineId?: string) => request(piPackageUrl("", machineId), parsePiPackagesResponse),
|
||||
packages: (machineId?: string) => request(piPackagePath("", machineId), parsePiPackagesResponse),
|
||||
install: (source: string, machineId?: string) => {
|
||||
const body: PiPackageInstallRequest = { source };
|
||||
return request(piPackageUrl("install", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
|
||||
return request(piPackagePath("install", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
|
||||
},
|
||||
remove: (source: string, scope?: PiPackageScope, machineId?: string) => {
|
||||
const body: PiPackageRemoveRequest = scope === undefined ? { source } : { source, scope };
|
||||
return request(piPackageUrl("remove", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
|
||||
return request(piPackagePath("remove", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
|
||||
},
|
||||
update: (source?: string, machineId?: string) => {
|
||||
const body: PiPackageUpdateRequest | undefined = source === undefined ? undefined : { source };
|
||||
return request(piPackageUrl("update", machineId), parsePiPackageMutationResponse, { method: "POST", ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
return request(piPackagePath("update", machineId), parsePiPackageMutationResponse, { method: "POST", ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -167,7 +168,7 @@ export const projectsApi = {
|
||||
};
|
||||
|
||||
export const workspacesApi = {
|
||||
workspaces: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
|
||||
workspaces: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces`, arrayOf(parseWorkspace)),
|
||||
deleteWorkspace: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}`, parseTerminalCommandRun, { method: "DELETE" }),
|
||||
workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
|
||||
workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
|
||||
@@ -206,28 +207,28 @@ export const sessionsApi = {
|
||||
cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }),
|
||||
archiveMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/archive`, parseSessionBulkArchiveResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
|
||||
deleteArchivedMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/delete-archived`, parseSessionBulkDeleteArchivedResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
|
||||
messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(session, options, machineId), parseMessagePage),
|
||||
status: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus),
|
||||
models: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse),
|
||||
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionUrl(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
|
||||
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionUrl(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
|
||||
thinkingLevels: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "thinking-levels", machineId), parseThinkingLevelsResponse),
|
||||
setThinkingLevel: (session: SessionLookup, level: string, machineId = "local") => request(sessionUrl(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { level }) }),
|
||||
cycleThinkingLevel: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
|
||||
commands: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "commands", machineId), arrayOf(parseSlashCommand)),
|
||||
prompt: (session: SessionLookup, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local", attachments?: PromptAttachment[]) => request(sessionUrl(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text, ...(streamingBehavior === undefined ? {} : { streamingBehavior }), ...(attachments !== undefined && attachments.length > 0 ? { attachments } : {}) }) }),
|
||||
saveAttachments: (session: SessionLookup, attachments: PromptAttachment[], machineId = "local", folder?: string) => request(sessionUrl(session, "attachments", machineId), parseSavedAttachments, { method: "POST", body: sessionBody(session, { attachments, ...(folder === undefined ? {} : { folder }) }) }),
|
||||
shell: (session: SessionLookup, text: string, machineId = "local") => request(sessionUrl(session, "shell", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text }) }),
|
||||
runCommand: (session: SessionLookup, text: string, machineId = "local") => request(sessionUrl(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { text }) }),
|
||||
respondToCommand: (session: SessionLookup, requestId: string, value: string, machineId = "local") => request(sessionUrl(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { requestId, value }) }),
|
||||
abort: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "abort", machineId), parseAborted, { method: "POST", body: sessionBody(session) }),
|
||||
stop: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "stop", machineId), parseStopped, { method: "POST", body: sessionBody(session) }),
|
||||
archive: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "archive", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
|
||||
archiveWithDescendants: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "archive-tree", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
|
||||
restore: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }),
|
||||
deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryUrl(session, machineId), parseDeleted, { method: "DELETE" }),
|
||||
detachParent: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: sessionBody(session) }),
|
||||
reloadSession: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "reload", machineId), parseReloaded, { method: "POST", body: sessionBody(session) }),
|
||||
messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messagePath(session, options, machineId), parseMessagePage),
|
||||
status: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "status", machineId), parseSessionStatus),
|
||||
models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse),
|
||||
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
|
||||
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
|
||||
thinkingLevels: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "thinking-levels", machineId), parseThinkingLevelsResponse),
|
||||
setThinkingLevel: (session: SessionLookup, level: string, machineId = "local") => request(sessionPath(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { level }) }),
|
||||
cycleThinkingLevel: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
|
||||
commands: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "commands", machineId), arrayOf(parseSlashCommand)),
|
||||
prompt: (session: SessionLookup, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local", attachments?: PromptAttachment[]) => request(sessionPath(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text, ...(streamingBehavior === undefined ? {} : { streamingBehavior }), ...(attachments !== undefined && attachments.length > 0 ? { attachments } : {}) }) }),
|
||||
saveAttachments: (session: SessionLookup, attachments: PromptAttachment[], machineId = "local", folder?: string) => request(sessionPath(session, "attachments", machineId), parseSavedAttachments, { method: "POST", body: sessionBody(session, { attachments, ...(folder === undefined ? {} : { folder }) }) }),
|
||||
shell: (session: SessionLookup, text: string, machineId = "local") => request(sessionPath(session, "shell", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text }) }),
|
||||
runCommand: (session: SessionLookup, text: string, machineId = "local") => request(sessionPath(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { text }) }),
|
||||
respondToCommand: (session: SessionLookup, requestId: string, value: string, machineId = "local") => request(sessionPath(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { requestId, value }) }),
|
||||
abort: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "abort", machineId), parseAborted, { method: "POST", body: sessionBody(session) }),
|
||||
stop: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "stop", machineId), parseStopped, { method: "POST", body: sessionBody(session) }),
|
||||
archive: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "archive", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
|
||||
archiveWithDescendants: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "archive-tree", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
|
||||
restore: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }),
|
||||
deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryPath(session, machineId), parseDeleted, { method: "DELETE" }),
|
||||
detachParent: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "detach-parent", machineId), parseDetached, { method: "POST", body: sessionBody(session) }),
|
||||
reloadSession: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "reload", machineId), parseReloaded, { method: "POST", body: sessionBody(session) }),
|
||||
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.mode !== undefined) params.set("mode", options.mode);
|
||||
@@ -256,7 +257,7 @@ export const terminalsApi = {
|
||||
};
|
||||
|
||||
async function getOptionalTerminalCommandRun(runId: string, machineId: string): Promise<TerminalCommandRun | undefined> {
|
||||
const response = await fetch(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}`);
|
||||
const response = await fetch(resolveAppUrl(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}`));
|
||||
if (response.status === 404) return undefined;
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||
@@ -313,7 +314,7 @@ export const filesApi = {
|
||||
|
||||
export const gitApi = {
|
||||
gitStatus: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse),
|
||||
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }, machineId = "local") => request(machineGitDiffUrl(machineId, projectId, workspaceId, options), parseGitDiffResponse),
|
||||
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }, machineId = "local") => request(machineGitDiffPath(machineId, projectId, workspaceId, options), parseGitDiffResponse),
|
||||
};
|
||||
|
||||
export const api = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Workspace } from "../../../shared/apiTypes";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
|
||||
import { activityApi, configApi, filesApi, gitApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
@@ -17,6 +17,10 @@ const workspace: Workspace = {
|
||||
};
|
||||
const session = { id: "s 1", cwd: workspace.path };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -113,7 +117,6 @@ describe("federated route contract", () => {
|
||||
webSocketUrls.push(url);
|
||||
}
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
|
||||
|
||||
sessionEvents(session, machineId);
|
||||
globalSessionEvents(machineId);
|
||||
@@ -146,8 +149,9 @@ function fetchCallToRoute(call: Parameters<FetchLike>, scopedMachineId: string):
|
||||
function routeFromMachineUrl(method: string, input: string | URL | Request, scopedMachineId: string): ObservedHttpRoute {
|
||||
const url = toUrl(input);
|
||||
const prefix = `/api/machines/${encodeURIComponent(scopedMachineId)}`;
|
||||
if (!url.pathname.startsWith(prefix)) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`);
|
||||
return { method, path: url.pathname.slice(prefix.length) || "/" };
|
||||
const prefixIndex = url.pathname.lastIndexOf(prefix);
|
||||
if (prefixIndex === -1) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`);
|
||||
return { method, path: url.pathname.slice(prefixIndex + prefix.length) || "/" };
|
||||
}
|
||||
|
||||
function toUrl(input: string | URL | Request): URL {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { resolveAppUrl } from "../appUrl";
|
||||
|
||||
export async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
const response = await fetch(url, { ...init, headers });
|
||||
const response = await fetch(resolveAppUrl(url), { ...init, headers });
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||
throw new Error(errorMessage(body) ?? response.statusText);
|
||||
|
||||
@@ -10,7 +10,7 @@ function FakeWebSocket(url: string): void {
|
||||
beforeEach(() => {
|
||||
webSocketUrls.length = 0;
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SessionRef } from "../../../shared/apiTypes";
|
||||
import { resolveAppWebSocketUrl } from "../appUrl";
|
||||
|
||||
type SessionLookup = SessionRef | string;
|
||||
|
||||
@@ -6,27 +7,22 @@ export function sessionEvents(session: SessionLookup, machineId = "local"): WebS
|
||||
const cwd = typeof session === "string" ? undefined : session.cwd;
|
||||
const query = cwd === undefined || cwd === "" ? "" : `?${new URLSearchParams({ cwd }).toString()}`;
|
||||
const sessionId = typeof session === "string" ? session : session.id;
|
||||
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId)}/events${query}`);
|
||||
return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId)}/events${query}`));
|
||||
}
|
||||
|
||||
export function globalSessionEvents(machineId = "local"): WebSocket {
|
||||
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/events`);
|
||||
return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/sessions/events`));
|
||||
}
|
||||
|
||||
export function terminalSocket(projectId: string, workspaceId: string, terminalId: string, initialSize?: { cols: number; rows: number }, machineId = "local"): WebSocket {
|
||||
const sizeQuery = initialSize === undefined ? "" : `?cols=${encodeURIComponent(String(initialSize.cols))}&rows=${encodeURIComponent(String(initialSize.rows))}`;
|
||||
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`);
|
||||
const sizeQuery = initialSize === undefined ? "" : `?${new URLSearchParams({ cols: String(initialSize.cols), rows: String(initialSize.rows) }).toString()}`;
|
||||
return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`));
|
||||
}
|
||||
|
||||
export function realtimeEvents(machineId = "local"): WebSocket {
|
||||
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/events`);
|
||||
return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/events`));
|
||||
}
|
||||
|
||||
function machinePrefix(machineId: string): string {
|
||||
return `/api/machines/${encodeURIComponent(machineId)}`;
|
||||
}
|
||||
|
||||
function webSocketBaseUrl(): string {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${location.host}`;
|
||||
return `api/machines/${encodeURIComponent(machineId)}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SessionRef } from "../../../shared/apiTypes";
|
||||
import { resolveAppUrl } from "../appUrl";
|
||||
|
||||
type SessionLookup = SessionRef | string;
|
||||
|
||||
@@ -10,36 +11,36 @@ function sessionCwd(session: SessionLookup): string | undefined {
|
||||
return typeof session === "string" ? undefined : session.cwd;
|
||||
}
|
||||
|
||||
export function machineGitDiffUrl(machineId: string, projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
|
||||
export function machineGitDiffPath(machineId: string, projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.path !== undefined) params.set("path", options.path);
|
||||
if (options?.staged === true) params.set("staged", "true");
|
||||
const query = params.toString();
|
||||
return `/api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
||||
return `api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
export function messageUrl(session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local"): string {
|
||||
export function messagePath(session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local"): string {
|
||||
const params = new URLSearchParams();
|
||||
const cwd = sessionCwd(session);
|
||||
if (cwd !== undefined && cwd !== "") params.set("cwd", cwd);
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
if (options?.before !== undefined) params.set("before", String(options.before));
|
||||
const query = params.toString();
|
||||
return `/api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`;
|
||||
return `api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`;
|
||||
}
|
||||
|
||||
export function workspaceFileWriteUrl(projectId: string, workspaceId: string, path: string, options?: { createDirs?: boolean; overwrite?: boolean; machineId?: string }): string {
|
||||
const params = new URLSearchParams({ path });
|
||||
if (options?.createDirs === false) params.set("createDirs", "false");
|
||||
if (options?.overwrite === false) params.set("overwrite", "false");
|
||||
const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
|
||||
return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`;
|
||||
const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
|
||||
return resolveAppUrl(`${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("path", path);
|
||||
if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt);
|
||||
const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
|
||||
return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`;
|
||||
const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
|
||||
return resolveAppUrl(`${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
effectiveWorkspaceUploadFolder,
|
||||
uploadWorkspaceFile,
|
||||
@@ -12,6 +12,14 @@ import {
|
||||
type WorkspaceUploadXhr,
|
||||
} from "./workspaceUploads";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("workspace upload helpers", () => {
|
||||
it("resolves effective upload defaults and workspace-relative paths", () => {
|
||||
expect(effectiveWorkspaceUploadFolder(undefined)).toBe(".pi-web/uploads");
|
||||
@@ -40,7 +48,7 @@ describe("workspace upload helpers", () => {
|
||||
|
||||
const xhr = xhrs.only();
|
||||
expect(xhr.method).toBe("PUT");
|
||||
expect(xhr.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false");
|
||||
expect(xhr.url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false");
|
||||
expect(xhr.headers.get("content-type")).toBe("text/plain");
|
||||
expect(xhr.body).toBe(file);
|
||||
|
||||
@@ -78,13 +86,13 @@ describe("workspace upload helpers", () => {
|
||||
});
|
||||
|
||||
const first = xhrs.at(0);
|
||||
expect(first.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt");
|
||||
expect(first.url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt");
|
||||
first.emitUploadProgress(1, 2);
|
||||
first.respondJson(200, { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
|
||||
await Promise.resolve();
|
||||
|
||||
const second = xhrs.at(1);
|
||||
expect(second.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt");
|
||||
expect(second.url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt");
|
||||
second.emitUploadProgress(3, 3);
|
||||
second.respondJson(200, { path: "uploads/manual/b.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true });
|
||||
|
||||
@@ -111,7 +119,7 @@ describe("workspace upload helpers", () => {
|
||||
});
|
||||
|
||||
const xhr = xhrs.only();
|
||||
expect(xhr.url).toBe("/api/machines/local/projects/p1/workspaces/w1/file?path=uploads%2Fnested.txt&createDirs=false");
|
||||
expect(xhr.url).toBe("https://pi.example.test/api/machines/local/projects/p1/workspaces/w1/file?path=uploads%2Fnested.txt&createDirs=false");
|
||||
xhr.respondJson(200, { path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
|
||||
|
||||
await expect(task.promise).resolves.toEqual([
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAppUrl, resolveAppWebSocketUrl, type AppUrlContext } from "./appUrl";
|
||||
|
||||
const rootHttpContext: AppUrlContext = {
|
||||
viteBaseUrl: "/",
|
||||
documentBaseUrl: "http://pi.example.test/",
|
||||
};
|
||||
|
||||
const nestedHttpsContext: AppUrlContext = {
|
||||
viteBaseUrl: "./",
|
||||
documentBaseUrl: "https://pi.example.test/test/ai/",
|
||||
};
|
||||
|
||||
describe("application URLs", () => {
|
||||
it("resolves app-owned paths at an HTTP root deployment", () => {
|
||||
expect(resolveAppUrl("api/pi-web/status", rootHttpContext)).toBe("http://pi.example.test/api/pi-web/status");
|
||||
expect(resolveAppUrl("/pi-web-plugins/manifest.json", rootHttpContext)).toBe("http://pi.example.test/pi-web-plugins/manifest.json");
|
||||
});
|
||||
|
||||
it("resolves paths within a canonical nested HTTPS deployment", () => {
|
||||
expect(resolveAppUrl("api/pi-web/status", nestedHttpsContext)).toBe("https://pi.example.test/test/ai/api/pi-web/status");
|
||||
expect(resolveAppUrl("/pi-web-plugins/manifest.json", nestedHttpsContext)).toBe("https://pi.example.test/test/ai/pi-web-plugins/manifest.json");
|
||||
});
|
||||
|
||||
it("preserves encoded path segments and query parameters", () => {
|
||||
expect(resolveAppUrl("api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one&before=10", nestedHttpsContext))
|
||||
.toBe("https://pi.example.test/test/ai/api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one&before=10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("application WebSocket URLs", () => {
|
||||
it("maps root HTTP URLs to absolute ws URLs", () => {
|
||||
expect(resolveAppWebSocketUrl("api/machines/local/events", rootHttpContext)).toBe("ws://pi.example.test/api/machines/local/events");
|
||||
});
|
||||
|
||||
it("maps nested HTTPS URLs to absolute wss URLs without losing path or query data", () => {
|
||||
expect(resolveAppWebSocketUrl("api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one", nestedHttpsContext))
|
||||
.toBe("wss://pi.example.test/test/ai/api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface AppUrlContext {
|
||||
viteBaseUrl: string;
|
||||
documentBaseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PI WEB-owned reference at a browser boundary.
|
||||
*
|
||||
* Core callers keep paths application-relative (no leading slash), encode every dynamic path segment,
|
||||
* and resolve exactly once. Leading slashes are accepted only for existing plugin-manifest compatibility
|
||||
* and mean the application root rather than the origin root.
|
||||
*/
|
||||
export function resolveAppUrl(path: string, context: AppUrlContext = browserAppUrlContext()): string {
|
||||
const applicationBaseUrl = new URL(context.viteBaseUrl, context.documentBaseUrl);
|
||||
return new URL(appRelativePath(path), applicationBaseUrl).toString();
|
||||
}
|
||||
|
||||
export function resolveAppWebSocketUrl(path: string, context: AppUrlContext = browserAppUrlContext()): string {
|
||||
const url = new URL(resolveAppUrl(path, context));
|
||||
if (url.protocol === "http:") {
|
||||
url.protocol = "ws:";
|
||||
} else if (url.protocol === "https:") {
|
||||
url.protocol = "wss:";
|
||||
} else {
|
||||
throw new Error(`Cannot create a WebSocket URL from ${url.protocol}`);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function browserAppUrlContext(): AppUrlContext {
|
||||
return {
|
||||
viteBaseUrl: import.meta.env.BASE_URL,
|
||||
documentBaseUrl: document.baseURI,
|
||||
};
|
||||
}
|
||||
|
||||
function appRelativePath(path: string): string {
|
||||
// A leading slash means the application root, not the origin root, so it must stay within nested deployments.
|
||||
return path.startsWith("/") ? `.${path}` : path;
|
||||
}
|
||||
@@ -1488,7 +1488,7 @@ export class PiWebApp extends LitElement {
|
||||
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`, {
|
||||
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, {
|
||||
machineId: machine.id,
|
||||
shouldLoadPlugin: (entry) => this.plugins.shouldLoadRemotePlugin(entry.id, entry.machineSpecific),
|
||||
}))
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("SessionController reload and selection", () => {
|
||||
return Promise.resolve(freshPage);
|
||||
},
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadExternalPlugins, resolvePluginModuleUrl } from "./external";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("external plugin manifests", () => {
|
||||
it("fetches the default manifest through the application base", async () => {
|
||||
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 404 })));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(loadExternalPlugins()).resolves.toEqual([]);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
|
||||
import { resolveAppUrl, type AppUrlContext } from "../appUrl";
|
||||
import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
|
||||
|
||||
export interface PluginManifestEntry {
|
||||
@@ -14,18 +15,20 @@ interface PluginManifest {
|
||||
export interface LoadExternalPluginsOptions {
|
||||
machineId?: string;
|
||||
shouldLoadPlugin?: (entry: PluginManifestEntry) => boolean;
|
||||
moduleLoader?: (moduleUrl: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
|
||||
const manifest = await fetchPluginManifest(manifestUrl);
|
||||
export async function loadExternalPlugins(manifestUrl = "pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
|
||||
const resolvedManifestUrl = resolveAppUrl(manifestUrl);
|
||||
const manifest = await fetchPluginManifest(resolvedManifestUrl);
|
||||
if (manifest === undefined) return [];
|
||||
|
||||
const registrations: PiWebPluginRegistration[] = [];
|
||||
for (const entry of manifest.plugins) {
|
||||
if (options.shouldLoadPlugin?.(entry) === false) continue;
|
||||
try {
|
||||
const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).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),
|
||||
@@ -40,6 +43,15 @@ export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifes
|
||||
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<unknown> {
|
||||
return import(/* @vite-ignore */ moduleUrl);
|
||||
}
|
||||
|
||||
async function fetchPluginManifest(manifestUrl: string): Promise<PluginManifest | undefined> {
|
||||
const response = await fetch(manifestUrl, { cache: "no-store" });
|
||||
if (response.status === 404) return undefined;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { build } from "vite";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
describe("production client build contents", () => {
|
||||
it("emits deployment-relative HTML and PWA URLs", async () => {
|
||||
const outDir = await mkdtemp(join(tmpdir(), "pi-web-client-build-"));
|
||||
try {
|
||||
await build({
|
||||
configFile: join(repoRoot, "vite.config.ts"),
|
||||
logLevel: "silent",
|
||||
build: { outDir, emptyOutDir: true },
|
||||
});
|
||||
|
||||
const html = await readFile(join(outDir, "index.html"), "utf8");
|
||||
const references = htmlAssetReferences(html);
|
||||
expect(references).toContain("./favicon.svg");
|
||||
expect(references).toContain("./apple-touch-icon.png");
|
||||
expect(references).toContain("./manifest.webmanifest");
|
||||
expect(references).toContainEqual(expect.stringMatching(/^\.\/assets\/index-[^/]+\.js$/));
|
||||
expect(references.filter((reference) => reference.startsWith("/"))).toEqual([]);
|
||||
|
||||
const manifest: unknown = JSON.parse(await readFile(join(outDir, "manifest.webmanifest"), "utf8"));
|
||||
expect(manifest).toMatchObject({
|
||||
start_url: "./",
|
||||
scope: "./",
|
||||
icons: [
|
||||
{ src: "./pwa-icon-192.png" },
|
||||
{ src: "./pwa-icon-512.png" },
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function htmlAssetReferences(html: string): string[] {
|
||||
return Array.from(html.matchAll(/\b(?:href|src)="([^"]+)"/g), (match) => match[1] ?? "");
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { appTestContext, fakeRemoteClient, registerAppTestHooks } from "./app.te
|
||||
registerAppTestHooks();
|
||||
|
||||
describe("buildApp PI WEB plugin routes", () => {
|
||||
it("serves the PI WEB plugin manifest and plugin assets", async () => {
|
||||
it("serves application-root plugin modules through the manifest and plugin-list APIs", 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 }] });
|
||||
@@ -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 manifest-relative and legacy plugin-root-relative modules while dropping 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,9 +96,12 @@ 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: "legacy-tools", module: "nested/pi-web-plugin.js?v=2", 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" },
|
||||
{ id: "cross-origin", module: "https://plugins.example.test/pi-web-plugin.js", source: "local", scope: "local" },
|
||||
{ id: "malformed", module: "nested/%E0%A4%A.js", source: "local", scope: "local" },
|
||||
],
|
||||
},
|
||||
})),
|
||||
@@ -103,7 +111,10 @@ 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" },
|
||||
{ id: "legacy-tools", module: `../../../../pi-web-plugins/${machineScopedPluginId(remote.id, "legacy-tools")}/nested/pi-web-plugin.js?v=2`, source: "local", scope: "local" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa
|
||||
if (modulePath === undefined) return [];
|
||||
return [{
|
||||
...plugin,
|
||||
module: `/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,13 @@ 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 pluginRootUrl = new URL(prefix, "http://pi-web.local");
|
||||
const manifestUrl = new URL("/pi-web-plugins/manifest.json", pluginRootUrl);
|
||||
try {
|
||||
const url = new URL(module, base);
|
||||
if (url.origin !== base.origin || !url.pathname.startsWith(prefix)) return undefined;
|
||||
// An explicit ./<plugin-id>/ prefix is manifest-relative; bare paths retain the legacy plugin-root-relative contract.
|
||||
const baseUrl = module.startsWith("./") ? manifestUrl : pluginRootUrl;
|
||||
const url = new URL(module, baseUrl);
|
||||
if (url.origin !== pluginRootUrl.origin || !url.pathname.startsWith(prefix)) return undefined;
|
||||
const path = safeRemotePluginAssetPath(url.pathname.slice(prefix.length));
|
||||
return path === undefined ? undefined : { path, query: url.search };
|
||||
} catch {
|
||||
|
||||
@@ -37,7 +37,10 @@ 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);
|
||||
const module = manifest.plugins[0]?.module;
|
||||
expect(module).toMatch(/^\/pi-web-plugins\/info\/pi-web-plugin\.js\?v=\d+$/u);
|
||||
expect(new URL(module ?? "", "http://old-gateway.test/pi-web-plugins/info/").pathname).toBe("/pi-web-plugins/info/pi-web-plugin.js");
|
||||
await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ module }] });
|
||||
|
||||
const asset = await service.readAsset("info", "pi-web-plugin.js");
|
||||
expect(asset?.contentType).toBe("application/javascript; charset=utf-8");
|
||||
@@ -105,7 +108,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");
|
||||
|
||||
Reference in New Issue
Block a user