Merge pull request #51 from jmfederico/fix/pr-46-base-path

fix: make base-path deployments portable
This commit is contained in:
Federico Jaramillo Martinez
2026-07-13 19:59:52 +02:00
committed by GitHub
29 changed files with 515 additions and 155 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Support root and nested reverse-proxy deployments with one published client, including scoped PWA assets, WebSockets, and local or federated plugins.
+11
View File
@@ -17,6 +17,17 @@ Project-specific testing rules live in `.agents/skills/testing-guide/SKILL.md`.
Use that skill whenever writing, modifying, reviewing, or planning tests, closing coverage gaps, triaging test failures, or creating test helpers/harnesses. Keep detailed testing conventions there rather than growing this top-level orientation file.
## Client application URL convention
- Build PI WEB-owned browser paths as application-relative references without a leading slash, for example `api/...` and `pi-web-plugins/...`.
- Encode every dynamic path segment with `encodeURIComponent`; encode query values, using `URLSearchParams` for multi-field queries.
- Resolve each reference exactly once at the browser boundary: ordinary JSON HTTP paths go to `request()`, direct browser APIs receive URLs from helpers backed by `resolveAppUrl()`, and WebSockets use `resolveAppWebSocketUrl()`.
- Name helpers returning unresolved application references with a `Path` suffix and helpers returning browser-ready absolute values with a `Url` suffix.
- Plugin module references must go through `resolvePluginModuleUrl()`. Its leading-slash handling is the documented rolling-compatibility exception; do not introduce other leading-root app references.
- Pre-JavaScript HTML assets use Vite `%BASE_URL%`; PWA manifest references stay `./`-relative. External links, data URLs, and module-relative plugin assets are not application paths.
- To assess deviations, search production client code for raw `fetch`, `WebSocket`, `XMLHttpRequest`, URL-bearing DOM attributes, and leading `/api` or `/pi-web-plugins` literals. Every app-owned result must follow one of the boundaries above.
- Published nested deployments require a canonical trailing slash; the reverse proxy must redirect a slashless prefix before serving the app.
## Configuration conventions
- `$PI_WEB_DATA_DIR` (`~/.pi-web` by default) contains PI WEB-managed state such as `projects.json` and `machines.json`; do not treat it as the user-editable config API.
+17
View File
@@ -91,6 +91,7 @@
<aside class="toc" aria-label="Config page contents">
<strong>On this page</strong>
<a href="#files">Config files</a>
<a href="#deployment-paths">Deployment paths</a>
<a href="#precedence">Precedence and reloads</a>
<a href="#global-config">Global config</a>
<a href="#project-config">Project config</a>
@@ -133,6 +134,22 @@
</p>
</section>
<section id="deployment-paths">
<h2>Reverse-proxy deployment paths</h2>
<p>
The deployment path is not a PI WEB config-file key or environment setting. The published client is
portable: one build works at <code>/</code> and at canonical trailing-slash prefixes such as
<code>/ai/</code> or <code>/test/ai/</code>.
</p>
<p>
For a nested deployment, redirect the slashless prefix to the trailing-slash URL, strip the prefix
before forwarding to PI WEB, and proxy authenticated HTTP and WebSocket traffic through the same
location. Relative browser and PWA URLs then stay within that prefix. See the
<a href="install#reverse-proxy-prefix">reverse proxy deployment example</a> for complete Nginx
configuration.
</p>
</section>
<section id="precedence">
<h2>Precedence and reloads</h2>
<p>Machine-global runtime values are resolved in this order:</p>
+6
View File
@@ -17,6 +17,12 @@ Pi package settings are separate from PI WEB config. They live in Pi's package-m
If you installed services with a custom config path, rerun `pi-web install --config /path/to/config.json` after changing that path or after upgrading from a version that only applied the custom path to the web service. This regenerates service files so the web/API and session daemon use the same `PI_WEB_CONFIG`.
## Reverse-proxy deployment paths
The deployment path is not a PI WEB config-file key or environment setting. The published client is portable: one build works at `/` and at canonical trailing-slash prefixes such as `/ai/` or `/test/ai/`.
For a nested deployment, redirect the slashless prefix to the trailing-slash URL, strip the prefix before forwarding to PI WEB, and proxy authenticated HTTP and WebSocket traffic through the same location. Relative browser and PWA URLs then stay within that prefix. See the [reverse proxy installation guide](https://pi-web.dev/install#reverse-proxy-prefix) for a complete Nginx example.
## Precedence and reloads
Machine-global runtime values are resolved as:
+74
View File
@@ -95,6 +95,7 @@
<a href="#pi-package">Install through Pi</a>
<a href="#manual-run">WSL / manual run</a>
<a href="#remote-access">Remote access</a>
<a href="#reverse-proxy-prefix">Reverse proxy prefixes</a>
<a href="#federated-machines">Federated machines</a>
<a href="#manage-services">Manage services</a>
<a href="#configure">Configure</a>
@@ -224,6 +225,79 @@
</div>
</section>
<section id="reverse-proxy-prefix">
<h2>Reverse proxy root and path-prefix deployments</h2>
<p>
The published PI WEB client is deployment-independent. The same package works at the origin root
(<code>/</code>) or at canonical nested prefixes such as <code>/ai/</code> and <code>/test/ai/</code>;
no prefix-specific rebuild or PI WEB configuration is needed.
</p>
<p>
For a root deployment, proxy <code>/</code> directly to <code>http://127.0.0.1:8504</code> without
rewriting the path. For a nested deployment:
</p>
<ol>
<li>Redirect the slashless prefix, such as <code>/ai</code>, to <code>/ai/</code>. The browser uses the trailing-slash document URL as the application base.</li>
<li>Strip the prefix before forwarding. PI WEB continues to serve root paths on its localhost listener.</li>
<li>Apply authentication to the whole served <code>/ai/</code> application and preserve required authentication headers and cookies.</li>
<li>Forward WebSocket upgrades through the same location as HTTP, API, image, PWA, and plugin traffic.</li>
</ol>
<div class="code-card">
<div class="copy-row">
<strong>Nginx path-prefix proxy</strong>
<button class="copy-button" data-copy="#nginx-prefix-proxy">Copy</button>
</div>
<pre id="nginx-prefix-proxy"><code><span class="comment"># http context</span>
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name pi.example.com;
ssl_certificate /etc/letsencrypt/live/pi.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pi.example.com/privkey.pem;
auth_basic "PI WEB";
auth_basic_user_file /etc/nginx/pi-web.htpasswd;
location = /ai {
return 308 /ai/$is_args$args;
}
location ^~ /ai/ {
<span class="comment"># The trailing slash strips /ai/ before forwarding.</span>
proxy_pass http://127.0.0.1:8504/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Authorization $http_authorization;
proxy_set_header Cookie $http_cookie;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 1h;
}
}</code></pre>
</div>
<p>
Use the same pattern for <code>/test/ai/</code> by changing both Nginx locations. If your proxy uses
bearer tokens, SSO, or another authentication mechanism, keep that policy on the prefixed application
location and continue forwarding the headers or cookies it requires; the slashless redirect serves no
PI WEB content. Do not create unprotected exceptions for <code>/api/</code> or
<code>/pi-web-plugins/</code>.
</p>
<p>
Once the proxy follows this contract, relative client assets, images, PWA assets, API calls, local and
federated plugins, and WebSocket URLs stay inside the prefix. Installed PWA <code>start_url</code> and
scope stay inside it as well.
</p>
</section>
<section id="federated-machines">
<h2>Federated machines</h2>
<p>
+3 -1
View File
@@ -174,7 +174,9 @@ PI WEB gateway you opened
<p>
Prefer a private path such as NetBird, Tailscale, WireGuard, private LAN, SSH tunnel, or an authenticated reverse
proxy. If the remote is behind a path prefix, include that prefix in the machine URL, for example
<code>https://devbox.example.test/pi-web</code>.
<code>https://devbox.example.test/pi-web</code>. The machine registry normalizes the trailing slash; when
opening that deployment directly in a browser, use its canonical <code>https://devbox.example.test/pi-web/</code>
URL and configure the proxy to redirect the slashless form.
</p>
<div class="callout danger">
Do not expose PI WEB directly to the public internet. Register machines only over trusted network paths
+8
View File
@@ -394,6 +394,14 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
UI should come from the selected PI WEB instance; on remote machines, the gateway copy is hidden unless
the remote machine exposes its own copy.
</p>
<p>
Current PI WEB manifests publish leading application-root module references. The browser keeps them
inside the current application base, so local and federated plugins follow root or nested reverse-proxy
deployments without a prefix-specific build while remaining compatible with existing gateways.
Federated gateways also accept manifest-relative references such as
<code>./&lt;plugin-id&gt;/plugin.js</code> and legacy plugin-root-relative references such as
<code>nested/plugin.js</code> from remote machines.
</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,
+4 -2
View File
@@ -325,7 +325,7 @@ Rules:
### Manifest and assets
The manifest contains each discovered plugin module:
The manifest contains each discovered plugin module. Current PI WEB releases emit `module` as a leading application-root reference:
```json
{
@@ -341,9 +341,11 @@ The manifest contains each discovered plugin module:
}
```
The browser maps leading application-root references into the current application base, so the same manifest works at the origin root or under a reverse-proxy path prefix. Keeping this output format also lets gateways from existing PI WEB releases consume plugins from an upgraded remote machine. For compatibility, federated gateways additionally accept explicit manifest-relative references such as `./my-plugin/pi-web-plugin.js` and legacy plugin-root-relative references such as `nested/pi-web-plugin.js`; all accepted forms are rewritten to deployment-portable, gateway-relative references.
`source` describes where the plugin came from (`bundled`, `local`, or the Pi package source). `scope` is `bundled`, `local`, `user`, or `project`. `machineSpecific` controls whether the gateway copy is valid for remote machines or only each selected machine's own copy can appear.
A plugin can fetch its own static assets with URLs under:
At an origin-root deployment, a plugin's static assets are available under:
```text
/pi-web-plugins/<plugin-id>/<path-inside-plugin-root>
+3 -3
View File
@@ -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;
+4 -4
View File
@@ -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"
+54 -39
View File
@@ -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/");
});
});
+59 -58
View File
@@ -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 {
+3 -1
View File
@@ -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);
+1 -1
View File
@@ -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(() => {
+7 -11
View File
@@ -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)}`;
}
+9 -8
View File
@@ -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()}`);
}
+13 -5
View File
@@ -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([
+40
View File
@@ -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");
});
});
+40
View File
@@ -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;
}
+1 -1
View File
@@ -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,
+52
View File
@@ -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");
});
});
+16 -4
View File
@@ -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;
+45
View File
@@ -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] ?? "");
}
+18 -7
View File
@@ -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 {
+5 -2
View File
@@ -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");
+1
View File
@@ -93,6 +93,7 @@ function devDocsPlugin(): Plugin {
export default defineConfig({
plugins: [devDocsPlugin()],
root: "src/client",
base: "./",
build: {
outDir: "../../dist/client",
emptyOutDir: true,