Merge branch 'main' into review-issue-48

This commit is contained in:
Federico Jaramillo Martinez
2026-07-13 21:08:17 +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. 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 ## 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. - `$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"> <aside class="toc" aria-label="Config page contents">
<strong>On this page</strong> <strong>On this page</strong>
<a href="#files">Config files</a> <a href="#files">Config files</a>
<a href="#deployment-paths">Deployment paths</a>
<a href="#precedence">Precedence and reloads</a> <a href="#precedence">Precedence and reloads</a>
<a href="#global-config">Global config</a> <a href="#global-config">Global config</a>
<a href="#project-config">Project config</a> <a href="#project-config">Project config</a>
@@ -133,6 +134,22 @@
</p> </p>
</section> </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"> <section id="precedence">
<h2>Precedence and reloads</h2> <h2>Precedence and reloads</h2>
<p>Machine-global runtime values are resolved in this order:</p> <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`. 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 ## Precedence and reloads
Machine-global runtime values are resolved as: Machine-global runtime values are resolved as:
+74
View File
@@ -95,6 +95,7 @@
<a href="#pi-package">Install through Pi</a> <a href="#pi-package">Install through Pi</a>
<a href="#manual-run">WSL / manual run</a> <a href="#manual-run">WSL / manual run</a>
<a href="#remote-access">Remote access</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="#federated-machines">Federated machines</a>
<a href="#manage-services">Manage services</a> <a href="#manage-services">Manage services</a>
<a href="#configure">Configure</a> <a href="#configure">Configure</a>
@@ -224,6 +225,79 @@
</div> </div>
</section> </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"> <section id="federated-machines">
<h2>Federated machines</h2> <h2>Federated machines</h2>
<p> <p>
+3 -1
View File
@@ -174,7 +174,9 @@ PI WEB gateway you opened
<p> <p>
Prefer a private path such as NetBird, Tailscale, WireGuard, private LAN, SSH tunnel, or an authenticated reverse 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 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> </p>
<div class="callout danger"> <div class="callout danger">
Do not expose PI WEB directly to the public internet. Register machines only over trusted network paths 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 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. the remote machine exposes its own copy.
</p> </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> <p>
For portable plugin assets, prefer URLs relative to the plugin module, such as 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, <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 ### 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 ```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. `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 ```text
/pi-web-plugins/<plugin-id>/<path-inside-plugin-root> /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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>PI WEB</title> <title>PI WEB</title>
<meta name="theme-color" content="#0d1117" /> <meta name="theme-color" content="#0d1117" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="%BASE_URL%apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="%BASE_URL%manifest.webmanifest" />
<style> <style>
:root { :root {
color-scheme: dark; color-scheme: dark;
+4 -4
View File
@@ -2,20 +2,20 @@
"name": "PI WEB", "name": "PI WEB",
"short_name": "PI WEB", "short_name": "PI WEB",
"description": "Remote web UI and browser control plane for persistent Pi Coding Agent sessions.", "description": "Remote web UI and browser control plane for persistent Pi Coding Agent sessions.",
"start_url": "/", "start_url": "./",
"scope": "/", "scope": "./",
"display": "standalone", "display": "standalone",
"background_color": "#0d1117", "background_color": "#0d1117",
"theme_color": "#0d1117", "theme_color": "#0d1117",
"icons": [ "icons": [
{ {
"src": "/pwa-icon-192.png", "src": "./pwa-icon-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
}, },
{ {
"src": "/pwa-icon-512.png", "src": "./pwa-icon-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "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 { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { PiWebConfigValues, TerminalCommandRun, Workspace } from "../../../shared/apiTypes"; import type { PiWebConfigValues, TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
import { configApi, filesApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; import { configApi, filesApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
@@ -40,6 +40,10 @@ const commandRun: TerminalCommandRun = {
metadata: {}, metadata: {},
}; };
beforeEach(() => {
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
});
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@@ -51,7 +55,7 @@ describe("machine-scoped runtime API", () => {
await piWebApi.piWebStatus("remote a"); await piWebApi.piWebStatus("remote a");
expect(fetchMock).toHaveBeenCalledOnce(); 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 () => { it("requests an uncached update check through the local status route", async () => {
@@ -60,7 +64,7 @@ describe("machine-scoped runtime API", () => {
await piWebApi.checkForUpdates(); await piWebApi.checkForUpdates();
expect(fetchMock).toHaveBeenCalledOnce(); 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"); expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store");
}); });
@@ -70,7 +74,7 @@ describe("machine-scoped runtime API", () => {
await piWebApi.checkForUpdates("remote a"); await piWebApi.checkForUpdates("remote a");
expect(fetchMock).toHaveBeenCalledOnce(); 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"); expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store");
}); });
@@ -80,7 +84,7 @@ describe("machine-scoped runtime API", () => {
await machinesApi.runtime("remote a"); await machinesApi.runtime("remote a");
expect(fetchMock).toHaveBeenCalledOnce(); 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()); await expect(pluginsApi.plugins()).resolves.toEqual(piWebPluginsResponse());
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/config", "https://pi.example.test/api/config",
"/api/config", "https://pi.example.test/api/config",
"/api/plugins", "https://pi.example.test/api/plugins",
]); ]);
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT"); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } }); 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()); await expect(pluginsApi.plugins("remote a")).resolves.toEqual(piWebPluginsResponse());
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/machines/remote%20a/config", "https://pi.example.test/api/machines/remote%20a/config",
"/api/machines/remote%20a/config", "https://pi.example.test/api/machines/remote%20a/config",
"/api/machines/remote%20a/plugins", "https://pi.example.test/api/machines/remote%20a/plugins",
]); ]);
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT"); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } }); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
@@ -144,11 +148,11 @@ describe("Pi package API", () => {
await piPackagesApi.update(); await piPackagesApi.update();
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/pi-packages", "https://pi.example.test/api/pi-packages",
"/api/pi-packages/install", "https://pi.example.test/api/pi-packages/install",
"/api/pi-packages/remove", "https://pi.example.test/api/pi-packages/remove",
"/api/pi-packages/update", "https://pi.example.test/api/pi-packages/update",
"/api/pi-packages/update", "https://pi.example.test/api/pi-packages/update",
]); ]);
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST"); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ source: "npm:@acme/new-tools" }); 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"); await piPackagesApi.update(undefined, "remote a");
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/machines/local/pi-packages", "https://pi.example.test/api/machines/local/pi-packages",
"/api/machines/remote%20a/pi-packages", "https://pi.example.test/api/machines/remote%20a/pi-packages",
"/api/machines/remote%20a/pi-packages/install", "https://pi.example.test/api/machines/remote%20a/pi-packages/install",
"/api/machines/remote%20a/pi-packages/remove", "https://pi.example.test/api/machines/remote%20a/pi-packages/remove",
"/api/machines/remote%20a/pi-packages/update", "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, 2)[1]))).toEqual({ source: "npm:@acme/new-tools" });
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "../project-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); await expect(sessionsApi.cleanup({ archiveIdleDays: 7, projectCwds: ["/repo"] }, "remote a")).resolves.toEqual(executed);
expect(fetchMock).toHaveBeenCalledTimes(2); 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(fetchCall(fetchMock, 0)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ archiveIdleDays: 7, deleteArchivedDays: null }); 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(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ archiveIdleDays: 7, projectCwds: ["/repo"] }); 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); await expect(sessionsApi.deleteArchivedMany([{ id: "s 1", cwd: "/repo" }], "remote a")).resolves.toEqual(deleted);
expect(fetchMock).toHaveBeenCalledTimes(2); 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(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(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(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] }); 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(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0); 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" }); expect(JSON.parse(requestBody(init))).toEqual({ text: "hello", streamingBehavior: "followUp" });
}); });
@@ -239,7 +243,7 @@ describe("session API compatibility", () => {
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0); 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" }); 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 }); await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a", workspaceScoped: true });
expect(fetchMock).toHaveBeenCalledOnce(); 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 () => { 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" }); await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a" });
expect(fetchMock).toHaveBeenCalledOnce(); 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(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0); 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"); expect(init?.method).toBe("DELETE");
}); });
@@ -283,7 +298,7 @@ describe("machine-scoped terminal command-run API", () => {
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0); 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(init?.method).toBe("POST");
expect(JSON.parse(requestBody(init))).toEqual({ origin: "core", title: "Build", command: "npm test", metadata: {} }); 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(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0); 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"); expect(init?.method).toBe("DELETE");
}); });
@@ -311,9 +326,9 @@ describe("machine-scoped terminal command-run API", () => {
await terminalsApi.cancelCommandRun("run 1", "remote a"); await terminalsApi.cancelCommandRun("run 1", "remote a");
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ 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", "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",
"/api/machines/remote%20a/terminal-command-runs/run%201", "https://pi.example.test/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/run%201/cancel",
]); ]);
expect(fetchCall(fetchMock, 2)[1]?.method).toBe("POST"); 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(); 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(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0); 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(init?.method).toBe("PUT");
expect(new Headers(init?.headers).get("content-type")).toBe("text/plain"); expect(new Headers(init?.headers).get("content-type")).toBe("text/plain");
}); });
@@ -348,7 +363,7 @@ describe("workspace file write API", () => {
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0); 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(init?.method).toBe("PUT");
expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream"); expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream");
}); });
@@ -386,7 +401,7 @@ describe("workspace file write API", () => {
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const [url] = fetchCall(fetchMock, 0); 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 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 { request } from "./http";
import { import {
arrayOf, arrayOf,
@@ -49,9 +50,9 @@ import {
parseWorkspace, parseWorkspace,
parseWorkspaceActivityResponse, parseWorkspaceActivityResponse,
} from "./parsers"; } 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; type SessionLookup = SessionRef | string;
@@ -63,20 +64,20 @@ function sessionCwd(session: SessionLookup): string | undefined {
return typeof session === "string" ? undefined : session.cwd; 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))}`; return `${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId(session))}`;
} }
function sessionUrl(session: SessionLookup, endpoint: string, machineId = "local"): string { function sessionPath(session: SessionLookup, endpoint: string, machineId = "local"): string {
return `${sessionBaseUrl(session, machineId)}/${endpoint}`; return `${sessionBasePath(session, machineId)}/${endpoint}`;
} }
function sessionQueryUrl(session: SessionLookup, endpoint: string, machineId = "local"): string { function sessionQueryPath(session: SessionLookup, endpoint: string, machineId = "local"): string {
return `${sessionUrl(session, endpoint, machineId)}${sessionQuery(session)}`; return `${sessionPath(session, endpoint, machineId)}${sessionQuery(session)}`;
} }
function sessionBaseQueryUrl(session: SessionLookup, machineId = "local"): string { function sessionBaseQueryPath(session: SessionLookup, machineId = "local"): string {
return `${sessionBaseUrl(session, machineId)}${sessionQuery(session)}`; return `${sessionBasePath(session, machineId)}${sessionQuery(session)}`;
} }
function sessionQuery(session: SessionLookup): string { function sessionQuery(session: SessionLookup): string {
@@ -99,59 +100,59 @@ function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef
return cwd === undefined || cwd === "" ? { id } : { id, cwd }; return cwd === undefined || cwd === "" ? { id } : { id, cwd };
} }
function piWebStatusUrl(machineId: string): string { function piWebStatusPath(machineId: string): string {
return machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`; return machineId === "local" ? "api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`;
} }
export const piWebApi = { export const piWebApi = {
piWebStatus: (machineId = "local") => request(piWebStatusUrl(machineId), parsePiWebStatusResponse), piWebStatus: (machineId = "local") => request(piWebStatusPath(machineId), parsePiWebStatusResponse),
checkForUpdates: (machineId = "local") => request(`${piWebStatusUrl(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }), checkForUpdates: (machineId = "local") => request(`${piWebStatusPath(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }),
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse), piWebRuntime: () => request("api/pi-web/runtime", parsePiWebRuntimeResponse),
}; };
export const machinesApi = { export const machinesApi = {
machines: () => request("/api/machines", parseMachinesResponse), machines: () => request("api/machines", parseMachinesResponse),
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("/api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }), 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" }), deleteMachine: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth), health: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime), runtime: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
}; };
function configUrl(machineId?: string): string { function configPath(machineId?: string): string {
return machineId === undefined ? "/api/config" : `${machinePrefix(machineId)}/config`; return machineId === undefined ? "api/config" : `${machinePrefix(machineId)}/config`;
} }
function pluginsUrl(machineId?: string): string { function pluginsPath(machineId?: string): string {
return machineId === undefined ? "/api/plugins" : `${machinePrefix(machineId)}/plugins`; return machineId === undefined ? "api/plugins" : `${machinePrefix(machineId)}/plugins`;
} }
export const configApi = { export const configApi = {
config: (machineId?: string) => request(configUrl(machineId), parsePiWebConfigResponse), config: (machineId?: string) => request(configPath(machineId), parsePiWebConfigResponse),
saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configUrl(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }), saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configPath(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
}; };
export const pluginsApi = { export const pluginsApi = {
plugins: (machineId?: string) => request(pluginsUrl(machineId), parsePiWebPluginsResponse), plugins: (machineId?: string) => request(pluginsPath(machineId), parsePiWebPluginsResponse),
}; };
function piPackageUrl(endpoint = "", machineId?: string): string { function piPackagePath(endpoint = "", machineId?: string): string {
const baseUrl = machineId === undefined ? "/api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`; const basePath = machineId === undefined ? "api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`;
return endpoint === "" ? baseUrl : `${baseUrl}/${endpoint}`; return endpoint === "" ? basePath : `${basePath}/${endpoint}`;
} }
export const piPackagesApi = { export const piPackagesApi = {
packages: (machineId?: string) => request(piPackageUrl("", machineId), parsePiPackagesResponse), packages: (machineId?: string) => request(piPackagePath("", machineId), parsePiPackagesResponse),
install: (source: string, machineId?: string) => { install: (source: string, machineId?: string) => {
const body: PiPackageInstallRequest = { source }; 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) => { remove: (source: string, scope?: PiPackageScope, machineId?: string) => {
const body: PiPackageRemoveRequest = scope === undefined ? { source } : { source, scope }; 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) => { update: (source?: string, machineId?: string) => {
const body: PiPackageUpdateRequest | undefined = source === undefined ? undefined : { source }; 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 = { 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" }), 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), 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), 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) }), 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) }), 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) }), 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), messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messagePath(session, options, machineId), parseMessagePage),
status: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus), status: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "status", machineId), parseSessionStatus),
models: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse), models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(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 }) }), 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(sessionUrl(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }), 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(sessionQueryUrl(session, "thinking-levels", machineId), parseThinkingLevelsResponse), thinkingLevels: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(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 }) }), 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(sessionUrl(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }), cycleThinkingLevel: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
commands: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "commands", machineId), arrayOf(parseSlashCommand)), 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(sessionUrl(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text, ...(streamingBehavior === undefined ? {} : { streamingBehavior }), ...(attachments !== undefined && attachments.length > 0 ? { attachments } : {}) }) }), 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(sessionUrl(session, "attachments", machineId), parseSavedAttachments, { method: "POST", body: sessionBody(session, { attachments, ...(folder === undefined ? {} : { folder }) }) }), 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(sessionUrl(session, "shell", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text }) }), 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(sessionUrl(session, "commands/run", machineId), parseCommandResult, { 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(sessionUrl(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { requestId, value }) }), 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(sessionUrl(session, "abort", machineId), parseAborted, { method: "POST", body: sessionBody(session) }), abort: (session: SessionLookup, machineId = "local") => request(sessionPath(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) }), stop: (session: SessionLookup, machineId = "local") => request(sessionPath(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) }), archive: (session: SessionLookup, machineId = "local") => request(sessionPath(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) }), archiveWithDescendants: (session: SessionLookup, machineId = "local") => request(sessionPath(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) }), restore: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }),
deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryUrl(session, machineId), parseDeleted, { method: "DELETE" }), deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryPath(session, machineId), parseDeleted, { method: "DELETE" }),
detachParent: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: sessionBody(session) }), detachParent: (session: SessionLookup, machineId = "local") => request(sessionPath(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) }), 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 }) => { authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options?.mode !== undefined) params.set("mode", options.mode); 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> { 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.status === 404) return undefined;
if (!response.ok) { if (!response.ok) {
const body: unknown = await response.json().catch((): unknown => ({})); const body: unknown = await response.json().catch((): unknown => ({}));
@@ -313,7 +314,7 @@ export const filesApi = {
export const gitApi = { export const gitApi = {
gitStatus: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse), 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 = { 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 type { Workspace } from "../../../shared/apiTypes";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes"; 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"; 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 }; const session = { id: "s 1", cwd: workspace.path };
beforeEach(() => {
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
});
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@@ -113,7 +117,6 @@ describe("federated route contract", () => {
webSocketUrls.push(url); webSocketUrls.push(url);
} }
vi.stubGlobal("WebSocket", FakeWebSocket); vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
sessionEvents(session, machineId); sessionEvents(session, machineId);
globalSessionEvents(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 { function routeFromMachineUrl(method: string, input: string | URL | Request, scopedMachineId: string): ObservedHttpRoute {
const url = toUrl(input); const url = toUrl(input);
const prefix = `/api/machines/${encodeURIComponent(scopedMachineId)}`; const prefix = `/api/machines/${encodeURIComponent(scopedMachineId)}`;
if (!url.pathname.startsWith(prefix)) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`); const prefixIndex = url.pathname.lastIndexOf(prefix);
return { method, path: url.pathname.slice(prefix.length) || "/" }; 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 { 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> { export async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers); const headers = new Headers(init?.headers);
if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json"); 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) { if (!response.ok) {
const body: unknown = await response.json().catch((): unknown => ({})); const body: unknown = await response.json().catch((): unknown => ({}));
throw new Error(errorMessage(body) ?? response.statusText); throw new Error(errorMessage(body) ?? response.statusText);
+1 -1
View File
@@ -10,7 +10,7 @@ function FakeWebSocket(url: string): void {
beforeEach(() => { beforeEach(() => {
webSocketUrls.length = 0; webSocketUrls.length = 0;
vi.stubGlobal("WebSocket", FakeWebSocket); vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" }); vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
}); });
afterEach(() => { afterEach(() => {
+7 -11
View File
@@ -1,4 +1,5 @@
import type { SessionRef } from "../../../shared/apiTypes"; import type { SessionRef } from "../../../shared/apiTypes";
import { resolveAppWebSocketUrl } from "../appUrl";
type SessionLookup = SessionRef | string; 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 cwd = typeof session === "string" ? undefined : session.cwd;
const query = cwd === undefined || cwd === "" ? "" : `?${new URLSearchParams({ cwd }).toString()}`; const query = cwd === undefined || cwd === "" ? "" : `?${new URLSearchParams({ cwd }).toString()}`;
const sessionId = typeof session === "string" ? session : session.id; 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 { 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 { 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))}`; const sizeQuery = initialSize === undefined ? "" : `?${new URLSearchParams({ cols: String(initialSize.cols), rows: String(initialSize.rows) }).toString()}`;
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`); return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`));
} }
export function realtimeEvents(machineId = "local"): WebSocket { 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 { function machinePrefix(machineId: string): string {
return `/api/machines/${encodeURIComponent(machineId)}`; return `api/machines/${encodeURIComponent(machineId)}`;
}
function webSocketBaseUrl(): string {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${location.host}`;
} }
+9 -8
View File
@@ -1,4 +1,5 @@
import type { SessionRef } from "../../../shared/apiTypes"; import type { SessionRef } from "../../../shared/apiTypes";
import { resolveAppUrl } from "../appUrl";
type SessionLookup = SessionRef | string; type SessionLookup = SessionRef | string;
@@ -10,36 +11,36 @@ function sessionCwd(session: SessionLookup): string | undefined {
return typeof session === "string" ? undefined : session.cwd; 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(); const params = new URLSearchParams();
if (options?.path !== undefined) params.set("path", options.path); if (options?.path !== undefined) params.set("path", options.path);
if (options?.staged === true) params.set("staged", "true"); if (options?.staged === true) params.set("staged", "true");
const query = params.toString(); 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 params = new URLSearchParams();
const cwd = sessionCwd(session); const cwd = sessionCwd(session);
if (cwd !== undefined && cwd !== "") params.set("cwd", cwd); if (cwd !== undefined && cwd !== "") params.set("cwd", cwd);
if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.before !== undefined) params.set("before", String(options.before)); if (options?.before !== undefined) params.set("before", String(options.before));
const query = params.toString(); 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 { export function workspaceFileWriteUrl(projectId: string, workspaceId: string, path: string, options?: { createDirs?: boolean; overwrite?: boolean; machineId?: string }): string {
const params = new URLSearchParams({ path }); const params = new URLSearchParams({ path });
if (options?.createDirs === false) params.set("createDirs", "false"); if (options?.createDirs === false) params.set("createDirs", "false");
if (options?.overwrite === false) params.set("overwrite", "false"); if (options?.overwrite === false) params.set("overwrite", "false");
const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`; 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 { export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("path", path); params.set("path", path);
if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt); if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt);
const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`; 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 { import {
effectiveWorkspaceUploadFolder, effectiveWorkspaceUploadFolder,
uploadWorkspaceFile, uploadWorkspaceFile,
@@ -12,6 +12,14 @@ import {
type WorkspaceUploadXhr, type WorkspaceUploadXhr,
} from "./workspaceUploads"; } from "./workspaceUploads";
beforeEach(() => {
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("workspace upload helpers", () => { describe("workspace upload helpers", () => {
it("resolves effective upload defaults and workspace-relative paths", () => { it("resolves effective upload defaults and workspace-relative paths", () => {
expect(effectiveWorkspaceUploadFolder(undefined)).toBe(".pi-web/uploads"); expect(effectiveWorkspaceUploadFolder(undefined)).toBe(".pi-web/uploads");
@@ -40,7 +48,7 @@ describe("workspace upload helpers", () => {
const xhr = xhrs.only(); const xhr = xhrs.only();
expect(xhr.method).toBe("PUT"); 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.headers.get("content-type")).toBe("text/plain");
expect(xhr.body).toBe(file); expect(xhr.body).toBe(file);
@@ -78,13 +86,13 @@ describe("workspace upload helpers", () => {
}); });
const first = xhrs.at(0); 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.emitUploadProgress(1, 2);
first.respondJson(200, { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); first.respondJson(200, { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
await Promise.resolve(); await Promise.resolve();
const second = xhrs.at(1); 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.emitUploadProgress(3, 3);
second.respondJson(200, { path: "uploads/manual/b.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true }); 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(); 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 }); xhr.respondJson(200, { path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
await expect(task.promise).resolves.toEqual([ 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); const existing = this.machinePluginLoadPromises.get(machine.id);
if (existing !== undefined) return existing; 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, machineId: machine.id,
shouldLoadPlugin: (entry) => this.plugins.shouldLoadRemotePlugin(entry.id, entry.machineSpecific), shouldLoadPlugin: (entry) => this.plugins.shouldLoadRemotePlugin(entry.id, entry.machineSpecific),
})) }))
@@ -32,6 +32,7 @@ describe("SessionController reload and selection", () => {
return Promise.resolve(freshPage); return Promise.resolve(freshPage);
}, },
status: (session) => Promise.resolve(status(sessionLookupId(session))), status: (session) => Promise.resolve(status(sessionLookupId(session))),
thinkingLevels: () => Promise.resolve({ levels: [] }),
}; };
const controller = new SessionController( const controller = new SessionController(
() => state, () => 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 { machineScopedPluginId } from "../../../shared/machinePluginIds";
import { resolveAppUrl, type AppUrlContext } from "../appUrl";
import type { PiWebPlugin, PiWebPluginRegistration } from "./types"; import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
export interface PluginManifestEntry { export interface PluginManifestEntry {
@@ -14,18 +15,20 @@ interface PluginManifest {
export interface LoadExternalPluginsOptions { export interface LoadExternalPluginsOptions {
machineId?: string; machineId?: string;
shouldLoadPlugin?: (entry: PluginManifestEntry) => boolean; shouldLoadPlugin?: (entry: PluginManifestEntry) => boolean;
moduleLoader?: (moduleUrl: string) => Promise<unknown>;
} }
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> { export async function loadExternalPlugins(manifestUrl = "pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
const manifest = await fetchPluginManifest(manifestUrl); const resolvedManifestUrl = resolveAppUrl(manifestUrl);
const manifest = await fetchPluginManifest(resolvedManifestUrl);
if (manifest === undefined) return []; if (manifest === undefined) return [];
const registrations: PiWebPluginRegistration[] = []; const registrations: PiWebPluginRegistration[] = [];
for (const entry of manifest.plugins) { for (const entry of manifest.plugins) {
if (options.shouldLoadPlugin?.(entry) === false) continue; if (options.shouldLoadPlugin?.(entry) === false) continue;
try { try {
const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).toString(); const moduleUrl = resolvePluginModuleUrl(entry.module, resolvedManifestUrl);
const module: unknown = await import(/* @vite-ignore */ moduleUrl); const module = await (options.moduleLoader ?? importPluginModule)(moduleUrl);
const plugin = parsePluginModule(module, moduleUrl); const plugin = parsePluginModule(module, moduleUrl);
registrations.push({ registrations.push({
id: options.machineId === undefined ? entry.id : machineScopedPluginId(options.machineId, entry.id), 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; 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> { async function fetchPluginManifest(manifestUrl: string): Promise<PluginManifest | undefined> {
const response = await fetch(manifestUrl, { cache: "no-store" }); const response = await fetch(manifestUrl, { cache: "no-store" });
if (response.status === 404) return undefined; 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(); registerAppTestHooks();
describe("buildApp PI WEB plugin routes", () => { 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" }); const manifestResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
expect(manifestResponse.statusCode).toBe(200); expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }); expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/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); 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 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 remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn(() => Promise.resolve({ 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 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 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.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({ 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 }); 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` }); 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"); 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 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 remote = addResponse.json<{ id: string }>();
appTestContext.remoteClient = fakeRemoteClient({ appTestContext.remoteClient = fakeRemoteClient({
@@ -91,9 +96,12 @@ describe("buildApp PI WEB plugin routes", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: { body: {
plugins: [ plugins: [
{ id: "safe-tools", module: "nested/pi-web-plugin.js?v=1", source: "local", scope: "local" }, { id: "safe-tools", module: "./safe-tools/nested/pi-web-plugin.js?v=1", source: "local", scope: "local" },
{ id: "traversal-tools", module: "..%2F..%2Fapi%2Fconfig", 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: "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.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({ 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 []; if (modulePath === undefined) return [];
return [{ return [{
...plugin, ...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 { function remotePluginModulePath(pluginId: string, module: string): { path: string; query: string } | undefined {
if (!isPiWebPluginId(pluginId)) return undefined; if (!isPiWebPluginId(pluginId)) return undefined;
const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`; 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 { try {
const url = new URL(module, base); // An explicit ./<plugin-id>/ prefix is manifest-relative; bare paths retain the legacy plugin-root-relative contract.
if (url.origin !== base.origin || !url.pathname.startsWith(prefix)) return undefined; 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)); const path = safeRemotePluginAssetPath(url.pathname.slice(prefix.length));
return path === undefined ? undefined : { path, query: url.search }; return path === undefined ? undefined : { path, query: url.search };
} catch { } catch {
+5 -2
View File
@@ -37,7 +37,10 @@ describe("PiWebPluginService", () => {
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })], plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })],
}); });
const manifest = await service.manifest(); 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"); const asset = await service.readAsset("info", "pi-web-plugin.js");
expect(asset?.contentType).toBe("application/javascript; charset=utf-8"); 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 service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
const manifest = await service.manifest(); 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.pathname).toBe("/pi-web-plugins/updates/pi-web-plugin.js");
expect(moduleUrl.searchParams.get("v")).toMatch(/^\d+$/u); expect(moduleUrl.searchParams.get("v")).toMatch(/^\d+$/u);
expect(moduleUrl.searchParams.get("piWebDockerMode")).toBe("dev"); expect(moduleUrl.searchParams.get("piWebDockerMode")).toBe("dev");
+1
View File
@@ -93,6 +93,7 @@ function devDocsPlugin(): Plugin {
export default defineConfig({ export default defineConfig({
plugins: [devDocsPlugin()], plugins: [devDocsPlugin()],
root: "src/client", root: "src/client",
base: "./",
build: { build: {
outDir: "../../dist/client", outDir: "../../dist/client",
emptyOutDir: true, emptyOutDir: true,