Merge remote-tracking branch 'origin/main' into pr-36-generic-agent-config

# Conflicts:
#	docs/config.html
#	docs/config.md
#	src/cli.test.ts
#	src/cli.ts
#	src/client/src/components/settings/SettingsSessiondPanel.ts
#	src/client/src/components/settings/settingsConfigDraft.test.ts
#	src/client/src/components/settings/settingsConfigDraft.ts
#	src/server/app.test.ts
#	src/server/app.ts
#	src/server/configRoutes.test.ts
#	src/server/configRoutes.ts
#	src/server/piWebPluginService.test.ts
#	src/server/piWebPluginService.ts
#	src/server/piWebStatus.test.ts
#	src/server/piWebStatus.ts
#	src/server/piWebStatusCache.ts
#	src/server/sessions/authService.test.ts
#	src/server/sessions/piSessionService.ts
#	src/server/sessions/sessionRoutes.test.ts
This commit is contained in:
Federico Jaramillo Martinez
2026-07-13 20:30:08 +02:00
289 changed files with 29164 additions and 6419 deletions
+70
View File
@@ -0,0 +1,70 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
import { appTestContext, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp agent config", () => {
it.each(["/api/config", "/api/machines/local/config"])("uses the latest configured agent dir for status after writes through %s", async (configRoute) => {
const originalEnv = captureEnv([
"PI_WEB_SKIP_VERSION_CHECK",
"PI_WEB_DOCKER_RUNTIME",
"PI_WEB_AGENT_COMMAND",
"PI_WEB_AGENT_DIR",
"PI_CODING_AGENT_DIR",
]);
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
process.env["PI_WEB_DOCKER_RUNTIME"] = "0";
Reflect.deleteProperty(process.env, "PI_WEB_AGENT_COMMAND");
Reflect.deleteProperty(process.env, "PI_WEB_AGENT_DIR");
Reflect.deleteProperty(process.env, "PI_CODING_AGENT_DIR");
try {
const initialAgentDir = join(appTestContext.tempDir, "initial-agent");
const updatedAgentDir = join(appTestContext.tempDir, "updated-agent");
appTestContext.piWebConfig = { agent: { command: "pi", dir: initialAgentDir } };
await mkdir(initialAgentDir, { recursive: true });
await installConfiguredPiWebPackage(updatedAgentDir);
const initialStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status" });
expect(initialStatus.statusCode).toBe(200);
expect(initialStatus.json<PiWebStatusResponse>().components.web.installation?.kind).not.toBe("pi-package");
const updateResponse = await appTestContext.app.inject({
method: "PUT",
url: configRoute,
payload: { config: { agent: { command: "pi", dir: updatedAgentDir } } },
});
expect(updateResponse.statusCode).toBe(200);
const refreshedStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status" });
expect(refreshedStatus.statusCode).toBe(200);
expect(refreshedStatus.json<PiWebStatusResponse>().components.web.installation).toMatchObject({
kind: "pi-package",
source: process.cwd(),
scope: "user",
});
} finally {
restoreEnv(originalEnv);
}
});
});
async function installConfiguredPiWebPackage(agentDir: string): Promise<void> {
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8");
}
function captureEnv(keys: readonly string[]): Map<string, string | undefined> {
return new Map(keys.map((key) => [key, process.env[key]]));
}
function restoreEnv(values: ReadonlyMap<string, string | undefined>): void {
for (const [key, value] of values) {
if (value === undefined) Reflect.deleteProperty(process.env, key);
else process.env[key] = value;
}
}
+90
View File
@@ -0,0 +1,90 @@
import { Readable } from "node:stream";
import { gunzipSync } from "node:zlib";
import { describe, expect, it, vi } from "vitest";
import { appTestContext, fakeRemoteClient, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("browser-facing HTTP compression", () => {
it("negotiates compression for large local-machine API responses", async () => {
const marker = "local transcript content ".repeat(256);
appTestContext.piWebConfig = {
plugins: { fake: { settings: { marker } } },
};
const compressed = await appTestContext.app.inject({
method: "GET",
url: "/api/machines/local/config",
headers: { "accept-encoding": "gzip" },
});
const identity = await appTestContext.app.inject({
method: "GET",
url: "/api/machines/local/config",
headers: { "accept-encoding": "identity" },
});
expect(compressed.statusCode).toBe(200);
expect(compressed.headers["content-encoding"]).toBe("gzip");
expect(compressed.headers["content-length"]).toBeUndefined();
expect(compressed.headers.vary).toContain("accept-encoding");
expect(gunzipJson(compressed)).toMatchObject({ effectiveConfig: { plugins: { fake: { settings: { marker } } } } });
expect(identity.statusCode).toBe(200);
expect(identity.headers["content-encoding"]).toBeUndefined();
expect(identity.json()).toMatchObject({ effectiveConfig: { plugins: { fake: { settings: { marker } } } } });
});
it("negotiates compression after streaming a remote-machine API response", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/machines",
payload: { name: "Remote", baseUrl: "https://remote.example.test/" },
});
const remote = addResponse.json<{ id: string }>();
const projects = Array.from({ length: 64 }, (_, index) => ({
id: `p-${String(index)}`,
name: `Remote project ${String(index)}`,
path: `/repos/project-${String(index)}`,
createdAt: "2026-07-11T00:00:00.000Z",
}));
const body = JSON.stringify(projects);
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: {
"content-type": "application/json",
"content-length": String(Buffer.byteLength(body)),
},
body: Readable.from([body]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const url = `/api/machines/${remote.id}/projects`;
const compressed = await appTestContext.app.inject({
method: "GET",
url,
headers: { "accept-encoding": "gzip" },
});
const identity = await appTestContext.app.inject({
method: "GET",
url,
headers: { "accept-encoding": "identity" },
});
expect(compressed.statusCode).toBe(200);
expect(compressed.headers["content-encoding"]).toBe("gzip");
expect(compressed.headers["content-length"]).toBeUndefined();
expect(compressed.headers.vary).toContain("accept-encoding");
expect(gunzipJson(compressed)).toEqual(projects);
expect(identity.statusCode).toBe(200);
expect(identity.headers["content-encoding"]).toBeUndefined();
expect(identity.json()).toEqual(projects);
expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/projects", undefined);
expect(request).toHaveBeenNthCalledWith(2, "GET", "/api/projects", undefined);
});
});
function gunzipJson(response: { rawPayload: Buffer }): unknown {
const value: unknown = JSON.parse(gunzipSync(response.rawPayload).toString("utf8"));
return value;
}
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import type { Project, Workspace } from "./types.js";
import { appTestContext, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp local machine aliases", () => {
it("serves local session and terminal proxy routes through machine-scoped aliases", async () => {
const sessionsResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/local/sessions?cwd=${encodeURIComponent(appTestContext.projectDir)}` });
expect(sessionsResponse.statusCode).toBe(200);
expect(sessionsResponse.json()).toEqual({ method: "GET", path: `/sessions?cwd=${encodeURIComponent(appTestContext.projectDir)}` });
expect(appTestContext.sessionDaemonRequests).toEqual([{ method: "GET", path: `/sessions?cwd=${encodeURIComponent(appTestContext.projectDir)}` }]);
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/machines/local/projects",
payload: { name: "Machine Local", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const terminalResponse = await appTestContext.app.inject({
method: "POST",
url: `/api/machines/local/projects/${project.id}/workspaces/${workspace.id}/terminal-command-runs`,
payload: { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } },
});
const closeTerminalsResponse = await appTestContext.app.inject({ method: "DELETE", url: `/api/machines/local/projects/${project.id}/workspaces/${workspace.id}/terminals` });
expect(terminalResponse.statusCode).toBe(200);
expect(terminalResponse.json()).toEqual({
method: "POST",
path: "/terminal-command-runs",
body: {
origin: "core",
projectId: project.id,
workspaceId: workspace.id,
cwd: appTestContext.projectDir,
title: "Build",
command: "npm test",
metadata: { "pi.operation": "test" },
},
});
expect(closeTerminalsResponse.statusCode).toBe(200);
expect(closeTerminalsResponse.json()).toEqual({ method: "DELETE", path: `/terminals?cwd=${encodeURIComponent(appTestContext.projectDir)}` });
expect(appTestContext.sessionDaemonRequests[1]).toEqual({
method: "POST",
path: "/terminal-command-runs",
body: {
origin: "core",
projectId: project.id,
workspaceId: workspace.id,
cwd: appTestContext.projectDir,
title: "Build",
command: "npm test",
metadata: { "pi.operation": "test" },
},
});
expect(appTestContext.sessionDaemonRequests[2]).toEqual({ method: "DELETE", path: `/terminals?cwd=${encodeURIComponent(appTestContext.projectDir)}` });
});
it("serves local projects and workspaces through machine-scoped aliases", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/machines/local/projects",
payload: { name: "Machine Local", path: appTestContext.projectDir, create: true },
});
expect(addResponse.statusCode).toBe(200);
const project = addResponse.json<Project>();
const listResponse = await appTestContext.app.inject({ method: "GET", url: "/api/machines/local/projects" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json<Project[]>()).toEqual([project]);
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([expect.objectContaining({ projectId: project.id, path: appTestContext.projectDir })]);
});
});
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it, vi } from "vitest";
import type { MachineClient } from "./machines/machineClient.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import { appTestContext, configFromMachineConfigWriteBody, fakeRemoteClient, fullPiWebConfig, piWebConfigResponse, registerAppTestHooks, selectedMachinePiWebConfig } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp machine routes", () => {
it("lists synthesized local machine through the HTTP contract", async () => {
const response = await appTestContext.app.inject({ method: "GET", url: "/api/machines" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ machines: [{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" }] });
});
it("adds remote machines without exposing tokens", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/", token: "secret" } });
expect(addResponse.statusCode).toBe(200);
expect(addResponse.json()).toMatchObject({ name: "Remote", kind: "remote", baseUrl: "https://remote.example.test" });
expect(addResponse.json()).not.toHaveProperty("token");
});
it("reports machine health for local and remote machines", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson: MachineClient["requestJson"] = () => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: {
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "Remote Web", stale: false, available: true },
sessiond: { component: "sessiond", label: "Remote Sessiond", stale: false, available: true },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: { update: "", restart: "", restartSystemd: "", restartDev: "" },
messages: [],
},
});
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const localHealth = await appTestContext.app.inject({ method: "GET", url: "/api/machines/local/health" });
const remoteHealth = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/health` });
expect(localHealth.statusCode).toBe(200);
expect(localHealth.json()).toMatchObject({ machineId: "local", ok: true, status: "online" });
expect(remoteHealth.statusCode).toBe(200);
expect(remoteHealth.json()).toMatchObject({ machineId: remote.id, ok: true, status: "online" });
});
it("reports effective machine runtime capabilities for remote machines", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: {
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
},
}));
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const runtime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` });
expect(runtime.statusCode).toBe(200);
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
});
it("filters remote selected-machine config reads to machine-safe keys", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json", "set-cookie": "secret=1" },
body: piWebConfigResponse(fullPiWebConfig()),
}));
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/config` });
expect(response.statusCode).toBe(200);
expect(response.headers["set-cookie"]).toBeUndefined();
expect(response.json<PiWebConfigResponse>()).toEqual({
...piWebConfigResponse(fullPiWebConfig()),
config: selectedMachinePiWebConfig(),
effectiveConfig: selectedMachinePiWebConfig(),
});
expect(requestJson).toHaveBeenCalledWith("GET", "/api/config");
});
it("merges remote selected-machine config updates into the target machine config", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(fullPiWebConfig()) });
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(configFromMachineConfigWriteBody(body)) });
});
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/srv/remote"] }, uploads: { defaultFolder: "remote\\uploads" }, maxUploadBytes: 4096, spawnSessions: true, agent: { command: "remote-agent", dir: "/srv/remote-agent" } } },
});
const expectedMerged: PiWebConfigValues = {
...fullPiWebConfig(),
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/srv/remote"] },
uploads: { defaultFolder: "remote/uploads" },
maxUploadBytes: 4096,
spawnSessions: true,
agent: { command: "remote-agent", dir: "/srv/remote-agent" },
};
expect(response.statusCode).toBe(200);
expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config");
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", { config: expectedMerged });
expect(response.json<PiWebConfigResponse>().config).toEqual({
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/srv/remote"] },
uploads: { defaultFolder: "remote/uploads" },
maxUploadBytes: 4096,
spawnSessions: true,
subsessions: false,
agent: { command: "remote-agent", dir: "/srv/remote-agent" },
});
});
it("rejects unsafe remote selected-machine config keys before proxying", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>();
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { host: "0.0.0.0", allowedHosts: true, shortcuts: { "core:view.chat": "mod+1" }, spawnSessions: true } },
});
expect(response.statusCode).toBe(400);
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config key is not allowed: host");
expect(requestJson).not.toHaveBeenCalled();
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { appTestContext, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp Pi package routes", () => {
it("serves Pi package management routes through the app wiring", async () => {
const listResponse = await appTestContext.app.inject({ method: "GET", url: "/api/pi-packages" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }] });
const installResponse = await appTestContext.app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools" } });
expect(installResponse.statusCode).toBe(200);
expect(installResponse.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" });
const localAliasResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines/local/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "user" } });
expect(localAliasResponse.statusCode).toBe(200);
expect(localAliasResponse.json()).toMatchObject({ action: "remove", source: "npm:@acme/tools", scope: "user" });
expect(appTestContext.piPackageRequests).toEqual([
{ action: "list" },
{ action: "install", source: "npm:@acme/new-tools" },
{ action: "remove", source: "npm:@acme/tools", scope: "user" },
]);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
import { buildApp } from "./app.js";
describe("PI WEB status routes", () => {
it("forces a fresh status load when refresh is requested", async () => {
const get = vi.fn(() => Promise.resolve(status("cached")));
const refresh = vi.fn(() => Promise.resolve(status("forced")));
const invalidate = vi.fn();
const app = await buildApp({ piWebStatusCache: { get, refresh, invalidate }, clientDist: false, logger: false });
try {
const cachedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status" });
const forcedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status?refresh=1" });
expect(cachedResponse.json<PiWebStatusResponse>().generatedAt).toBe("cached");
expect(forcedResponse.json<PiWebStatusResponse>().generatedAt).toBe("forced");
expect(get).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledWith({ force: true });
} finally {
await app.close();
}
});
});
function status(generatedAt: string): PiWebStatusResponse {
return {
packageName: "@jmfederico/pi-web",
generatedAt,
components: {
web: { component: "web", label: "Web/UI", stale: false, available: true },
sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: {},
messages: [],
};
}
+134
View File
@@ -0,0 +1,134 @@
import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
import { appTestContext, fakeRemoteClient, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp PI WEB plugin routes", () => {
it("serves application-root plugin modules through the manifest and plugin-list APIs", async () => {
const manifestResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] });
const pluginsResponse = await appTestContext.app.inject({ method: "GET", url: "/api/plugins" });
expect(pluginsResponse.statusCode).toBe(200);
expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
const localMachinePluginsResponse = await appTestContext.app.inject({ method: "GET", url: "/api/machines/local/plugins" });
expect(localMachinePluginsResponse.statusCode).toBe(200);
expect(localMachinePluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
const assetResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
expect(assetResponse.statusCode).toBe(200);
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
expect(assetResponse.body).toBe("export default {};");
const svgResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/assets/icon.svg" });
expect(svgResponse.statusCode).toBe(200);
expect(svgResponse.headers["content-type"]).toContain("image/svg+xml");
expect(svgResponse.body).toContain("<svg");
const missingResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/missing.js" });
expect(missingResponse.statusCode).toBe(404);
});
it("proxies remote machine plugin lists for settings", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json", "set-cookie": "secret=1" },
body: Readable.from([JSON.stringify({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/plugins` });
expect(response.statusCode).toBe(200);
expect(response.headers["set-cookie"]).toBeUndefined();
expect(response.json()).toEqual({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] });
expect(request).toHaveBeenCalledWith("GET", "/api/plugins", undefined);
});
it("rewrites existing root-style remote plugin manifests and proxies their assets", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local", machineSpecific: true }] },
}));
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/javascript", "set-cookie": "secret=1" },
body: Readable.from(["export default {};"]),
}));
appTestContext.remoteClient = fakeRemoteClient({ requestJson, request });
const manifestResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` });
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
const rewrittenModule = `../../../../pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`;
expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({
plugins: [{ id: "remote-tools", module: rewrittenModule, source: "local", scope: "local", machineSpecific: true }],
});
expect(new URL(rewrittenModule, `https://gateway.example.test/api/machines/${remote.id}/pi-web-plugins/manifest.json`).toString())
.toBe(`https://gateway.example.test/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`);
expect(new URL(rewrittenModule, `https://gateway.example.test/test/ai/api/machines/${remote.id}/pi-web-plugins/manifest.json`).toString())
.toBe(`https://gateway.example.test/test/ai/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`);
expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 });
const assetResponse = await appTestContext.app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123` });
expect(assetResponse.statusCode).toBe(200);
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
expect(assetResponse.headers["set-cookie"]).toBeUndefined();
expect(assetResponse.body).toBe("export default {};");
expect(request).toHaveBeenCalledWith("GET", "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123");
});
it("accepts manifest-relative and legacy plugin-root-relative modules while dropping unsafe remote modules", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
appTestContext.remoteClient = fakeRemoteClient({
requestJson: vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: {
plugins: [
{ id: "safe-tools", module: "./safe-tools/nested/pi-web-plugin.js?v=1", source: "local", scope: "local" },
{ id: "legacy-tools", module: "nested/pi-web-plugin.js?v=2", source: "local", scope: "local" },
{ id: "traversal-tools", module: "./traversal-tools/..%2F..%2Fapi%2Fconfig", source: "local", scope: "local" },
{ id: "wrong-root", module: "/pi-web-plugins/other/pi-web-plugin.js", source: "local", scope: "local" },
{ id: "cross-origin", module: "https://plugins.example.test/pi-web-plugin.js", source: "local", scope: "local" },
{ id: "malformed", module: "nested/%E0%A4%A.js", source: "local", scope: "local" },
],
},
})),
});
const manifestResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` });
expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({
plugins: [
{ id: "safe-tools", module: `../../../../pi-web-plugins/${machineScopedPluginId(remote.id, "safe-tools")}/nested/pi-web-plugin.js?v=1`, source: "local", scope: "local" },
{ id: "legacy-tools", module: `../../../../pi-web-plugins/${machineScopedPluginId(remote.id, "legacy-tools")}/nested/pi-web-plugin.js?v=2`, source: "local", scope: "local" },
],
});
});
it("rejects remote machine plugin asset traversal before proxying", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }));
appTestContext.remoteClient = fakeRemoteClient({ request });
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
const response = await appTestContext.app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/..%2F..%2Fapi%2Fconfig` });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Invalid remote PI WEB plugin asset path" });
expect(request).not.toHaveBeenCalled();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { Project, Workspace } from "./types.js";
import { appTestContext, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp project routes", () => {
it("adds, lists, and closes projects through the HTTP contract", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Example", path: appTestContext.projectDir, create: true },
});
expect(addResponse.statusCode).toBe(200);
const project = addResponse.json<Project>();
expect(project).toMatchObject({ name: "Example", path: appTestContext.projectDir });
expect(project.id).not.toBe("");
const listResponse = await appTestContext.app.inject({ method: "GET", url: "/api/projects" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json<Project[]>()).toEqual([project]);
const closeResponse = await appTestContext.app.inject({ method: "DELETE", url: `/api/projects/${project.id}` });
expect(closeResponse.statusCode).toBe(200);
expect(closeResponse.json()).toEqual({ closed: true });
const emptyListResponse = await appTestContext.app.inject({ method: "GET", url: "/api/projects" });
expect(emptyListResponse.json<Project[]>()).toEqual([]);
});
it("returns stable errors for invalid project requests", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Missing", path: join(appTestContext.tempDir, "missing") },
});
expect(addResponse.statusCode).toBe(400);
expect(addResponse.json()).toHaveProperty("error");
const closeResponse = await appTestContext.app.inject({ method: "DELETE", url: "/api/projects/does-not-exist" });
expect(closeResponse.statusCode).toBe(404);
expect(closeResponse.json()).toEqual({ error: "Project not found" });
});
it("lists a non-git project as a single workspace", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Plain", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([
expect.objectContaining({
projectId: project.id,
path: appTestContext.projectDir,
label: "Plain",
isMain: true,
isGitRepo: false,
isGitWorktree: false,
}),
]);
});
it("exposes the default upload config on workspace responses", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Upload Defaults", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([
expect.objectContaining({
projectId: project.id,
effectiveConfig: { uploads: { defaultFolder: ".pi-web/uploads" } },
}),
]);
});
it("lets project-local upload config override global upload config on workspace responses", async () => {
appTestContext.piWebConfig = { uploads: { defaultFolder: "global-uploads" } };
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Project Upload Defaults", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
await mkdir(join(appTestContext.projectDir, ".pi-web"), { recursive: true });
await writeFile(join(appTestContext.projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, uploads: { defaultFolder: "project-uploads" } }, null, 2)}\n`);
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([
expect.objectContaining({
projectId: project.id,
effectiveConfig: { uploads: { defaultFolder: "project-uploads" } },
}),
]);
});
});
+201
View File
@@ -0,0 +1,201 @@
import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { RemoteMachineRequestError, type MachineClient } from "./machines/machineClient.js";
import { PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS } from "../shared/federatedRoutes.js";
import { appTestContext, fakeRemoteClient, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp remote machine proxy routes", () => {
it("proxies allowlisted remote HTTP routes through the selected machine", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json", connection: "close" },
body: Readable.from([JSON.stringify([{ id: "p1", name: "Remote Project", path: "/repo", createdAt: "now" }])]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects?active=true` });
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toContain("application/json");
expect(response.json()).toEqual([{ id: "p1", name: "Remote Project", path: "/repo", createdAt: "now" }]);
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
});
it("preserves the force-refresh query when proxying update checks", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn<MachineClient["request"]>(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ ok: true })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web/status?refresh=1` });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ ok: true });
expect(request).toHaveBeenCalledWith("GET", "/api/pi-web/status?refresh=1", undefined);
});
it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn<MachineClient["request"]>((method, path, body) => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ method, path, body })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const listResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-packages` });
const installBody = { source: "npm:@acme/new-tools" };
const installResponse = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/pi-packages/install`, payload: installBody });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()).toEqual({ method: "GET", path: "/api/pi-packages" });
expect(installResponse.statusCode).toBe(200);
expect(installResponse.json()).toEqual({ method: "POST", path: "/api/pi-packages/install", body: installBody });
expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/pi-packages", undefined);
expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/pi-packages/install", installBody, { timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS });
});
it("proxies remote workspace effective upload config through the existing federated workspace route", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const remoteWorkspaces = [{
id: "w1",
projectId: "p1",
path: "/repo",
label: "main",
isMain: true,
isGitRepo: false,
isGitWorktree: false,
effectiveConfig: { uploads: { defaultFolder: "remote-project-uploads" } },
}];
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify(remoteWorkspaces)]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces` });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(remoteWorkspaces);
expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces", undefined);
});
it("preserves remote file preview security headers while proxying safe response metadata", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: {
"content-type": "image/svg+xml",
"content-security-policy": "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'",
"x-content-type-options": "nosniff",
"set-cookie": "session=secret",
},
body: Readable.from(["<svg xmlns=\"http://www.w3.org/2000/svg\" />"]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file/preview?path=${encodeURIComponent("diagram.svg")}` });
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toContain("image/svg+xml");
expect(response.headers["content-security-policy"]).toContain("sandbox");
expect(response.headers["x-content-type-options"]).toBe("nosniff");
expect(response.headers["set-cookie"]).toBeUndefined();
expect(response.body).toBe("<svg xmlns=\"http://www.w3.org/2000/svg\" />");
expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces/w1/file/preview?path=diagram.svg", undefined);
});
it("proxies remote workspace file writes as raw request bodies", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ path: "image.png", size: payload.length, modifiedAt: "now", created: true })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file?path=${encodeURIComponent("image.png")}`,
payload,
headers: { "content-type": "application/octet-stream" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ path: "image.png", size: payload.length, modifiedAt: "now", created: true });
expect(request).toHaveBeenCalledWith("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "application/octet-stream" });
});
it("proxies remote terminal command-run and continue routes", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn((method: string, path: string) => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ method, path })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const createBody = { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } };
const deleteWorkspaceResponse = await appTestContext.app.inject({ method: "DELETE", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1` });
const createResponse = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminal-command-runs`, payload: createBody });
const listResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs?projectId=p1&statuses=running` });
const getResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs/run1` });
const cancelResponse = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/terminal-command-runs/run1/cancel` });
const closeWorkspaceTerminalsResponse = await appTestContext.app.inject({ method: "DELETE", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminals` });
const continueResponse = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminals/t1/continue` });
expect(deleteWorkspaceResponse.json()).toEqual({ method: "DELETE", path: "/api/projects/p1/workspaces/w1" });
expect(createResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminal-command-runs" });
expect(listResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs?projectId=p1&statuses=running" });
expect(getResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs/run1" });
expect(cancelResponse.json()).toEqual({ method: "POST", path: "/api/terminal-command-runs/run1/cancel" });
expect(closeWorkspaceTerminalsResponse.json()).toEqual({ method: "DELETE", path: "/api/projects/p1/workspaces/w1/terminals" });
expect(continueResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminals/t1/continue" });
expect(request).toHaveBeenCalledWith("POST", "/api/projects/p1/workspaces/w1/terminal-command-runs", createBody);
});
it("proxies remote session reloads through the selected machine", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ reloaded: true })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/reload`, payload: { cwd: "/repo" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ reloaded: true });
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" });
});
it("forwards remote JSON request bodies and normalizes remote timeouts", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.reject(new RemoteMachineRequestError("timed out", 504)));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/prompt`, payload: { text: "hello" } });
expect(response.statusCode).toBe(504);
expect(response.json()).toMatchObject({ error: "Remote machine timeout", machineId: remote.id, statusCode: 504 });
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/prompt", { text: "hello" });
});
});
-969
View File
@@ -1,969 +0,0 @@
import { mkdir, mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Readable } from "node:stream";
import type { FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildApp } from "./app.js";
import { ProjectService } from "./projects/projectService.js";
import { ProjectStore } from "./storage/projectStore.js";
import { RemoteMachineRequestError, type MachineClient } from "./machines/machineClient.js";
import { MachineService } from "./machines/machineService.js";
import { MachineStore } from "./machines/machineStore.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { PiWebConfigResponse, PiWebConfigValues, PiWebStatusResponse } from "../shared/apiTypes.js";
import type { Project, Workspace } from "./types.js";
let app: FastifyInstance;
let tempDir: string;
let projectDir: string;
let remoteClient: MachineClient | undefined;
let sessionDaemonRequests: CapturedSessionDaemonRequest[];
let piWebConfig: PiWebConfigValues;
beforeEach(async () => {
tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-")));
projectDir = join(tempDir, "project");
remoteClient = undefined;
sessionDaemonRequests = [];
piWebConfig = {};
app = await buildApp({
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
workspaces: new WorkspaceService(),
machines: new MachineService(new MachineStore(join(tempDir, "machines.json")), {
remoteClientFactory: () => {
if (remoteClient === undefined) throw new Error("No remote machine client configured");
return remoteClient;
},
now: () => new Date("2026-05-25T00:00:00.000Z"),
localRuntime: () => Promise.resolve({
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "PI WEB", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
}),
}),
sessionDaemon: fakeSessionDaemon(),
config: fakeConfigService(),
piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
},
clientDist: false,
logger: false,
});
});
afterEach(async () => {
await app.close();
await rm(tempDir, { recursive: true, force: true });
});
describe("buildApp", () => {
it("lists synthesized local machine through the HTTP contract", async () => {
const response = await app.inject({ method: "GET", url: "/api/machines" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ machines: [{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" }] });
});
it("adds remote machines without exposing tokens", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/", token: "secret" } });
expect(addResponse.statusCode).toBe(200);
expect(addResponse.json()).toMatchObject({ name: "Remote", kind: "remote", baseUrl: "https://remote.example.test" });
expect(addResponse.json()).not.toHaveProperty("token");
});
it("reports machine health for local and remote machines", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson: MachineClient["requestJson"] = () => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: {
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "Remote Web", stale: false, available: true },
sessiond: { component: "sessiond", label: "Remote Sessiond", stale: false, available: true },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: { update: "", restart: "", restartSystemd: "", restartDev: "" },
messages: [],
},
});
remoteClient = fakeRemoteClient({ requestJson });
const localHealth = await app.inject({ method: "GET", url: "/api/machines/local/health" });
const remoteHealth = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/health` });
expect(localHealth.statusCode).toBe(200);
expect(localHealth.json()).toMatchObject({ machineId: "local", ok: true, status: "online" });
expect(remoteHealth.statusCode).toBe(200);
expect(remoteHealth.json()).toMatchObject({ machineId: remote.id, ok: true, status: "online" });
});
it("reports effective machine runtime capabilities for remote machines", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: {
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
},
}));
remoteClient = fakeRemoteClient({ requestJson });
const runtime = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` });
expect(runtime.statusCode).toBe(200);
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
});
it("proxies allowlisted remote HTTP routes through the selected machine", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json", connection: "close" },
body: Readable.from([JSON.stringify([{ id: "p1", name: "Remote Project", path: "/repo", createdAt: "now" }])]),
}));
remoteClient = fakeRemoteClient({ request });
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects?active=true` });
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toContain("application/json");
expect(response.json()).toEqual([{ id: "p1", name: "Remote Project", path: "/repo", createdAt: "now" }]);
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
});
it("proxies remote workspace effective upload config through the existing federated workspace route", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const remoteWorkspaces = [{
id: "w1",
projectId: "p1",
path: "/repo",
label: "main",
isMain: true,
isGitRepo: false,
isGitWorktree: false,
effectiveConfig: { uploads: { defaultFolder: "remote-project-uploads" } },
}];
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify(remoteWorkspaces)]),
}));
remoteClient = fakeRemoteClient({ request });
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces` });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(remoteWorkspaces);
expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces", undefined);
});
it("preserves remote file preview security headers while proxying safe response metadata", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: {
"content-type": "image/svg+xml",
"content-security-policy": "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'",
"x-content-type-options": "nosniff",
"set-cookie": "session=secret",
},
body: Readable.from(["<svg xmlns=\"http://www.w3.org/2000/svg\" />"]),
}));
remoteClient = fakeRemoteClient({ request });
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file/preview?path=${encodeURIComponent("diagram.svg")}` });
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toContain("image/svg+xml");
expect(response.headers["content-security-policy"]).toContain("sandbox");
expect(response.headers["x-content-type-options"]).toBe("nosniff");
expect(response.headers["set-cookie"]).toBeUndefined();
expect(response.body).toBe("<svg xmlns=\"http://www.w3.org/2000/svg\" />");
expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces/w1/file/preview?path=diagram.svg", undefined);
});
it("proxies remote workspace file writes as raw request bodies", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ path: "image.png", size: payload.length, modifiedAt: "now", created: true })]),
}));
remoteClient = fakeRemoteClient({ request });
const response = await app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file?path=${encodeURIComponent("image.png")}`,
payload,
headers: { "content-type": "application/octet-stream" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ path: "image.png", size: payload.length, modifiedAt: "now", created: true });
expect(request).toHaveBeenCalledWith("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "application/octet-stream" });
});
it("proxies remote terminal command-run and continue routes", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn((method: string, path: string) => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ method, path })]),
}));
remoteClient = fakeRemoteClient({ request });
const createBody = { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } };
const deleteWorkspaceResponse = await app.inject({ method: "DELETE", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1` });
const createResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminal-command-runs`, payload: createBody });
const listResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs?projectId=p1&statuses=running` });
const getResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs/run1` });
const cancelResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/terminal-command-runs/run1/cancel` });
const closeWorkspaceTerminalsResponse = await app.inject({ method: "DELETE", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminals` });
const continueResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminals/t1/continue` });
expect(deleteWorkspaceResponse.json()).toEqual({ method: "DELETE", path: "/api/projects/p1/workspaces/w1" });
expect(createResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminal-command-runs" });
expect(listResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs?projectId=p1&statuses=running" });
expect(getResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs/run1" });
expect(cancelResponse.json()).toEqual({ method: "POST", path: "/api/terminal-command-runs/run1/cancel" });
expect(closeWorkspaceTerminalsResponse.json()).toEqual({ method: "DELETE", path: "/api/projects/p1/workspaces/w1/terminals" });
expect(continueResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminals/t1/continue" });
expect(request).toHaveBeenCalledWith("POST", "/api/projects/p1/workspaces/w1/terminal-command-runs", createBody);
});
it("proxies remote session reloads through the selected machine", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ reloaded: true })]),
}));
remoteClient = fakeRemoteClient({ request });
const response = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/reload`, payload: { cwd: "/repo" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ reloaded: true });
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" });
});
it("forwards remote JSON request bodies and normalizes remote timeouts", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.reject(new RemoteMachineRequestError("timed out", 504)));
remoteClient = fakeRemoteClient({ request });
const response = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/prompt`, payload: { text: "hello" } });
expect(response.statusCode).toBe(504);
expect(response.json()).toMatchObject({ error: "Remote machine timeout", machineId: remote.id, statusCode: 504 });
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/prompt", { text: "hello" });
});
it("adds, lists, and closes projects through the HTTP contract", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Example", path: projectDir, create: true },
});
expect(addResponse.statusCode).toBe(200);
const project = addResponse.json<Project>();
expect(project).toMatchObject({ name: "Example", path: projectDir });
expect(project.id).not.toBe("");
const listResponse = await app.inject({ method: "GET", url: "/api/projects" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json<Project[]>()).toEqual([project]);
const closeResponse = await app.inject({ method: "DELETE", url: `/api/projects/${project.id}` });
expect(closeResponse.statusCode).toBe(200);
expect(closeResponse.json()).toEqual({ closed: true });
const emptyListResponse = await app.inject({ method: "GET", url: "/api/projects" });
expect(emptyListResponse.json<Project[]>()).toEqual([]);
});
it("serves local session and terminal proxy routes through machine-scoped aliases", async () => {
const sessionsResponse = await app.inject({ method: "GET", url: `/api/machines/local/sessions?cwd=${encodeURIComponent(projectDir)}` });
expect(sessionsResponse.statusCode).toBe(200);
expect(sessionsResponse.json()).toEqual({ method: "GET", path: `/sessions?cwd=${encodeURIComponent(projectDir)}` });
expect(sessionDaemonRequests).toEqual([{ method: "GET", path: `/sessions?cwd=${encodeURIComponent(projectDir)}` }]);
const addResponse = await app.inject({
method: "POST",
url: "/api/machines/local/projects",
payload: { name: "Machine Local", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const terminalResponse = await app.inject({
method: "POST",
url: `/api/machines/local/projects/${project.id}/workspaces/${workspace.id}/terminal-command-runs`,
payload: { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } },
});
const closeTerminalsResponse = await app.inject({ method: "DELETE", url: `/api/machines/local/projects/${project.id}/workspaces/${workspace.id}/terminals` });
expect(terminalResponse.statusCode).toBe(200);
expect(terminalResponse.json()).toEqual({
method: "POST",
path: "/terminal-command-runs",
body: {
origin: "core",
projectId: project.id,
workspaceId: workspace.id,
cwd: projectDir,
title: "Build",
command: "npm test",
metadata: { "pi.operation": "test" },
},
});
expect(closeTerminalsResponse.statusCode).toBe(200);
expect(closeTerminalsResponse.json()).toEqual({ method: "DELETE", path: `/terminals?cwd=${encodeURIComponent(projectDir)}` });
expect(sessionDaemonRequests[1]).toEqual({
method: "POST",
path: "/terminal-command-runs",
body: {
origin: "core",
projectId: project.id,
workspaceId: workspace.id,
cwd: projectDir,
title: "Build",
command: "npm test",
metadata: { "pi.operation": "test" },
},
});
expect(sessionDaemonRequests[2]).toEqual({ method: "DELETE", path: `/terminals?cwd=${encodeURIComponent(projectDir)}` });
});
it("serves local projects and workspaces through machine-scoped aliases", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/machines/local/projects",
payload: { name: "Machine Local", path: projectDir, create: true },
});
expect(addResponse.statusCode).toBe(200);
const project = addResponse.json<Project>();
const listResponse = await app.inject({ method: "GET", url: "/api/machines/local/projects" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json<Project[]>()).toEqual([project]);
const workspacesResponse = await app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([expect.objectContaining({ projectId: project.id, path: projectDir })]);
});
it("serves the PI WEB plugin manifest and plugin assets", async () => {
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] });
const pluginsResponse = await app.inject({ method: "GET", url: "/api/plugins" });
expect(pluginsResponse.statusCode).toBe(200);
expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
const assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
expect(assetResponse.statusCode).toBe(200);
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
expect(assetResponse.body).toBe("export default {};");
const missingResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/missing.js" });
expect(missingResponse.statusCode).toBe(404);
});
it("rewrites and proxies remote machine plugin manifests and assets", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local", machineSpecific: true }] },
}));
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/javascript", "set-cookie": "secret=1" },
body: Readable.from(["export default {};"]),
}));
remoteClient = fakeRemoteClient({ requestJson, request });
const manifestResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` });
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({
plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local", machineSpecific: true }],
});
expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 });
const assetResponse = await app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123` });
expect(assetResponse.statusCode).toBe(200);
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
expect(assetResponse.headers["set-cookie"]).toBeUndefined();
expect(assetResponse.body).toBe("export default {};");
expect(request).toHaveBeenCalledWith("GET", "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123");
});
it("drops unsafe remote machine plugin manifest modules", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
remoteClient = fakeRemoteClient({
requestJson: vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: {
plugins: [
{ id: "safe-tools", module: "nested/pi-web-plugin.js?v=1", source: "local", scope: "local" },
{ id: "traversal-tools", module: "..%2F..%2Fapi%2Fconfig", source: "local", scope: "local" },
{ id: "wrong-root", module: "/pi-web-plugins/other/pi-web-plugin.js", source: "local", scope: "local" },
],
},
})),
});
const manifestResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` });
expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({
plugins: [{ id: "safe-tools", module: `/pi-web-plugins/${machineScopedPluginId(remote.id, "safe-tools")}/nested/pi-web-plugin.js?v=1`, source: "local", scope: "local" }],
});
});
it("rejects remote machine plugin asset traversal before proxying", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }));
remoteClient = fakeRemoteClient({ request });
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
const response = await app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/..%2F..%2Fapi%2Fconfig` });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Invalid remote PI WEB plugin asset path" });
expect(request).not.toHaveBeenCalled();
});
it("returns stable errors for invalid project requests", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Missing", path: join(tempDir, "missing") },
});
expect(addResponse.statusCode).toBe(400);
expect(addResponse.json()).toHaveProperty("error");
const closeResponse = await app.inject({ method: "DELETE", url: "/api/projects/does-not-exist" });
expect(closeResponse.statusCode).toBe(404);
expect(closeResponse.json()).toEqual({ error: "Project not found" });
});
it("lists a non-git project as a single workspace", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Plain", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([
expect.objectContaining({
projectId: project.id,
path: projectDir,
label: "Plain",
isMain: true,
isGitRepo: false,
isGitWorktree: false,
}),
]);
});
it("exposes the default upload config on workspace responses", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Upload Defaults", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([
expect.objectContaining({
projectId: project.id,
effectiveConfig: { uploads: { defaultFolder: ".pi-web/uploads" } },
}),
]);
});
it("lets project-local upload config override global upload config on workspace responses", async () => {
piWebConfig = { uploads: { defaultFolder: "global-uploads" } };
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Project Upload Defaults", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
await mkdir(join(projectDir, ".pi-web"), { recursive: true });
await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, uploads: { defaultFolder: "project-uploads" } }, null, 2)}\n`);
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([
expect.objectContaining({
projectId: project.id,
effectiveConfig: { uploads: { defaultFolder: "project-uploads" } },
}),
]);
});
it("uses the latest configured agent dir for PI WEB status after config writes", async () => {
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
try {
const initialAgentDir = join(tempDir, "initial-agent");
const updatedAgentDir = join(tempDir, "updated-agent");
piWebConfig = { agent: { command: "pi", dir: initialAgentDir } };
await mkdir(initialAgentDir, { recursive: true });
await installConfiguredPiWebPackage(updatedAgentDir);
const initialStatus = await app.inject({ method: "GET", url: "/api/pi-web/status" });
expect(initialStatus.statusCode).toBe(200);
const updateResponse = await app.inject({
method: "PUT",
url: "/api/config",
payload: { config: { agent: { command: "pi", dir: updatedAgentDir } } },
});
expect(updateResponse.statusCode).toBe(200);
const refreshedStatus = await app.inject({ method: "GET", url: "/api/pi-web/status" });
expect(refreshedStatus.statusCode).toBe(200);
expect(refreshedStatus.json<PiWebStatusResponse>().components.web.installation).toMatchObject({ kind: "pi-package", source: process.cwd(), scope: "user" });
} finally {
restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck);
}
});
it("serves supported workspace images as previews", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Images", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const svg = "<svg xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"1\" height=\"1\" /></svg>";
await writeFile(join(projectDir, "diagram.svg"), svg);
await writeFile(join(projectDir, "note.txt"), "hello");
await writeFile(join(projectDir, "huge.png"), "");
await truncate(join(projectDir, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1);
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const previewResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("diagram.svg")}` });
expect(previewResponse.statusCode).toBe(200);
expect(previewResponse.headers["content-type"]).toContain("image/svg+xml");
expect(previewResponse.headers["cache-control"]).toBe("private, max-age=3600");
expect(previewResponse.headers["content-security-policy"]).toContain("sandbox");
expect(previewResponse.headers["x-content-type-options"]).toBe("nosniff");
expect(previewResponse.body).toBe(svg);
const rejectedResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("note.txt")}` });
expect(rejectedResponse.statusCode).toBe(400);
expect(rejectedResponse.json()).toEqual({ error: "Image preview is not supported for this file type" });
const tooLargeResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("huge.png")}` });
expect(tooLargeResponse.statusCode).toBe(400);
expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" });
});
it("keeps normal file suggestions workspace-local when path access config is invalid", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Local Suggestions", path: projectDir, create: true },
});
expect(addResponse.statusCode).toBe(200);
await writeFile(join(projectDir, "sdk.md"), "local sdk\n");
await mkdir(join(projectDir, ".pi-web"), { recursive: true });
await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [""] } }, null, 2)}\n`);
const response = await app.inject({ method: "GET", url: `/api/files?cwd=${encodeURIComponent(projectDir)}&q=sdk&scope=all` });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([{ path: "sdk.md", kind: "other" }]);
});
it("serves project-configured allowed external files through the workspace explorer", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "External", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const externalDir = join(tempDir, "external-docs");
const deniedFile = join(tempDir, "secret.md");
await mkdir(externalDir);
await writeFile(join(externalDir, "sdk.md"), "external sdk\n");
await writeFile(deniedFile, "secret\n");
await mkdir(join(projectDir, ".pi-web"), { recursive: true });
await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [externalDir] } }, null, 2)}\n`);
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const fileResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(join(externalDir, "sdk.md"))}` });
const treeResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/tree?path=${encodeURIComponent(externalDir)}` });
const suggestionResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=${encodeURIComponent(join(externalDir, "s"))}` });
const localSuggestionResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=sdk` });
const deniedResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(deniedFile)}` });
expect(fileResponse.statusCode).toBe(200);
expect(fileResponse.json()).toMatchObject({ path: join(externalDir, "sdk.md"), content: "external sdk\n", binary: false });
expect(treeResponse.statusCode).toBe(200);
expect(treeResponse.json()).toMatchObject({
path: externalDir,
entries: [expect.objectContaining({ name: "sdk.md", path: join(externalDir, "sdk.md"), type: "file" })],
truncated: false,
});
expect(suggestionResponse.statusCode).toBe(200);
expect(suggestionResponse.json()).toEqual([{ path: join(externalDir, "sdk.md"), kind: "other" }]);
expect(localSuggestionResponse.statusCode).toBe(200);
expect(localSuggestionResponse.json()).toEqual([]);
expect(deniedResponse.statusCode).toBe(400);
expect(deniedResponse.json()).toEqual({ error: "Path is outside allowed paths" });
});
it("writes workspace files through the HTTP contract", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "WriteTest", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const writeTextResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
payload: "hello world",
headers: { "content-type": "text/plain" },
});
expect(writeTextResponse.statusCode).toBe(200);
expect(writeTextResponse.json()).toMatchObject({ path: "hello.txt", created: true });
expect(typeof writeTextResponse.json<{ size: unknown }>().size).toBe("number");
const readResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}` });
expect(readResponse.json<{ content: unknown }>().content).toBe("hello world");
const writeBinaryResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("image.png")}`,
payload: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
headers: { "content-type": "application/octet-stream" },
});
expect(writeBinaryResponse.statusCode).toBe(200);
expect(writeBinaryResponse.json()).toMatchObject({ path: "image.png", created: true });
const writeDeepResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}`,
payload: "deep content",
headers: { "content-type": "text/plain" },
});
expect(writeDeepResponse.statusCode).toBe(200);
const readDeepResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}` });
expect(readDeepResponse.json<{ content: unknown }>().content).toBe("deep content");
const overwriteResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
payload: "updated",
headers: { "content-type": "text/plain" },
});
expect(overwriteResponse.json()).toMatchObject({ path: "hello.txt", created: false });
const noOverwriteResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}&overwrite=false`,
payload: "should fail",
headers: { "content-type": "text/plain" },
});
expect(noOverwriteResponse.statusCode).toBe(400);
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
const traversalResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
payload: "evil",
headers: { "content-type": "text/plain" },
});
expect(traversalResponse.statusCode).toBe(400);
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
const noPathResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
payload: "no path",
headers: { "content-type": "text/plain" },
});
expect(noPathResponse.statusCode).toBe(400);
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
const noDirsResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("nonexistent/parent/file.txt")}&createDirs=false`,
payload: "should fail",
headers: { "content-type": "text/plain" },
});
expect(noDirsResponse.statusCode).toBe(400);
await mkdir(join(projectDir, "subdir"), { recursive: true });
const dirWriteResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("subdir")}`,
payload: "should fail",
headers: { "content-type": "text/plain" },
});
expect(dirWriteResponse.statusCode).toBe(400);
});
it("deletes workspace files through the HTTP contract", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "DeleteTest", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
payload: "delete me",
headers: { "content-type": "text/plain" },
});
const deleteResponse = await app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
});
expect(deleteResponse.statusCode).toBe(200);
expect(deleteResponse.json()).toMatchObject({ path: "to-delete.txt", existed: true });
const deleteMissingResponse = await app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("missing.txt")}`,
});
expect(deleteMissingResponse.statusCode).toBe(200);
expect(deleteMissingResponse.json()).toMatchObject({ path: "missing.txt", existed: false });
const traversalResponse = await app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
});
expect(traversalResponse.statusCode).toBe(400);
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
const noPathResponse = await app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
});
expect(noPathResponse.statusCode).toBe(400);
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
});
it("moves workspace files through the HTTP contract", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "MoveTest", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}`,
payload: "move me",
headers: { "content-type": "text/plain" },
});
const moveResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("original.txt")}&toPath=${encodeURIComponent("moved.txt")}`,
});
expect(moveResponse.statusCode).toBe(200);
expect(moveResponse.json()).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
expect(typeof moveResponse.json<{ size: unknown }>().size).toBe("number");
const readSourceResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}` });
expect(readSourceResponse.statusCode).toBe(400);
const readTargetResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("moved.txt")}` });
expect(readTargetResponse.statusCode).toBe(200);
expect(readTargetResponse.json<{ content: unknown }>().content).toBe("move me");
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source2.txt")}`,
payload: "source",
headers: { "content-type": "text/plain" },
});
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target2.txt")}`,
payload: "target",
headers: { "content-type": "text/plain" },
});
const overwriteResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source2.txt")}&toPath=${encodeURIComponent("target2.txt")}&overwrite=true`,
});
expect(overwriteResponse.statusCode).toBe(200);
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source3.txt")}`,
payload: "s",
headers: { "content-type": "text/plain" },
});
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target3.txt")}`,
payload: "t",
headers: { "content-type": "text/plain" },
});
const noOverwriteResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source3.txt")}&toPath=${encodeURIComponent("target3.txt")}`,
});
expect(noOverwriteResponse.statusCode).toBe(400);
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
const traversalFromResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("../../etc/passwd")}&toPath=${encodeURIComponent("safe.txt")}`,
});
expect(traversalFromResponse.statusCode).toBe(400);
const noParamsResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move`,
});
expect(noParamsResponse.statusCode).toBe(400);
expect(noParamsResponse.json<{ error: string }>().error).toContain("fromPath query parameter is required");
});
});
interface CapturedSessionDaemonRequest {
method: string;
path: string;
body?: unknown;
}
function fakeConfigService() {
return {
read: () => piWebConfigResponse(piWebConfig),
write: (config: PiWebConfigValues) => {
piWebConfig = config;
return piWebConfigResponse(config);
},
};
}
function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: join(tempDir, "config.json"),
exists: false,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
};
}
function fakeSessionDaemon(): SessionProxyDaemon {
return {
request: (method, path, body) => {
const captured = { method, path, ...(body === undefined ? {} : { body }) } satisfies CapturedSessionDaemonRequest;
sessionDaemonRequests.push(captured);
return Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify(captured),
});
},
connectWebSocket: () => { throw new Error("WebSocket not configured for test"); },
};
}
async function installConfiguredPiWebPackage(agentDir: string): Promise<void> {
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8");
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) Reflect.deleteProperty(process.env, key);
else process.env[key] = value;
}
function fakeRemoteClient(overrides: Partial<MachineClient>): MachineClient {
return {
request: () => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }),
requestJson: () => Promise.resolve({ statusCode: 200, headers: {}, body: undefined }),
connectWebSocket: () => { throw new Error("WebSocket not configured for test"); },
...overrides,
};
}
+255
View File
@@ -0,0 +1,255 @@
import { mkdtemp, realpath, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Readable } from "node:stream";
import type { FastifyInstance } from "fastify";
import { afterEach, beforeEach } from "vitest";
import { buildApp } from "./app.js";
import { ProjectService } from "./projects/projectService.js";
import { ProjectStore } from "./storage/projectStore.js";
import type { MachineClient } from "./machines/machineClient.js";
import { MachineService } from "./machines/machineService.js";
import { MachineStore } from "./machines/machineStore.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import type { PiPackageService } from "./piPackageService.js";
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
interface AppTestContext {
readonly app: FastifyInstance;
readonly tempDir: string;
readonly projectDir: string;
remoteClient: MachineClient | undefined;
readonly sessionDaemonRequests: CapturedSessionDaemonRequest[];
readonly piPackageRequests: CapturedPiPackageRequest[];
piWebConfig: PiWebConfigValues;
}
let app: FastifyInstance | undefined;
let tempDir: string | undefined;
let projectDir: string | undefined;
let remoteClient: MachineClient | undefined;
let sessionDaemonRequests: CapturedSessionDaemonRequest[] = [];
let piPackageRequests: CapturedPiPackageRequest[] = [];
let piWebConfig: PiWebConfigValues = {};
export const appTestContext: AppTestContext = {
get app() {
if (app === undefined) throw new Error("App test harness was not initialized");
return app;
},
get tempDir() {
if (tempDir === undefined) throw new Error("App test tempDir was not initialized");
return tempDir;
},
get projectDir() {
if (projectDir === undefined) throw new Error("App test projectDir was not initialized");
return projectDir;
},
get remoteClient() {
return remoteClient;
},
set remoteClient(client) {
remoteClient = client;
},
get sessionDaemonRequests() {
return sessionDaemonRequests;
},
get piPackageRequests() {
return piPackageRequests;
},
get piWebConfig() {
return piWebConfig;
},
set piWebConfig(config) {
piWebConfig = config;
},
};
export function registerAppTestHooks(): void {
beforeEach(async () => {
tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-")));
projectDir = join(tempDir, "project");
remoteClient = undefined;
sessionDaemonRequests = [];
piPackageRequests = [];
piWebConfig = {};
app = await buildApp({
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
workspaces: new WorkspaceService(),
machines: new MachineService(new MachineStore(join(tempDir, "machines.json")), {
remoteClientFactory: () => {
if (remoteClient === undefined) throw new Error("No remote machine client configured");
return remoteClient;
},
now: () => new Date("2026-05-25T00:00:00.000Z"),
localRuntime: () => Promise.resolve({
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "PI WEB", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
}),
}),
sessionDaemon: fakeSessionDaemon(),
config: fakeConfigService(),
piPackages: fakePiPackageService(),
piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
readAsset: fakePiWebPluginAsset,
},
clientDist: false,
logger: false,
});
});
afterEach(async () => {
const appToClose = app;
const tempDirToRemove = tempDir;
app = undefined;
tempDir = undefined;
projectDir = undefined;
remoteClient = undefined;
sessionDaemonRequests = [];
piPackageRequests = [];
piWebConfig = {};
if (appToClose !== undefined) await appToClose.close();
if (tempDirToRemove !== undefined) await rm(tempDirToRemove, { recursive: true, force: true });
});
}
function fakePiWebPluginAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> {
if (pluginId !== "fake") return Promise.resolve(undefined);
if (assetPath === "plugin.js") return Promise.resolve({ content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" });
if (assetPath === "assets/icon.svg") return Promise.resolve({ content: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>'), contentType: "image/svg+xml" });
return Promise.resolve(undefined);
}
export interface CapturedSessionDaemonRequest {
method: string;
path: string;
body?: unknown;
}
interface CapturedPiPackageRequest {
action: "list" | "install" | "remove" | "update";
source?: string;
scope?: "user" | "project";
}
function fakeConfigService() {
return {
read: () => piWebConfigResponse(piWebConfig),
write: (config: PiWebConfigValues) => {
piWebConfig = config;
return piWebConfigResponse(config);
},
};
}
export function fullPiWebConfig(): PiWebConfigValues {
return {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.example.test"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true, settings: { note: "remote" } } },
pathAccess: { allowedPaths: ["/srv/repos"] },
uploads: { defaultFolder: "uploads" },
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
};
}
export function selectedMachinePiWebConfig(): PiWebConfigValues {
return {
plugins: { info: { enabled: true, settings: { note: "remote" } } },
pathAccess: { allowedPaths: ["/srv/repos"] },
uploads: { defaultFolder: "uploads" },
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
};
}
export function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: join(appTestContext.tempDir, "config.json"),
exists: false,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
};
}
interface MachineConfigWriteBody {
config: PiWebConfigValues;
}
export function configFromMachineConfigWriteBody(body: unknown): PiWebConfigValues {
if (!isMachineConfigWriteBody(body)) throw new Error("Expected machine config write body");
return body.config;
}
function isMachineConfigWriteBody(value: unknown): value is MachineConfigWriteBody {
if (!isRecord(value)) return false;
return isRecord(value["config"]);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function fakePiPackageService(): PiPackageService {
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }];
return {
list: () => {
piPackageRequests.push({ action: "list" });
return Promise.resolve({ packages });
},
install: (source) => {
piPackageRequests.push({ action: "install", source });
return Promise.resolve({ action: "install", source, packages });
},
remove: (source, scope = "user") => {
piPackageRequests.push({ action: "remove", source, scope });
return Promise.resolve({ action: "remove", source, scope, removed: true, packages });
},
update: (source) => {
piPackageRequests.push({ action: "update", ...(source === undefined ? {} : { source }) });
return Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages });
},
};
}
function fakeSessionDaemon(): SessionProxyDaemon {
return {
request: (method, path, body) => {
const captured = { method, path, ...(body === undefined ? {} : { body }) } satisfies CapturedSessionDaemonRequest;
sessionDaemonRequests.push(captured);
return Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify(captured),
});
},
connectWebSocket: () => { throw new Error("WebSocket not configured for test"); },
};
}
export function fakeRemoteClient(overrides: Partial<MachineClient>): MachineClient {
return {
request: () => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }),
requestJson: () => Promise.resolve({ statusCode: 200, headers: {}, body: undefined }),
connectWebSocket: () => { throw new Error("WebSocket not configured for test"); },
...overrides,
};
}
+34 -9
View File
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify";
import fastifyCompress from "@fastify/compress";
import fastifyStatic from "@fastify/static";
import fastifyWebsocket from "@fastify/websocket";
import { ProjectStore } from "./storage/projectStore.js";
@@ -18,8 +19,10 @@ import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
import { registerGitRoutes } from "./gitRoutes.js";
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { createFilePiWebConfigService, registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js";
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js";
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { effectiveAgentConfig, type EffectivePiWebAgentConfig } from "../config.js";
@@ -35,6 +38,8 @@ export interface AppDependencies {
machines?: MachineService;
sessionDaemon?: SessionProxyDaemon;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
piPackages?: PiPackageService;
piWebStatusCache?: PiWebStatusCache;
config?: PiWebConfigService;
clientDist?: string | false;
logger?: FastifyServerOptions["logger"];
@@ -137,6 +142,13 @@ function invalidatePiWebStatusOnWrite(config: PiWebConfigService, statusCache: P
export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> {
const app = Fastify({ logger: deps.logger ?? true, ...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }) });
// Vite proxies development API requests here, while production and machine-scoped
// API requests already terminate here, so this is the shared browser HTTP edge.
await app.register(fastifyCompress, {
globalCompression: true,
globalDecompression: false,
threshold: 1024,
});
await app.register(fastifyWebsocket);
const projects = deps.projects ?? new ProjectService(new ProjectStore());
@@ -147,13 +159,19 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService({
configProvider: readConfig,
});
const piPackages = deps.piPackages ?? createDefaultPiPackageService(process.cwd(), (await readAgentConfig()).dir);
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const piWebStatusCache = createPiWebStatusCache(async () => {
const agent = await readAgentConfig();
return getPiWebStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir });
}, {
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
});
const piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache(
async ({ force }) => {
const agent = await readAgentConfig();
return getPiWebStatus(sessionDaemon, {
forceReleaseCheck: force,
agentCommand: agent.command,
agentDir: agent.dir,
});
},
{ onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); } },
);
const machines = deps.machines ?? new MachineService(undefined, {
localRuntime: () => getPiWebRuntime(sessionDaemon),
});
@@ -168,14 +186,21 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
return reply.type(asset.contentType).send(asset.content);
});
app.get("/api/pi-web/status", async () => piWebStatusCache.get());
app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1"
? piWebStatusCache.refresh({ force: true })
: piWebStatusCache.get());
app.get("/api/pi-web/version", async () => {
const agent = await readAgentConfig();
return getPiWebVersionStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir });
});
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
app.get("/api/plugins", async () => piWebPlugins.plugins());
registerConfigRoutes(app, invalidatePiWebStatusOnWrite(configService, piWebStatusCache));
app.get("/api/machines/local/plugins", async () => piWebPlugins.plugins());
registerPiPackageRoutes(app, piPackages);
registerPiPackageRoutes(app, piPackages, "/api/machines/local");
const invalidatingConfigService = invalidatePiWebStatusOnWrite(configService, piWebStatusCache);
registerConfigRoutes(app, invalidatingConfigService);
registerLocalMachineConfigRoutes(app, invalidatingConfigService);
registerMachineRoutes(app, machines);
registerMachinePluginProxyRoutes(app, machines);
+332
View File
@@ -0,0 +1,332 @@
import { mkdir, truncate, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { Project, Workspace } from "./types.js";
import { appTestContext, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp workspace file routes", () => {
it("serves supported workspace images as previews", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Images", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const svg = "<svg xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"1\" height=\"1\" /></svg>";
await writeFile(join(appTestContext.projectDir, "diagram.svg"), svg);
await writeFile(join(appTestContext.projectDir, "note.txt"), "hello");
await writeFile(join(appTestContext.projectDir, "huge.png"), "");
await truncate(join(appTestContext.projectDir, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1);
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const previewResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("diagram.svg")}` });
expect(previewResponse.statusCode).toBe(200);
expect(previewResponse.headers["content-type"]).toContain("image/svg+xml");
expect(previewResponse.headers["cache-control"]).toBe("private, max-age=3600");
expect(previewResponse.headers["content-security-policy"]).toContain("sandbox");
expect(previewResponse.headers["x-content-type-options"]).toBe("nosniff");
expect(previewResponse.body).toBe(svg);
const rejectedResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("note.txt")}` });
expect(rejectedResponse.statusCode).toBe(400);
expect(rejectedResponse.json()).toEqual({ error: "Image preview is not supported for this file type" });
const tooLargeResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("huge.png")}` });
expect(tooLargeResponse.statusCode).toBe(400);
expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" });
});
it("keeps normal file suggestions workspace-local when path access config is invalid", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Local Suggestions", path: appTestContext.projectDir, create: true },
});
expect(addResponse.statusCode).toBe(200);
await writeFile(join(appTestContext.projectDir, "sdk.md"), "local sdk\n");
await mkdir(join(appTestContext.projectDir, ".pi-web"), { recursive: true });
await writeFile(join(appTestContext.projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [""] } }, null, 2)}\n`);
const response = await appTestContext.app.inject({ method: "GET", url: `/api/files?cwd=${encodeURIComponent(appTestContext.projectDir)}&q=sdk&scope=all` });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([{ path: "sdk.md", kind: "other" }]);
});
it("serves project-configured allowed external files through the workspace explorer", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "External", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const externalDir = join(appTestContext.tempDir, "external-docs");
const deniedFile = join(appTestContext.tempDir, "secret.md");
await mkdir(externalDir);
await writeFile(join(externalDir, "sdk.md"), "external sdk\n");
await writeFile(deniedFile, "secret\n");
await mkdir(join(appTestContext.projectDir, ".pi-web"), { recursive: true });
await writeFile(join(appTestContext.projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [externalDir] } }, null, 2)}\n`);
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const fileResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(join(externalDir, "sdk.md"))}` });
const treeResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/tree?path=${encodeURIComponent(externalDir)}` });
const suggestionResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=${encodeURIComponent(join(externalDir, "s"))}` });
const localSuggestionResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=sdk` });
const deniedResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(deniedFile)}` });
expect(fileResponse.statusCode).toBe(200);
expect(fileResponse.json()).toMatchObject({ path: join(externalDir, "sdk.md"), content: "external sdk\n", binary: false });
expect(treeResponse.statusCode).toBe(200);
expect(treeResponse.json()).toMatchObject({
path: externalDir,
entries: [expect.objectContaining({ name: "sdk.md", path: join(externalDir, "sdk.md"), type: "file" })],
truncated: false,
});
expect(suggestionResponse.statusCode).toBe(200);
expect(suggestionResponse.json()).toEqual([{ path: join(externalDir, "sdk.md"), kind: "other" }]);
expect(localSuggestionResponse.statusCode).toBe(200);
expect(localSuggestionResponse.json()).toEqual([]);
expect(deniedResponse.statusCode).toBe(400);
expect(deniedResponse.json()).toEqual({ error: "Path is outside allowed paths" });
});
it("writes workspace files through the HTTP contract", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "WriteTest", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const writeTextResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
payload: "hello world",
headers: { "content-type": "text/plain" },
});
expect(writeTextResponse.statusCode).toBe(200);
expect(writeTextResponse.json()).toMatchObject({ path: "hello.txt", created: true });
expect(typeof writeTextResponse.json<{ size: unknown }>().size).toBe("number");
const readResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}` });
expect(readResponse.json<{ content: unknown }>().content).toBe("hello world");
const writeBinaryResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("image.png")}`,
payload: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
headers: { "content-type": "application/octet-stream" },
});
expect(writeBinaryResponse.statusCode).toBe(200);
expect(writeBinaryResponse.json()).toMatchObject({ path: "image.png", created: true });
const writeDeepResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}`,
payload: "deep content",
headers: { "content-type": "text/plain" },
});
expect(writeDeepResponse.statusCode).toBe(200);
const readDeepResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}` });
expect(readDeepResponse.json<{ content: unknown }>().content).toBe("deep content");
const overwriteResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
payload: "updated",
headers: { "content-type": "text/plain" },
});
expect(overwriteResponse.json()).toMatchObject({ path: "hello.txt", created: false });
const noOverwriteResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}&overwrite=false`,
payload: "should fail",
headers: { "content-type": "text/plain" },
});
expect(noOverwriteResponse.statusCode).toBe(400);
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
const traversalResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
payload: "evil",
headers: { "content-type": "text/plain" },
});
expect(traversalResponse.statusCode).toBe(400);
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
const noPathResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
payload: "no path",
headers: { "content-type": "text/plain" },
});
expect(noPathResponse.statusCode).toBe(400);
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
const noDirsResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("nonexistent/parent/file.txt")}&createDirs=false`,
payload: "should fail",
headers: { "content-type": "text/plain" },
});
expect(noDirsResponse.statusCode).toBe(400);
await mkdir(join(appTestContext.projectDir, "subdir"), { recursive: true });
const dirWriteResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("subdir")}`,
payload: "should fail",
headers: { "content-type": "text/plain" },
});
expect(dirWriteResponse.statusCode).toBe(400);
});
it("deletes workspace files through the HTTP contract", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "DeleteTest", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
payload: "delete me",
headers: { "content-type": "text/plain" },
});
const deleteResponse = await appTestContext.app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
});
expect(deleteResponse.statusCode).toBe(200);
expect(deleteResponse.json()).toMatchObject({ path: "to-delete.txt", existed: true });
const deleteMissingResponse = await appTestContext.app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("missing.txt")}`,
});
expect(deleteMissingResponse.statusCode).toBe(200);
expect(deleteMissingResponse.json()).toMatchObject({ path: "missing.txt", existed: false });
const traversalResponse = await appTestContext.app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
});
expect(traversalResponse.statusCode).toBe(400);
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
const noPathResponse = await appTestContext.app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
});
expect(noPathResponse.statusCode).toBe(400);
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
});
it("moves workspace files through the HTTP contract", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "MoveTest", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}`,
payload: "move me",
headers: { "content-type": "text/plain" },
});
const moveResponse = await appTestContext.app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("original.txt")}&toPath=${encodeURIComponent("moved.txt")}`,
});
expect(moveResponse.statusCode).toBe(200);
expect(moveResponse.json()).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
expect(typeof moveResponse.json<{ size: unknown }>().size).toBe("number");
const readSourceResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}` });
expect(readSourceResponse.statusCode).toBe(400);
const readTargetResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("moved.txt")}` });
expect(readTargetResponse.statusCode).toBe(200);
expect(readTargetResponse.json<{ content: unknown }>().content).toBe("move me");
await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source2.txt")}`,
payload: "source",
headers: { "content-type": "text/plain" },
});
await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target2.txt")}`,
payload: "target",
headers: { "content-type": "text/plain" },
});
const overwriteResponse = await appTestContext.app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source2.txt")}&toPath=${encodeURIComponent("target2.txt")}&overwrite=true`,
});
expect(overwriteResponse.statusCode).toBe(200);
await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source3.txt")}`,
payload: "s",
headers: { "content-type": "text/plain" },
});
await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target3.txt")}`,
payload: "t",
headers: { "content-type": "text/plain" },
});
const noOverwriteResponse = await appTestContext.app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source3.txt")}&toPath=${encodeURIComponent("target3.txt")}`,
});
expect(noOverwriteResponse.statusCode).toBe(400);
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
const traversalFromResponse = await appTestContext.app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("../../etc/passwd")}&toPath=${encodeURIComponent("safe.txt")}`,
});
expect(traversalFromResponse.statusCode).toBe(400);
const noParamsResponse = await appTestContext.app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move`,
});
expect(noParamsResponse.statusCode).toBe(400);
expect(noParamsResponse.json<{ error: string }>().error).toContain("fromPath query parameter is required");
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { normalizeMessage } from "../client/src/chatMessages.js";
import type { MessagePage } from "../shared/apiTypes.js";
import { projectBrowserMessage, projectBrowserMessageResponse, projectBrowserSessionEvent } from "./browserMessageProjection.js";
function signedAssistantMessage() {
return {
role: "assistant",
content: [
{ type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true },
{ type: "text", text: "visible answer", textSignature: "text-metadata" },
{ type: "toolCall", name: "read", arguments: { thinkingSignature: "ordinary nested argument" }, thoughtSignature: "tool-metadata" },
],
model: "model-1",
};
}
describe("browser message projection", () => {
it("omits only thinking-block signatures without mutating runtime messages", () => {
const message = signedAssistantMessage();
const projected = projectBrowserMessage(message);
expect(projected).toEqual({
role: "assistant",
content: [
{ type: "thinking", thinking: "private chain", redacted: true },
{ type: "text", text: "visible answer", textSignature: "text-metadata" },
{ type: "toolCall", name: "read", arguments: { thinkingSignature: "ordinary nested argument" }, thoughtSignature: "tool-metadata" },
],
model: "model-1",
});
expect(message.content[0]).toEqual({ type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true });
expect(normalizeMessage(projected)).toEqual(normalizeMessage(message));
});
it("projects both paged and legacy array history responses", () => {
const message = signedAssistantMessage();
const page: MessagePage = { messages: [message], start: 4, total: 5 };
expect(projectBrowserMessageResponse(page)).toEqual({
messages: [{ ...message, content: [{ type: "thinking", thinking: "private chain", redacted: true }, ...message.content.slice(1)] }],
start: 4,
total: 5,
});
expect(projectBrowserMessageResponse([message])).toEqual([
{ ...message, content: [{ type: "thinking", thinking: "private chain", redacted: true }, ...message.content.slice(1)] },
]);
expect(page.messages[0]).toBe(message);
});
it("projects final-message events but leaves other event shapes untouched", () => {
const message = signedAssistantMessage();
const finalEvent = { type: "message.end" as const, message };
const appendEvent = { type: "message.append" as const, message };
expect(projectBrowserSessionEvent(finalEvent)).toEqual({
type: "message.end",
message: { ...message, content: [{ type: "thinking", thinking: "private chain", redacted: true }, ...message.content.slice(1)] },
});
expect(projectBrowserSessionEvent(appendEvent)).toBe(appendEvent);
expect(finalEvent.message).toBe(message);
});
});
+59
View File
@@ -0,0 +1,59 @@
import type { MessagePage, SessionUiEvent } from "../shared/apiTypes.js";
/**
* Remove provider-only thinking data at the browser transport boundary. The
* runtime message remains unchanged because only affected messages and content
* blocks are copied.
*/
export function projectBrowserMessage(message: unknown): unknown {
if (!isRecord(message)) return message;
const originalContent = message["content"];
if (!isUnknownArray(originalContent)) return message;
const content = mapChanged(originalContent, (part) => {
if (!isRecord(part) || part["type"] !== "thinking" || !Object.hasOwn(part, "thinkingSignature")) return part;
const projected = { ...part };
delete projected["thinkingSignature"];
return projected;
});
return content === originalContent ? message : { ...message, content };
}
export function projectBrowserMessageResponse(response: unknown[] | MessagePage): unknown[] | MessagePage {
if (Array.isArray(response)) return mapChanged(response, projectBrowserMessage);
const messages = mapChanged(response.messages, projectBrowserMessage);
return messages === response.messages ? response : { ...response, messages };
}
export function projectBrowserSessionEvent(event: SessionUiEvent): SessionUiEvent {
if (event.type !== "message.end" || event.message === undefined) return event;
const message = projectBrowserMessage(event.message);
return message === event.message ? event : { ...event, message };
}
function mapChanged<T>(values: T[], project: (value: T) => T): T[] {
let projectedValues: T[] | undefined;
let index = 0;
for (const value of values) {
const projected = project(value);
if (projectedValues === undefined) {
if (projected === value) {
index += 1;
continue;
}
projectedValues = values.slice(0, index);
}
projectedValues.push(projected);
index += 1;
}
return projectedValues ?? values;
}
function isUnknownArray(value: unknown): value is unknown[] {
return Array.isArray(value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+139 -4
View File
@@ -1,6 +1,6 @@
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { parsePiWebConfigResponseBody, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
let app: FastifyInstance;
@@ -18,6 +18,7 @@ beforeEach(async () => {
};
app = Fastify({ logger: false });
registerConfigRoutes(app, service);
registerLocalMachineConfigRoutes(app, service);
await app.ready();
});
@@ -34,15 +35,33 @@ describe("config routes", () => {
});
it("updates config through the service", async () => {
const requestedConfig: PiWebConfigValues = {
host: "0.0.0.0",
port: 9000,
allowedHosts: true,
spawnSessions: true,
subsessions: true,
shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null },
plugins: { info: { enabled: false, settings: { note: "hidden" } } },
pathAccess: { allowedPaths: ["/tmp"] },
uploads: { defaultFolder: "uploads\\manual" },
maxUploadBytes: 1234,
agent: { command: "agent-lab", dir: "~/agent-profiles/lab" },
};
const expectedConfig: PiWebConfigValues = {
...requestedConfig,
uploads: { defaultFolder: "uploads/manual" },
};
const response = await app.inject({
method: "PUT",
url: "/api/config",
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" }, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } },
payload: { config: requestedConfig },
});
expect(response.statusCode).toBe(200);
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" }, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 });
expect(response.json<PiWebConfigResponse>().config).toEqual(savedConfig);
expect(savedConfig).toEqual(expectedConfig);
expect(response.json<PiWebConfigResponse>().config).toEqual(expectedConfig);
});
it("rejects invalid config payloads before writing", async () => {
@@ -92,8 +111,124 @@ describe("config routes", () => {
expect(response.json()).toHaveProperty("error");
expect(service.write).not.toHaveBeenCalled();
});
it("filters local machine config reads to selected-machine-safe keys", async () => {
savedConfig = fullConfig();
const response = await app.inject({ method: "GET", url: "/api/machines/local/config" });
expect(response.statusCode).toBe(200);
expect(response.json<PiWebConfigResponse>()).toEqual({
...responseFor(savedConfig, true),
config: selectedMachineConfig(),
effectiveConfig: selectedMachineConfig(),
});
});
it("merges local selected-machine config updates without dropping gateway-only keys", async () => {
savedConfig = fullConfig();
const selectedMachinePatch: PiWebConfigValues = {
plugins: { info: { enabled: false } },
uploads: { defaultFolder: "uploads\\manual" },
spawnSessions: true,
agent: { command: "alternate-agent", dir: "/srv/alternate-agent" },
};
const response = await app.inject({
method: "PUT",
url: "/api/machines/local/config",
payload: { config: selectedMachinePatch },
});
const expectedConfig: PiWebConfigValues = {
...fullConfig(),
plugins: { info: { enabled: false } },
uploads: { defaultFolder: "uploads/manual" },
spawnSessions: true,
agent: { command: "alternate-agent", dir: "/srv/alternate-agent" },
};
expect(response.statusCode).toBe(200);
expect(savedConfig).toEqual(expectedConfig);
expect(service.write).toHaveBeenCalledWith(expectedConfig);
expect(response.json<PiWebConfigResponse>().config).toEqual({
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/srv/repos"] },
uploads: { defaultFolder: "uploads/manual" },
maxUploadBytes: 1024,
spawnSessions: true,
subsessions: false,
agent: { command: "alternate-agent", dir: "/srv/alternate-agent" },
});
});
it("defaults missing agent override fields from older config responses", () => {
const parsed = parsePiWebConfigResponseBody({
path: "/tmp/pi-web/config.json",
exists: true,
config: {},
effectiveConfig: {},
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
});
expect(parsed.envOverrides).toMatchObject({ agentCommand: false, agentDir: false, agentSessionDir: false });
});
it("rejects unsafe local selected-machine config keys before writing", async () => {
savedConfig = fullConfig();
const response = await app.inject({
method: "PUT",
url: "/api/machines/local/config",
payload: { config: { host: "0.0.0.0", spawnSessions: true } },
});
expect(response.statusCode).toBe(400);
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config key is not allowed: host");
expect(savedConfig).toEqual(fullConfig());
expect(service.write).not.toHaveBeenCalled();
});
it("rejects invalid local selected-machine config values before writing", async () => {
const response = await app.inject({
method: "PUT",
url: "/api/machines/local/config",
payload: { config: { spawnSessions: "yes" } },
});
expect(response.statusCode).toBe(400);
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config spawnSessions must be a boolean");
expect(service.write).not.toHaveBeenCalled();
});
});
function fullConfig(): PiWebConfigValues {
return {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.example.test"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true, settings: { note: "visible" } } },
pathAccess: { allowedPaths: ["/srv/repos"] },
uploads: { defaultFolder: "uploads" },
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
};
}
function selectedMachineConfig(): PiWebConfigValues {
return {
plugins: { info: { enabled: true, settings: { note: "visible" } } },
pathAccess: { allowedPaths: ["/srv/repos"] },
uploads: { defaultFolder: "uploads" },
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
};
}
function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
+125 -1
View File
@@ -8,6 +8,18 @@ export interface PiWebConfigService {
write: (config: PiWebConfigValues) => PiWebConfigResponse | Promise<PiWebConfigResponse>;
}
export const SELECTED_MACHINE_CONFIG_KEYS = [
"plugins",
"pathAccess",
"uploads",
"maxUploadBytes",
"spawnSessions",
"subsessions",
"agent",
] as const satisfies readonly (keyof PiWebConfigValues)[];
const SELECTED_MACHINE_CONFIG_KEY_SET = new Set<string>(SELECTED_MACHINE_CONFIG_KEYS);
export function createFilePiWebConfigService(options: LoadOptions = {}): PiWebConfigService {
return {
read: () => currentPiWebConfigResponse(options),
@@ -50,6 +62,62 @@ export function registerConfigRoutes(app: FastifyInstance, service: PiWebConfigS
});
}
export function registerLocalMachineConfigRoutes(app: FastifyInstance, service: PiWebConfigService = createFilePiWebConfigService()): void {
app.get("/api/machines/local/config", async (_request, reply) => {
try {
return selectedMachineConfigResponse(await service.read());
} catch (error) {
return reply.code(500).send({ error: errorMessage(error) });
}
});
app.put<{ Body: { config?: unknown } | undefined }>("/api/machines/local/config", async (request, reply) => {
try {
const current = await service.read();
const patch = parseSelectedMachineConfigRequest(request.body?.config);
return selectedMachineConfigResponse(await service.write(mergeSelectedMachineConfig(current.config, patch)));
} catch (error) {
const status = isConfigValidationError(error) ? 400 : 500;
return reply.code(status).send({ error: errorMessage(error) });
}
});
}
export function parseSelectedMachineConfigRequest(value: unknown): PiWebConfig {
if (!isRecord(value)) throw new Error("PI WEB selected-machine config update must include a config object");
for (const key of Object.keys(value)) {
if (!SELECTED_MACHINE_CONFIG_KEY_SET.has(key)) throw new Error(`PI WEB selected-machine config key is not allowed: ${key}`);
}
try {
return pickSelectedMachineConfig(parseConfigRequest(value));
} catch (error) {
throw new Error(selectedMachineConfigErrorMessage(error), { cause: error });
}
}
export function mergeSelectedMachineConfig(current: PiWebConfigValues, patch: PiWebConfigValues): PiWebConfig {
return { ...current, ...pickSelectedMachineConfig(patch) };
}
export function selectedMachineConfigResponse(response: PiWebConfigResponse): PiWebConfigResponse {
return {
...response,
config: pickSelectedMachineConfig(response.config),
effectiveConfig: pickSelectedMachineConfig(response.effectiveConfig),
};
}
export function parsePiWebConfigResponseBody(value: unknown, source = "PI WEB config response"): PiWebConfigResponse {
const record = requireResponseRecord(value, source);
return {
path: requireResponseString(record, "path", source),
exists: requireResponseBoolean(record, "exists", source),
config: parseConfigRequest(record["config"]),
effectiveConfig: parseConfigRequest(record["effectiveConfig"]),
envOverrides: parsePiWebConfigEnvOverridesResponse(record["envOverrides"], source),
};
}
function parseConfigRequest(value: unknown): PiWebConfig {
if (!isRecord(value)) throw new Error("PI WEB config update must include a config object");
const config: PiWebConfig = {};
@@ -90,6 +158,24 @@ function parseConfigRequest(value: unknown): PiWebConfig {
return config;
}
function pickSelectedMachineConfig(config: PiWebConfigValues): PiWebConfig {
return {
...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
...(config.pathAccess !== undefined ? { pathAccess: config.pathAccess } : {}),
...(config.uploads !== undefined ? { uploads: config.uploads } : {}),
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
...(config.agent !== undefined ? { agent: config.agent } : {}),
};
}
function selectedMachineConfigErrorMessage(error: unknown): string {
const message = errorMessage(error);
if (message.startsWith("PI WEB config ")) return `PI WEB selected-machine config ${message.slice("PI WEB config ".length)}`;
return `PI WEB selected-machine config ${message}`;
}
function parseAllowedHostsRequest(value: unknown): string[] | true {
if (value === true) return true;
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
@@ -167,6 +253,44 @@ function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]
}));
}
function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): PiWebConfigEnvOverrides {
const record = requireResponseRecord(value, `${source} envOverrides`);
return {
host: requireResponseBoolean(record, "host", source),
port: requireResponseBoolean(record, "port", source),
allowedHosts: requireResponseBoolean(record, "allowedHosts", source),
spawnSessions: requireResponseBoolean(record, "spawnSessions", source),
subsessions: requireResponseBoolean(record, "subsessions", source),
agentCommand: optionalResponseBoolean(record, "agentCommand", source) ?? false,
agentDir: optionalResponseBoolean(record, "agentDir", source) ?? false,
agentSessionDir: optionalResponseBoolean(record, "agentSessionDir", source) ?? false,
};
}
function requireResponseRecord(value: unknown, source: string): Record<string, unknown> {
if (!isRecord(value)) throw new Error(`${source} must be an object`);
return value;
}
function requireResponseString(record: Record<string, unknown>, key: string, source: string): string {
const value = record[key];
if (typeof value !== "string") throw new Error(`${source} field must be a string: ${key}`);
return value;
}
function requireResponseBoolean(record: Record<string, unknown>, key: string, source: string): boolean {
const value = record[key];
if (typeof value !== "boolean") throw new Error(`${source} field must be a boolean: ${key}`);
return value;
}
function optionalResponseBoolean(record: Record<string, unknown>, key: string, source: string): boolean | undefined {
const value = record[key];
if (value === undefined) return undefined;
if (typeof value !== "boolean") throw new Error(`${source} field must be a boolean: ${key}`);
return value;
}
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {}): PiWebConfigEnvOverrides {
const command = config.agent?.command;
return {
@@ -186,7 +310,7 @@ function isEnvSet(value: string | undefined): boolean {
}
function isConfigValidationError(error: unknown): boolean {
return error instanceof Error && error.message.startsWith("PI WEB config");
return error instanceof Error && (error.message.startsWith("PI WEB config") || error.message.startsWith("PI WEB selected-machine config"));
}
function errorMessage(error: unknown): string {
+791
View File
@@ -0,0 +1,791 @@
import { execFile } from "node:child_process";
import { copyFile, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { beforeEach, afterEach, describe, expect, it } from "vitest";
import { sanitizedGitEnv } from "./git/gitEnv.js";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const dockerEntrypoint = join(repoRoot, "docker", "pi-web-docker");
let tempDir = "";
interface CommandResult {
stdout: string;
stderr: string;
}
interface FakeDocker {
binDir: string;
logPath: string;
}
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-docker-test-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("Docker command assets", () => {
// The Docker control shell scripts intentionally support Linux and macOS hosts.
// Keep Windows CI on static/syntax coverage instead of executing POSIX host-path and socket flows there.
const dockerCommandIt = it.skipIf(process.platform === "win32");
it("keeps shell entrypoints syntactically valid", async () => {
await Promise.all([
execUtf8("sh", ["-n", dockerEntrypoint], process.env),
execUtf8("sh", ["-n", join(repoRoot, "docker", "install.sh")], process.env),
execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "dev", "compose")], process.env),
execUtf8("bash", ["-n", join(repoRoot, "docker", "internal", "dev", "sync-node-modules")], process.env),
execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "host-profile.sh")], process.env),
]);
});
it("packages the canonical Docker command and internal support assets", async () => {
const [dockerfile, devDockerfile, runtimeCompose, devCompose, installer, devWrapper, dependencySync, dockerignore] = await Promise.all([
readRepoFile("docker/Dockerfile"),
readRepoFile("docker/Dockerfile.dev"),
readRepoFile("docker/compose.yml"),
readRepoFile("docker/compose.dev.yml"),
readRepoFile("docker/install.sh"),
readRepoFile("docker/internal/dev/compose"),
readRepoFile("docker/internal/dev/sync-node-modules"),
readRepoFile("docker/.dockerignore"),
]);
const customImageHooksIndex = devDockerfile.indexOf("for script in /tmp/pi-web-custom-image.d/*.sh");
const dependencyGenerationIndex = devDockerfile.indexOf("/opt/pi-web-dev-dependencies/generation");
expect(dockerfile).toContain("COPY pi-web-docker /usr/local/bin/pi-web-docker");
expect(dockerfile).toContain("COPY internal/bin/hostexec /usr/local/bin/hostexec");
expect(dockerfile).toContain("COPY internal/image/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base");
expect(dockerfile).toContain("--include=peer");
expect(dockerfile).toContain('peer_pi_bin="${global_root}/@jmfederico/pi-web/node_modules/.bin/pi"');
expect(dockerfile).not.toContain("@earendil-works/pi-coding-agent@");
expect(devDockerfile).toContain("COPY docker/pi-web-docker /usr/local/bin/pi-web-docker");
expect(devDockerfile).toContain("COPY docker/internal/bin/hostexec /usr/local/bin/hostexec");
expect(devDockerfile).toContain("COPY --chmod=0755 docker/internal/dev/sync-node-modules /usr/local/sbin/pi-web-dev-sync-node-modules");
expect(devDockerfile).toContain("/opt/pi-web-dev-dependencies/node_modules");
// Hooks can mutate the dependency seed, so its cache generation must be finalized afterward.
expect(customImageHooksIndex).toBeGreaterThanOrEqual(0);
expect(dependencyGenerationIndex).toBeGreaterThan(customImageHooksIndex);
expect(dependencySync).toContain(".pi-web-dev-dependency-generation");
expect(dockerignore).toContain("!pi-web-docker");
expect(dockerignore).toContain("!internal/bin/hostexec");
expect(installer).toContain("write_asset pi-web-docker 0755");
expect(installer).toContain("write_asset internal/host-profile.sh 0644");
expect(installer).toContain("compose_cmd --project-name \"$compose_project_name\"");
expect(installer).toContain("PI_WEB_DOCKER_INSTALL_DIR=$install_dir");
expect(installer).toContain("PI_WEB_DOCKER_REF=$asset_ref");
expect(installer).toContain("COMPOSE_PROJECT_NAME=$compose_project_name");
expect(devWrapper).toContain("$repo_root/docker/internal/host-profile.sh");
expect(devWrapper).toContain("--project-name \"$compose_project_name\"");
expect(devWrapper).toContain("PI_WEB_DOCKER_DEV_REPO_ROOT=$repo_root");
expect(devWrapper).toContain("COMPOSE_PROJECT_NAME=$compose_project_name");
expect(runtimeCompose).toContain("PI_WEB_DOCKER_RUNTIME: \"1\"");
expect(runtimeCompose).toContain("PI_WEB_DOCKER_MODE: runtime");
expect(runtimeCompose).toContain("PI_WEB_DOCKER_INSTALL_DIR: ${PI_WEB_DOCKER_INSTALL_DIR:?set by docker/install.sh}");
expect(runtimeCompose).toContain("PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_IMAGE:-pi-web:local}");
expect(runtimeCompose).toContain("COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web}");
expect(devCompose).toContain("PI_WEB_DOCKER_MODE: dev");
expect(devCompose).toContain("PI_WEB_DOCKER_DEV_REPO_ROOT: ${PI_WEB_DOCKER_DEV_REPO_ROOT:?set by docker/pi-web-docker --dev}");
expect(devCompose).toContain("PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_DEV_IMAGE:-pi-web:dev}");
expect(devCompose).toContain("COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web-dev}");
expect(devCompose).toContain("/usr/local/sbin/pi-web-dev-sync-node-modules");
expect(devCompose.match(/volumes: \*pi-web-dev-volumes/g)).toHaveLength(3);
});
dockerCommandIt("fetches remote installer assets without clobbering the write target", async () => {
const installDir = join(tempDir, "remote-runtime");
const fakeDocker = await installFakeDocker();
await installFakeCurl(fakeDocker.binDir);
await installFakeUname(fakeDocker.binDir, "Darwin");
const home = join(tempDir, "home");
const socketPath = join(home, ".docker", "run", "docker.sock");
await withUnixSocket(socketPath, async () => {
await execUtf8("sh", [
join(repoRoot, "docker", "install.sh"),
"--install-dir", installDir,
"--data-dir", join(installDir, "data"),
"--asset-ref", "test-assets",
"--skip-compose",
], {
...cleanProcessEnv(),
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
HOME: home,
FAKE_DOCKER_LOG: fakeDocker.logPath,
PI_WEB_DOCKER_ASSET_BASE: "https://assets.example.test/docker",
});
});
expect(await readFile(join(installDir, "Dockerfile"), "utf8")).toContain("COPY pi-web-docker /usr/local/bin/pi-web-docker");
expect(await readFile(join(installDir, "pi-web-docker"), "utf8")).toContain("Usage: pi-web-docker");
const env = await readFile(join(installDir, ".env"), "utf8");
expect(env).toContain(`PI_WEB_DOCKER_INSTALL_DIR=${installDir}`);
expect(env).toContain("PI_WEB_DOCKER_REF=test-assets");
});
dockerCommandIt("runs status through Docker Compose in the foreground", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
const result = await runDockerCommand(["status"], runtimeEnv(fakeDocker, installDir));
expect(result.stdout).toContain("fake docker compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml ps");
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain("compose version");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml ps");
expect(log).not.toContain("run -d");
});
dockerCommandIt("runs production host lifecycle commands through the generated runtime env", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
const env = runtimeHostEnv(fakeDocker, installDir);
await runDockerCommand(["start"], env);
await runDockerCommand(["stop"], env);
await runDockerCommand(["restart-sessiond"], env);
await runDockerCommand(["logs", "web"], env);
await runDockerCommand(["shell", "sessiond"], env);
await runDockerCommand(["cli", "config", "show"], env);
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml up -d");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml down");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml restart sessiond");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml logs -f web");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml exec sessiond bash");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml exec web pi-web config show");
expect(log).not.toContain("run -d");
});
dockerCommandIt("ignores ambient Compose project names for runtime lifecycle commands", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
await runDockerCommand(["status"], {
...runtimeHostEnv(fakeDocker, installDir),
COMPOSE_PROJECT_NAME: "ambient-project",
});
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml ps");
expect(log).not.toContain("--project-name ambient-project");
});
dockerCommandIt("runs development commands through generated env while preserving host user ids", async () => {
const devRoot = await createDevRepoFixture();
const fakeDocker = await installFakeDocker();
await installFakeUname(fakeDocker.binDir, "Darwin");
await installFakeId(fakeDocker.binDir, 1234, 2345);
const home = join(tempDir, "home");
const runtimeDataDir = join(tempDir, "runtime-data");
const runtimeEnvFile = join(tempDir, "runtime.env");
const socketPath = join(home, ".docker", "run", "docker.sock");
await writeFile(runtimeEnvFile, [
"PI_WEB_UID=0",
"PI_WEB_GID=0",
`PI_WEB_DOCKER_DATA_DIR=${runtimeDataDir}`,
"PI_WEB_BIND_ADDR=0.0.0.0",
"COMPOSE_PROJECT_NAME=runtime-project",
"",
].join("\n"));
await withUnixSocket(socketPath, async () => {
await runDockerCommand(["--dev", "status"], devHostEnv(fakeDocker, devRoot, home, { PI_WEB_DOCKER_RUNTIME_ENV_FILE: runtimeEnvFile }));
});
const generatedEnvPath = join(devRoot, ".pi-web", "docker-compose-dev.generated.env");
const generatedEnv = await readFile(generatedEnvPath, "utf8");
expect(generatedEnv).toContain("PI_WEB_UID=1234\n");
expect(generatedEnv).toContain("PI_WEB_GID=2345\n");
expect(generatedEnv).toContain("DOCKER_GID=0\n");
expect(generatedEnv).toContain(`PI_WEB_DOCKER_DATA_DIR=${runtimeDataDir}\n`);
expect(generatedEnv).toContain(`PI_WEB_DOCKER_DEV_REPO_ROOT=${devRoot}\n`);
expect(generatedEnv).toContain("COMPOSE_PROJECT_NAME=pi-web-dev\n");
expect(generatedEnv).toContain("PI_WEB_DEV_API_BIND_ADDR=0.0.0.0\n");
expect(generatedEnv).not.toContain("COMPOSE_PROJECT_NAME=runtime-project");
await withUnixSocket(socketPath, async () => {
await runDockerCommand(["--dev", "status"], devHostEnv(fakeDocker, devRoot, home, {
COMPOSE_PROJECT_NAME: "ambient-dev-project",
PI_WEB_DOCKER_RUNTIME_ENV_FILE: runtimeEnvFile,
}));
});
const regeneratedEnv = await readFile(generatedEnvPath, "utf8");
expect(regeneratedEnv).toContain("COMPOSE_PROJECT_NAME=pi-web-dev\n");
expect(regeneratedEnv).toContain(`PI_WEB_DOCKER_DATA_DIR=${runtimeDataDir}\n`);
expect(regeneratedEnv).not.toContain("COMPOSE_PROJECT_NAME=ambient-dev-project");
const localConfig = await readFile(join(devRoot, ".pi-web", "docker-compose-dev.local.env"), "utf8");
expect(localConfig).toContain("docker/pi-web-docker --dev creates this file once");
expect(localConfig).toContain("PI_WEB_UID and PI_WEB_GID default to the current host user");
const override = await readFile(join(devRoot, ".pi-web", "docker-compose-dev.host.generated.yml"), "utf8");
expect(override).toContain(socketPath);
expect(override).toContain(devRoot);
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain(`compose --project-name pi-web-dev --env-file ${generatedEnvPath} -f ${devRoot}/docker/compose.dev.yml -f ${devRoot}/.pi-web/docker-compose-dev.host.generated.yml ps`);
});
dockerCommandIt("rejects development commands as root unless explicitly allowed", async () => {
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 0, 0);
const result = await runDockerCommandAllowFailure(["--dev", "status"], {
...cleanProcessEnv(),
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
});
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("refusing to run Docker development mode as root");
});
dockerCommandIt("passes the root override through to the development compose helper", async () => {
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 0, 0);
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createDevRepoFixtureWithFakeHelper(helperLog);
await runDockerCommand(["--dev", "--allow-root", "status"], {
...cleanProcessEnv(),
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
PI_WEB_DOCKER_DEV_REPO_ROOT: devRoot,
});
expect(await readFile(helperLog, "utf8")).toBe("allow=1 args=ps\n");
});
dockerCommandIt("refuses development updates when the checkout has uncommitted files", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
await writeFile(join(devRoot, "staged.txt"), "changed\n", "utf8");
await execUtf8("git", ["-C", devRoot, "add", "staged.txt"], cleanProcessEnv());
await writeFile(join(devRoot, "modified.txt"), "changed\n", "utf8");
await writeFile(join(devRoot, "untracked.txt"), "untracked\n", "utf8");
const result = await runDockerCommandAllowFailure(
["--dev", "update"],
devHostEnv(fakeDocker, devRoot, join(tempDir, "home")),
);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("refusing to update the Docker development stack because the checkout has uncommitted changes");
expect(result.stderr).toContain("staged.txt");
expect(result.stderr).toContain("modified.txt");
expect(result.stderr).toContain("?? untracked.txt");
expect(result.stderr).toContain("commit, stash, or remove these changes");
await expect(readFile(helperLog, "utf8")).rejects.toThrow();
});
dockerCommandIt("refuses dirty development updates before scheduling a detached helper", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
await writeFile(join(devRoot, "untracked.txt"), "untracked\n", "utf8");
const result = await runDockerCommandAllowFailure(["--dev", "update"], devRuntimeEnv(fakeDocker, devRoot));
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("checkout has uncommitted changes");
expect(result.stdout).not.toContain("Started detached PI WEB Docker helper");
await expect(readFile(fakeDocker.logPath, "utf8")).rejects.toThrow();
await expect(readFile(helperLog, "utf8")).rejects.toThrow();
});
dockerCommandIt("refuses development updates while a Git operation is in progress", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
const head = (await execUtf8("git", ["-C", devRoot, "rev-parse", "HEAD"], cleanProcessEnv())).stdout.trim();
await writeFile(join(devRoot, ".git", "MERGE_HEAD"), `${head}\n`, "utf8");
const result = await runDockerCommandAllowFailure(
["--dev", "update"],
devHostEnv(fakeDocker, devRoot, join(tempDir, "home")),
);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("while a Git merge is in progress");
expect(result.stderr).toContain("resolve or abort the Git merge");
await expect(readFile(helperLog, "utf8")).rejects.toThrow();
});
dockerCommandIt("allows clean development updates and dirty development starts", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
const env = devHostEnv(fakeDocker, devRoot, join(tempDir, "home"));
await runDockerCommand(["--dev", "update"], env);
await writeFile(join(devRoot, "in-progress-work.txt"), "dirty by design\n", "utf8");
await runDockerCommand(["--dev", "start"], env);
expect(await readFile(helperLog, "utf8")).toBe([
"allow=0 args=build --pull",
"allow=0 args=up -d --force-recreate --remove-orphans",
"allow=0 args=up -d --build",
"",
].join("\n"));
});
dockerCommandIt("starts development detached helpers as the generated dev user", async () => {
const devRoot = await createDevGeneratedEnv({ uid: 1234, gid: 2345, dockerGid: 3456 });
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
await runDockerCommand(["--dev", "restart-sessiond"], devRuntimeEnv(fakeDocker, devRoot));
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain(`--env-file ${devRoot}/.pi-web/docker-compose-dev.generated.env`);
expect(log).toContain("--group-add 3456");
expect(log).toContain("--user 1234:2345");
expect(log).toContain("PI_WEB_DOCKER_MODE=dev");
expect(log).toContain("PI_WEB_DOCKER_ALLOW_ROOT=0");
expect(log).toContain("PI_WEB_DOCKER_HELPER_IMAGE=pi-web:test");
expect(log).toContain(`PI_WEB_DOCKER_DEV_REPO_ROOT=${devRoot}`);
expect(log).toContain("COMPOSE_PROJECT_NAME=pi-web-dev-test");
expect(log).toContain("pi-web.docker-helper.mode=dev");
expect(log).toContain("pi-web:test pi-web-docker --dev __run-detached restart-sessiond");
expect(log).not.toContain("--user 0:0");
});
dockerCommandIt("rejects inside-container commands whose explicit mode does not match the container mode", async () => {
const result = await runDockerCommandAllowFailure(["restart-sessiond"], {
...cleanProcessEnv(),
PI_WEB_DOCKER_RUNTIME: "1",
PI_WEB_DOCKER_MODE: "dev",
});
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("this PI WEB Docker container is in dev mode");
});
dockerCommandIt("routes production install to the bootstrap installer", async () => {
const result = await runDockerCommand(["install", "--help"], cleanProcessEnv());
expect(result.stdout).toContain("Usage: docker/install.sh [options]");
});
dockerCommandIt("explains source checkout runtime-mode mistakes", async () => {
const fakeDocker = await installFakeDocker();
const result = await runDockerCommandAllowFailure(["start"], {
...cleanProcessEnv(),
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
HOME: "/home/pi-web-test",
});
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("runtime install assets were not found");
expect(result.stderr).toContain("running this checkout's Docker command in runtime mode");
expect(result.stderr).toContain("./docker/pi-web-docker --dev start");
expect(result.stderr).toContain("/home/pi-web-test/.local/share/pi-web-docker/pi-web-docker start");
expect(result.stderr).toContain("PI_WEB_DOCKER_INSTALL_DIR");
});
dockerCommandIt("starts restart-sessiond in a detached Docker helper", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
const result = await runDockerCommand(["restart-sessiond"], runtimeEnv(fakeDocker, installDir));
expect(result.stdout).toContain("Started detached PI WEB Docker helper");
expect(result.stdout).toContain("Streaming detached PI WEB Docker helper logs inline.");
expect(result.stdout).toContain("Reconnect with: docker logs -f pi-web-docker-restart-sessiond-");
expect(result.stdout).toContain("fake helper log");
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain("container inspect");
expect(log).toContain("run -d");
expect(log).toContain("--env-file");
expect(log).toContain(`${installDir}/.env`);
expect(log).toContain("--volumes-from");
expect(log).toContain("--group-add 3456");
expect(log).toContain("--user 1234:2345");
expect(log).toContain(`PI_WEB_DOCKER_INSTALL_DIR=${installDir}`);
expect(log).toContain(`PI_WEB_DOCKER_DATA_DIR=${join(installDir, "data")}`);
expect(log).toContain("PI_WEB_PORT=12345");
expect(log).toContain("PI_WEB_DOCKER_EXTRA_HOST_PATHS=/srv/pi-web-extra /opt/pi-web-extra");
expect(log).toContain("PI_WEB_EXTRA_ZYPPER_PACKAGES=git-lfs jq");
expect(log).not.toContain('PI_WEB_EXTRA_ZYPPER_PACKAGES="git-lfs jq"');
expect(log).toContain("PI_WEB_DOCKER_HELPER_IMAGE=pi-web:test");
expect(log).toContain("COMPOSE_PROJECT_NAME=pi-web-test");
expect(log).toContain("pi-web.docker-helper.mode=runtime");
expect(log).toContain("pi-web.docker-helper.root=");
expect(log).toContain("pi-web.docker-helper.project=pi-web-test");
expect(log).toContain("pi-web:test pi-web-docker __run-detached restart-sessiond");
expect(log).not.toContain("--user 0:0");
expect(log).not.toContain("compose -f compose.yml -f compose.override.yml restart sessiond");
});
dockerCommandIt("executes the detached restart-sessiond action through Compose", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
await runDockerCommand(["__run-detached", "restart-sessiond"], runtimeEnv(fakeDocker, installDir));
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml restart sessiond");
expect(log).not.toContain("run -d");
});
dockerCommandIt("executes the detached runtime update action through Compose without nesting helpers", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
await runDockerCommand(["__run-detached", "update"], runtimeEnv(fakeDocker, installDir));
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml build --pull --no-cache");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml up -d --force-recreate --remove-orphans");
expect(log).not.toContain("run -d");
});
});
async function readRepoFile(relativePath: string): Promise<string> {
return await readFile(join(repoRoot, relativePath), "utf8");
}
function runDockerCommand(args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult> {
return execUtf8("sh", [dockerEntrypoint, ...args], env);
}
function runDockerCommandAllowFailure(args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult & { exitCode: number }> {
return execUtf8AllowFailure("sh", [dockerEntrypoint, ...args], env);
}
function execUtf8(file: string, args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult> {
return new Promise((resolvePromise, reject) => {
execFile(file, args, { encoding: "utf8", env }, (error, stdout, stderr) => {
if (error !== null) {
reject(error instanceof Error ? error : new Error("Process failed"));
return;
}
resolvePromise({ stdout, stderr });
});
});
}
function execUtf8AllowFailure(file: string, args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult & { exitCode: number }> {
return new Promise((resolvePromise) => {
execFile(file, args, { encoding: "utf8", env }, (error, stdout, stderr) => {
const exitCode = typeof error === "object" && error !== null && "code" in error && typeof error.code === "number" ? error.code : 0;
resolvePromise({ stdout, stderr, exitCode });
});
});
}
async function createRuntimeInstall(): Promise<string> {
const installDir = join(tempDir, "runtime");
await mkdir(installDir, { recursive: true });
await writeFile(join(installDir, ".env"), [
"PI_WEB_UID=1234",
"PI_WEB_GID=2345",
"DOCKER_GID=3456",
`PI_WEB_DOCKER_DATA_DIR=${join(installDir, "data")}`,
`PI_WEB_DOCKER_INSTALL_DIR=${installDir}`,
"PI_WEB_DOCKER_EXTRA_HOST_PATHS=\"/srv/pi-web-extra /opt/pi-web-extra\"",
"PI_WEB_BIND_ADDR=127.0.0.1",
"PI_WEB_PORT=12345",
"PI_WEB_EXTRA_ZYPPER_PACKAGES=\"git-lfs jq\"",
"PI_WEB_IMAGE=pi-web:test",
"COMPOSE_PROJECT_NAME=pi-web-test",
"",
].join("\n"), "utf8");
await writeFile(join(installDir, "compose.yml"), "name: pi-web\nservices: {}\n", "utf8");
await writeFile(join(installDir, "compose.override.yml"), "services: {}\n", "utf8");
return installDir;
}
async function createDevRepoFixture(): Promise<string> {
const devRoot = join(tempDir, "dev-repo");
await mkdir(join(devRoot, "docker", "internal", "dev"), { recursive: true });
await copyFile(join(repoRoot, "docker", "internal", "dev", "compose"), join(devRoot, "docker", "internal", "dev", "compose"));
await chmod(join(devRoot, "docker", "internal", "dev", "compose"), 0o755);
await copyFile(join(repoRoot, "docker", "internal", "host-profile.sh"), join(devRoot, "docker", "internal", "host-profile.sh"));
await writeFile(join(devRoot, "docker", "compose.dev.yml"), "name: pi-web-dev\nservices: {}\n", "utf8");
return devRoot;
}
async function createDevRepoFixtureWithFakeHelper(logPath: string): Promise<string> {
const devRoot = join(tempDir, "dev-repo-fake-helper");
const helperPath = join(devRoot, "docker", "internal", "dev", "compose");
await mkdir(dirname(helperPath), { recursive: true });
await writeFile(helperPath, `#!/usr/bin/env sh
set -eu
printf 'allow=%s args=%s\n' "\${PI_WEB_DOCKER_ALLOW_ROOT:-}" "$*" >>${shellSingleQuote(logPath)}
`, "utf8");
await chmod(helperPath, 0o755);
return devRoot;
}
async function createCleanDevGitRepoWithFakeHelper(logPath: string): Promise<string> {
const devRoot = await createDevRepoFixtureWithFakeHelper(logPath);
await Promise.all([
writeFile(join(devRoot, "staged.txt"), "clean\n", "utf8"),
writeFile(join(devRoot, "modified.txt"), "clean\n", "utf8"),
]);
const env = cleanProcessEnv();
await execUtf8("git", ["init", "--quiet", devRoot], env);
await execUtf8("git", ["-C", devRoot, "add", "."], env);
await execUtf8("git", [
"-C", devRoot,
"-c", "user.name=PI WEB Test",
"-c", "[email protected]",
"-c", "core.hooksPath=/dev/null",
"commit", "--quiet", "--no-gpg-sign", "-m", "test fixture",
], env);
return devRoot;
}
async function createDevGeneratedEnv(ids: { uid: number; gid: number; dockerGid: number }): Promise<string> {
const devRoot = join(tempDir, "dev-runtime");
await mkdir(join(devRoot, ".pi-web"), { recursive: true });
await writeFile(join(devRoot, ".pi-web", "docker-compose-dev.generated.env"), [
`PI_WEB_UID=${String(ids.uid)}`,
`PI_WEB_GID=${String(ids.gid)}`,
`DOCKER_GID=${String(ids.dockerGid)}`,
`PI_WEB_DOCKER_DATA_DIR=${join(tempDir, "dev-data")}`,
`PI_WEB_DOCKER_DEV_REPO_ROOT=${devRoot}`,
"PI_WEB_DEV_API_BIND_ADDR=127.0.0.1",
"PI_WEB_DEV_BIND_ADDR=127.0.0.1",
"PI_WEB_DEV_API_PORT=8504",
"PI_WEB_DEV_PORT=8505",
"PI_WEB_DEV_IMAGE=pi-web:test",
"COMPOSE_PROJECT_NAME=pi-web-dev-test",
"",
].join("\n"), "utf8");
return devRoot;
}
async function installFakeDocker(): Promise<FakeDocker> {
const binDir = join(tempDir, "bin");
const logPath = join(tempDir, "docker.log");
const dockerPath = join(binDir, "docker");
await mkdir(binDir, { recursive: true });
await writeFile(dockerPath, `#!/usr/bin/env sh
set -eu
: "\${FAKE_DOCKER_LOG:?}"
printf '%s\n' "$*" >>"$FAKE_DOCKER_LOG"
case "\${1:-}" in
--version)
printf 'Docker version 99.0.0, fake\n'
exit 0
;;
context)
case "\${2:-}" in
show)
printf 'default\n'
exit 0
;;
inspect)
exit 0
;;
esac
;;
info)
if [ "\${2:-}" = --format ]; then
printf 'Docker Desktop\n'
else
printf 'Fake Docker info\n'
fi
exit 0
;;
compose)
if [ "\${2:-}" = version ]; then
exit 0
fi
printf 'fake docker'
for arg in "$@"; do
printf ' %s' "$arg"
done
printf '\n'
exit 0
;;
container)
if [ "\${2:-}" = inspect ]; then
for arg in "$@"; do
if [ "$arg" = --format ]; then
printf 'pi-web:test\n'
exit 0
fi
done
printf '{}\n'
exit 0
fi
;;
ps|rm)
exit 0
;;
run)
printf 'fake-helper-container-id\n'
exit 0
;;
logs)
printf 'fake helper log\n'
exit 0
;;
inspect)
for arg in "$@"; do
case "$arg" in
*State.ExitCode*)
printf '0\n'
exit 0
;;
esac
done
printf '{}\n'
exit 0
;;
esac
printf 'unexpected fake docker args: %s\n' "$*" >&2
exit 9
`, "utf8");
await chmod(dockerPath, 0o755);
return { binDir, logPath };
}
async function installFakeCurl(binDir: string): Promise<void> {
const curlPath = join(binDir, "curl");
await writeFile(curlPath, `#!/usr/bin/env sh
set -eu
asset_root=${shellSingleQuote(join(repoRoot, "docker"))}
output=
url=
while [ "$#" -gt 0 ]; do
case "$1" in
-o)
shift
output=\${1:-}
;;
-*)
;;
*)
url=$1
;;
esac
if [ "$#" -gt 0 ]; then
shift
fi
done
[ -n "$url" ] || { printf '%s\n' "fake curl missing URL" >&2; exit 2; }
[ -n "$output" ] || { printf '%s\n' "fake curl missing -o output" >&2; exit 2; }
case "$url" in
*/docker/*) rel=\${url##*/docker/} ;;
*) printf 'unexpected fake curl url: %s\n' "$url" >&2; exit 2 ;;
esac
src=$asset_root/$rel
[ -f "$src" ] || { printf 'missing fake curl asset: %s\n' "$src" >&2; exit 2; }
mkdir -p "$(dirname "$output")"
cp "$src" "$output"
`, "utf8");
await chmod(curlPath, 0o755);
}
async function installFakeUname(binDir: string, osName: string): Promise<void> {
const unamePath = join(binDir, "uname");
await writeFile(unamePath, `#!/usr/bin/env sh
set -eu
printf '%s\n' ${shellSingleQuote(osName)}
`, "utf8");
await chmod(unamePath, 0o755);
}
async function installFakeId(binDir: string, uid: number, gid: number): Promise<void> {
const idPath = join(binDir, "id");
await writeFile(idPath, `#!/usr/bin/env sh
set -eu
case "\${1:-}" in
-u) printf '%s\n' ${String(uid)} ;;
-g) printf '%s\n' ${String(gid)} ;;
*) printf '%s\n' ${String(uid)} ;;
esac
`, "utf8");
await chmod(idPath, 0o755);
}
async function withUnixSocket<T>(socketPath: string, callback: () => Promise<T>): Promise<T> {
await mkdir(dirname(socketPath), { recursive: true });
const server = createServer();
await new Promise<void>((resolvePromise, reject) => {
server.once("error", reject);
server.listen(socketPath, resolvePromise);
});
try {
return await callback();
} finally {
await new Promise<void>((resolvePromise) => {
server.close(() => {
resolvePromise();
});
});
await rm(socketPath, { force: true });
}
}
function cleanProcessEnv(): NodeJS.ProcessEnv {
const env = sanitizedGitEnv(process.env);
for (const key of Object.keys(env)) {
if (key === "COMPOSE_PROJECT_NAME" || key === "DOCKER_GID" || key === "HOSTEXEC_IMAGE" || key === "XDG_DATA_HOME" || key.startsWith("PI_WEB_")) {
Reflect.deleteProperty(env, key);
}
}
return env;
}
function runtimeEnv(fakeDocker: FakeDocker, installDir: string): NodeJS.ProcessEnv {
return {
...runtimeHostEnv(fakeDocker, installDir),
PI_WEB_DOCKER_RUNTIME: "1",
};
}
function runtimeHostEnv(fakeDocker: FakeDocker, installDir: string): NodeJS.ProcessEnv {
return {
...cleanProcessEnv(),
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
FAKE_DOCKER_LOG: fakeDocker.logPath,
PI_WEB_DOCKER_RUNTIME: "0",
PI_WEB_DOCKER_MODE: "runtime",
PI_WEB_DOCKER_INSTALL_DIR: installDir,
};
}
function devHostEnv(fakeDocker: FakeDocker, devRoot: string, home: string, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
return {
...cleanProcessEnv(),
...extra,
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
HOME: home,
DOCKER_HOST: "",
FAKE_DOCKER_LOG: fakeDocker.logPath,
PI_WEB_DOCKER_RUNTIME: "0",
PI_WEB_DOCKER_MODE: "dev",
PI_WEB_DOCKER_DEV_REPO_ROOT: devRoot,
};
}
function devRuntimeEnv(fakeDocker: FakeDocker, devRoot: string): NodeJS.ProcessEnv {
return {
...cleanProcessEnv(),
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
FAKE_DOCKER_LOG: fakeDocker.logPath,
PI_WEB_DOCKER_RUNTIME: "1",
PI_WEB_DOCKER_MODE: "dev",
PI_WEB_DOCKER_DEV_REPO_ROOT: devRoot,
PI_WEB_DOCKER_CONTAINER_ID: "current-container",
};
}
function shellSingleQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
+102
View File
@@ -0,0 +1,102 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const syncScript = join(repoRoot, "docker", "internal", "dev", "sync-node-modules");
const dockerSyncIt = it.skipIf(process.platform === "win32");
let tempDir = "";
interface SyncFixture {
workspaceDir: string;
seedDir: string;
targetDir: string;
}
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-docker-dependencies-test-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("Docker development dependency synchronization", () => {
dockerSyncIt("replaces a stale dependency tree once per image generation", async () => {
const fixture = await createSyncFixture();
const first = await runSync(fixture);
expect(first.exitCode).toBe(0);
expect(first.stderr).toContain("Synchronizing PI WEB Docker dev dependencies");
expect(await readFile(join(fixture.targetDir, "fresh", "version.txt"), "utf8")).toBe("0.80.6\n");
expect(await readlink(join(fixture.targetDir, ".bin", "fresh"))).toBe("../fresh/version.txt");
expect(await readFile(join(fixture.targetDir, ".pi-web-dev-dependency-generation"), "utf8")).toBe("image-generation-2\n");
await expect(readFile(join(fixture.targetDir, "stale.txt"), "utf8")).rejects.toThrow();
await writeFile(join(fixture.targetDir, "keep-on-current-generation.txt"), "kept\n", "utf8");
const second = await runSync(fixture);
expect(second.exitCode).toBe(0);
expect(second.stderr).toContain("dependencies are current");
expect(await readFile(join(fixture.targetDir, "keep-on-current-generation.txt"), "utf8")).toBe("kept\n");
});
dockerSyncIt("fails without changing the volume when the image manifests are stale", async () => {
const fixture = await createSyncFixture();
await writeFile(join(fixture.workspaceDir, "package-lock.json"), '{"lockfileVersion":3,"changed":true}\n', "utf8");
const result = await runSync(fixture);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("development image dependencies do not match the checkout");
expect(await readFile(join(fixture.targetDir, "stale.txt"), "utf8")).toBe("stale\n");
});
});
async function createSyncFixture(): Promise<SyncFixture> {
const workspaceDir = join(tempDir, "workspace");
const seedDir = join(tempDir, "seed");
const targetDir = join(workspaceDir, "node_modules");
const packageJson = '{"name":"dependency-sync-fixture","private":true}\n';
const packageLock = '{"name":"dependency-sync-fixture","lockfileVersion":3}\n';
await Promise.all([
mkdir(join(seedDir, "node_modules", "fresh"), { recursive: true }),
mkdir(join(seedDir, "node_modules", ".bin"), { recursive: true }),
mkdir(targetDir, { recursive: true }),
]);
await Promise.all([
writeFile(join(workspaceDir, "package.json"), packageJson, "utf8"),
writeFile(join(workspaceDir, "package-lock.json"), packageLock, "utf8"),
writeFile(join(seedDir, "package.json"), packageJson, "utf8"),
writeFile(join(seedDir, "package-lock.json"), packageLock, "utf8"),
writeFile(join(seedDir, "generation"), "image-generation-2\n", "utf8"),
writeFile(join(seedDir, "node_modules", "fresh", "version.txt"), "0.80.6\n", "utf8"),
writeFile(join(targetDir, "stale.txt"), "stale\n", "utf8"),
writeFile(join(targetDir, ".pi-web-dev-dependency-generation"), "image-generation-1\n", "utf8"),
]);
await symlink("../fresh/version.txt", join(seedDir, "node_modules", ".bin", "fresh"));
return { workspaceDir, seedDir, targetDir };
}
function runSync(fixture: SyncFixture): Promise<{ stdout: string; stderr: string; exitCode: number }> {
return new Promise((resolvePromise) => {
execFile("bash", [syncScript], {
encoding: "utf8",
env: {
...process.env,
PI_WEB_DEV_WORKSPACE_DIR: fixture.workspaceDir,
PI_WEB_DEV_DEPENDENCY_SEED_DIR: fixture.seedDir,
},
}, (error, stdout, stderr) => {
const exitCode = typeof error === "object" && error !== null && "code" in error && typeof error.code === "number" ? error.code : 0;
resolvePromise({ stdout, stderr, exitCode });
});
});
}
+32
View File
@@ -29,6 +29,38 @@ describe("RemoteMachineClient", () => {
expect(new Headers(init.headers).get("content-type")).toBe("application/json");
expect(init.body).toBe(JSON.stringify({ cwd: "/repo" }));
});
it("requests compression for the remote hop even when configured headers use different casing", async () => {
const fetchImpl = vi.fn<typeof fetch>(() => Promise.resolve(new Response("ok", { status: 200 })));
const client = new RemoteMachineClient({
baseUrl: "https://remote.example.test/",
headers: { "Accept-Encoding": "identity" },
}, fetchImpl);
await client.request("GET", "/api/projects");
const { init } = onlyFetchCall(fetchImpl);
expect(new Headers(init.headers).get("accept-encoding")).toBe("gzip, deflate");
});
it("removes stale representation headers after Fetch decodes a compressed response", async () => {
const fetchImpl = vi.fn<typeof fetch>(() => Promise.resolve(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
"content-type": "application/json",
"content-encoding": "gzip",
"content-length": "31",
},
})));
const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/" }, fetchImpl);
const response = await client.requestJson("GET", "/api/projects");
expect(response.body).toEqual({ ok: true });
expect(response.headers["content-type"]).toBe("application/json");
expect(response.headers["content-encoding"]).toBeUndefined();
expect(response.headers["content-length"]).toBeUndefined();
});
});
function fetchInputUrl(input: RequestInfo | URL): string {
+20 -10
View File
@@ -28,6 +28,8 @@ export interface MachineClient {
export const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 30_000;
export const DEFAULT_REMOTE_HEALTH_TIMEOUT_MS = 3_000;
const REMOTE_RESPONSE_ACCEPT_ENCODING = "gzip, deflate";
const BLOCKED_CONFIGURED_HEADER_NAMES = new Set([
"host",
"connection",
@@ -57,7 +59,7 @@ export class RemoteMachineClient implements MachineClient {
const response = await this.fetchResponse(method, path, body, options);
return {
statusCode: response.status,
headers: headersToRecord(response.headers),
headers: decodedResponseHeaders(response.headers),
...(response.body === null ? {} : { body: readableFromWebResponseBody(response.body) }),
};
}
@@ -68,7 +70,7 @@ export class RemoteMachineClient implements MachineClient {
const parsed: unknown = text === "" ? undefined : JSON.parse(text);
return {
statusCode: response.status,
headers: headersToRecord(response.headers),
headers: decodedResponseHeaders(response.headers),
body: parsed,
};
}
@@ -100,12 +102,12 @@ export class RemoteMachineClient implements MachineClient {
}
}
private requestHeaders(body: unknown, options: MachineRequestOptions): HeadersInit {
return {
...this.remoteHeaders(),
accept: "*/*",
...(body === undefined ? {} : { "content-type": options.contentType ?? defaultContentTypeForBody(body) }),
};
private requestHeaders(body: unknown, options: MachineRequestOptions): Headers {
const headers = new Headers(this.remoteHeaders());
headers.set("accept", "*/*");
headers.set("accept-encoding", REMOTE_RESPONSE_ACCEPT_ENCODING);
if (body !== undefined) headers.set("content-type", options.contentType ?? defaultContentTypeForBody(body));
return headers;
}
private remoteHeaders(): Record<string, string> {
@@ -145,8 +147,16 @@ function filterConfiguredHeaders(headers: Record<string, string> | undefined): R
return Object.fromEntries(Object.entries(headers).filter(([key]) => !BLOCKED_CONFIGURED_HEADER_NAMES.has(key.toLowerCase())));
}
function headersToRecord(headers: Headers): Record<string, string> {
return Object.fromEntries(headers.entries());
function decodedResponseHeaders(headers: Headers): Record<string, string> {
const values: Record<string, string> = Object.fromEntries(headers.entries());
const contentEncoding = values["content-encoding"];
if (contentEncoding !== undefined && contentEncoding !== "identity") {
// Fetch decodes response bodies but retains headers for the encoded wire
// representation. The outer HTTP edge must negotiate and frame the decoded body.
delete values["content-encoding"];
delete values["content-length"];
}
return values;
}
function serializeRequestBody(method: string, body: unknown): NonNullable<RequestInit["body"]> | undefined {
@@ -86,7 +86,7 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa
if (modulePath === undefined) return [];
return [{
...plugin,
module: `/pi-web-plugins/${encodeURIComponent(machineScopedPluginId(machineId, plugin.id))}/${modulePath.path}${modulePath.query}`,
module: `../../../../pi-web-plugins/${encodeURIComponent(machineScopedPluginId(machineId, plugin.id))}/${modulePath.path}${modulePath.query}`,
}];
}),
};
@@ -95,10 +95,13 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa
function remotePluginModulePath(pluginId: string, module: string): { path: string; query: string } | undefined {
if (!isPiWebPluginId(pluginId)) return undefined;
const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`;
const base = new URL(prefix, "http://pi-web.local");
const pluginRootUrl = new URL(prefix, "http://pi-web.local");
const manifestUrl = new URL("/pi-web-plugins/manifest.json", pluginRootUrl);
try {
const url = new URL(module, base);
if (url.origin !== base.origin || !url.pathname.startsWith(prefix)) return undefined;
// An explicit ./<plugin-id>/ prefix is manifest-relative; bare paths retain the legacy plugin-root-relative contract.
const baseUrl = module.startsWith("./") ? manifestUrl : pluginRootUrl;
const url = new URL(module, baseUrl);
if (url.origin !== pluginRootUrl.origin || !url.pathname.startsWith(prefix)) return undefined;
const path = safeRemotePluginAssetPath(url.pathname.slice(prefix.length));
return path === undefined ? undefined : { path, query: url.search };
} catch {
+72 -12
View File
@@ -1,8 +1,9 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { WebSocket } from "ws";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js";
import { mergeSelectedMachineConfig, parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, selectedMachineConfigResponse } from "../configRoutes.js";
import { bridgeSockets } from "../webSocketBridge.js";
import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js";
import { RemoteMachineRequestError, type MachineClient, type MachineJsonResponse, type MachineRequestOptions } from "./machineClient.js";
import { MachineService } from "./machineService.js";
export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES;
@@ -23,7 +24,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
app.route<{ Params: { machineId: string }; Body: unknown }>({
method: spec.method,
url: `/api/machines/:machineId${spec.path}`,
handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
handler: (request, reply) => proxyHttpRequest(machines, spec, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
});
}
@@ -34,7 +35,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
}
}
async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
async function proxyHttpRequest(machines: MachineService, spec: FederatedHttpRouteSpec, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
if (machineId === "local") {
return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" });
}
@@ -45,19 +46,62 @@ async function proxyHttpRequest(machines: MachineService, machineId: string, met
}
try {
const requestOptions = proxyRequestOptions(body, contentType);
const remotePath = remoteApiPath(machineId, requestUrl);
if (spec.path === "/config") return await proxySelectedMachineConfigRequest(client, machineId, method, remotePath, body, reply);
const requestOptions = proxyRequestOptions(spec, body, contentType);
const upstream = requestOptions === undefined
? await client.request(method, remoteApiPath(machineId, requestUrl), body)
: await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions);
? await client.request(method, remotePath, body)
: await client.request(method, remotePath, body, requestOptions);
reply.code(upstream.statusCode);
applySafeHeaders(reply, upstream.headers);
if (upstream.body === undefined) return await reply.send();
return await reply.send(upstream.body);
} catch (error) {
if (isSelectedMachineConfigRequestError(error)) return reply.code(400).send({ error: errorMessage(error) });
return sendGatewayError(reply, machineId, error);
}
}
async function proxySelectedMachineConfigRequest(client: MachineClient, machineId: string, method: string, remotePath: string, body: unknown, reply: FastifyReply): Promise<FastifyReply> {
if (method === "GET") {
return sendSelectedMachineConfigResponse(reply, await client.requestJson("GET", remotePath), machineId);
}
if (method === "PUT") {
const patch = parseSelectedMachineConfigRequest(configPayload(body));
const currentResponse = await client.requestJson("GET", remotePath);
if (!isSuccessfulStatus(currentResponse.statusCode)) return sendUpstreamJsonResponse(reply, currentResponse, machineId);
const current = parsePiWebConfigResponseBody(currentResponse.body, "Remote machine config response");
const merged = mergeSelectedMachineConfig(current.config, patch);
return sendSelectedMachineConfigResponse(reply, await client.requestJson("PUT", remotePath, { config: merged }), machineId);
}
return reply.code(405).send({ error: "Method not allowed" });
}
function configPayload(body: unknown): unknown {
return isRecord(body) ? body["config"] : undefined;
}
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
if (!isSuccessfulStatus(upstream.statusCode)) return sendUpstreamJsonResponse(reply, upstream, machineId);
reply.code(upstream.statusCode);
applySafeHeaders(reply, upstream.headers);
return reply.send(selectedMachineConfigResponse(parsePiWebConfigResponseBody(upstream.body, "Remote machine config response")));
}
function sendUpstreamJsonResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
reply.code(upstream.statusCode);
applySafeHeaders(reply, upstream.headers);
return reply.send(upstream.body ?? { error: "Remote machine config request failed", machineId, statusCode: upstream.statusCode });
}
function isSuccessfulStatus(statusCode: number): boolean {
return statusCode >= 200 && statusCode < 300;
}
async function proxyWebSocket(machines: MachineService, machineId: string, requestUrl: string, socket: WebSocket): Promise<void> {
if (machineId === "local") {
socket.close(1011, "Local machine route is not registered for this endpoint");
@@ -84,10 +128,14 @@ function remoteApiPath(machineId: string, requestUrl: string): string {
return `/api${compatPath}`;
}
function proxyRequestOptions(body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
if (!isRawProxyBody(body)) return undefined;
const value = firstHeaderValue(contentType);
return value === undefined || value === "" ? undefined : { contentType: value };
function proxyRequestOptions(spec: Pick<FederatedHttpRouteSpec, "timeoutMs">, body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
const options: MachineRequestOptions = {};
if (spec.timeoutMs !== undefined) options.timeoutMs = spec.timeoutMs;
if (isRawProxyBody(body)) {
const value = firstHeaderValue(contentType);
if (value !== undefined && value !== "") options.contentType = value;
}
return Object.keys(options).length === 0 ? undefined : options;
}
function isRawProxyBody(body: unknown): boolean {
@@ -106,6 +154,18 @@ function applySafeHeaders(reply: FastifyReply, headers: Record<string, string |
}
}
function isSelectedMachineConfigRequestError(error: unknown): boolean {
return error instanceof Error && error.message.startsWith("PI WEB selected-machine config");
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown): FastifyReply {
const statusCode = error instanceof RemoteMachineRequestError ? error.statusCode : 502;
const label = statusCode === 504 ? "Remote machine timeout" : "Remote machine unavailable";
@@ -113,6 +173,6 @@ function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown
error: label,
machineId,
statusCode,
detail: error instanceof Error ? error.message : String(error),
detail: errorMessage(error),
});
}
+172 -2
View File
@@ -2,6 +2,9 @@ import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PiWebRuntimeResponse } from "../../shared/apiTypes.js";
import { PI_WEB_CAPABILITIES } from "../../shared/capabilities.js";
import type { MachineClient } from "./machineClient.js";
import { MachineService } from "./machineService.js";
import { MachineStore, machineStorePath } from "./machineStore.js";
@@ -24,6 +27,7 @@ describe("MachineService", () => {
expect(await service.list()).toEqual([
{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" },
]);
await expect(stat(storePath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("adds remote machines and omits secrets from public responses", async () => {
@@ -38,8 +42,57 @@ describe("MachineService", () => {
await expectOwnerOnlyMachineStore(storePath);
});
it("tightens permissions after reading an existing machine store", async () => {
if (process.platform === "win32") return;
it("gets, updates, and removes remote machines without exposing stored secrets", async () => {
const machine = await service.add({
name: "Remote",
baseUrl: "https://remote.example.test",
token: "initial-secret",
headers: { "X-Pi-Web-Test": "initial" },
});
expect(await service.get(machine.id)).toEqual(machine);
const updated = await service.update(machine.id, {
name: " Updated Remote ",
baseUrl: "https://updated.example.test/",
token: "updated-secret",
headers: { "X-Pi-Web-Test": "updated" },
});
if (updated === undefined) throw new Error("Expected remote machine update to succeed");
expect(updated).toMatchObject({
id: machine.id,
name: "Updated Remote",
kind: "remote",
baseUrl: "https://updated.example.test",
createdAt: machine.createdAt,
});
expect(updated).not.toHaveProperty("token");
expect(updated).not.toHaveProperty("headers");
expect(await service.get(machine.id)).toEqual(updated);
expect(await service.list()).toEqual([expect.objectContaining({ id: "local", kind: "local" }), updated]);
const persistedAfterUpdate: unknown = JSON.parse(await readFile(storePath, "utf8"));
expect(persistedAfterUpdate).toMatchObject({
machines: [expect.objectContaining({
id: machine.id,
name: "Updated Remote",
baseUrl: "https://updated.example.test",
token: "updated-secret",
headers: { "X-Pi-Web-Test": "updated" },
})],
});
await expect(service.remove(machine.id)).resolves.toBe(true);
await expect(service.get(machine.id)).resolves.toBeUndefined();
await expect(service.remove(machine.id)).resolves.toBe(false);
expect(await service.list()).toEqual([expect.objectContaining({ id: "local", kind: "local" })]);
const persistedAfterRemove: unknown = JSON.parse(await readFile(storePath, "utf8"));
expect(persistedAfterRemove).toEqual({ machines: [] });
});
it.skipIf(process.platform === "win32")("tightens permissions after reading an existing machine store", async () => {
await writeFile(storePath, `${JSON.stringify({
machines: [{
id: "remote-1",
@@ -97,6 +150,90 @@ describe("MachineService", () => {
});
});
it("fetches and caches remote runtime through the configured client", async () => {
const body = remoteRuntimeBody();
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({ statusCode: 200, headers: {}, body }));
const factoryMachines: unknown[] = [];
const remoteService = new MachineService(new MachineStore(storePath), {
remoteClientFactory: (machine) => {
factoryMachines.push(machine);
return fakeRemoteClient({ requestJson });
},
now: () => new Date("2026-05-25T00:00:00.000Z"),
runtimeCacheTtlMs: 10_000,
});
const machine = await remoteService.add({
name: " Remote ",
baseUrl: "https://remote.example.test/",
token: "secret",
headers: { "X-Pi-Web-Test": "yes" },
});
const first = await remoteService.runtime(machine.id);
const second = await remoteService.runtime(machine.id);
expect(first).toEqual({
machineId: machine.id,
ok: true,
checkedAt: "2026-05-25T00:00:00.000Z",
packageName: body.packageName,
generatedAt: body.generatedAt,
components: body.components,
capabilities: body.capabilities,
});
expect(second).toEqual(first);
expect(requestJson).toHaveBeenCalledTimes(1);
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
expect(factoryMachines).toEqual([
expect.objectContaining({
id: machine.id,
name: "Remote",
baseUrl: "https://remote.example.test",
token: "secret",
headers: { "X-Pi-Web-Test": "yes" },
}),
]);
});
it("caches remote runtime errors and clears them after remote updates", async () => {
let now = new Date("2026-05-25T00:00:00.000Z");
const body = remoteRuntimeBody();
const requestJson = vi.fn<MachineClient["requestJson"]>()
.mockRejectedValueOnce(new Error("network down"))
.mockResolvedValueOnce({ statusCode: 200, headers: {}, body });
const remoteService = new MachineService(new MachineStore(storePath), {
remoteClientFactory: () => fakeRemoteClient({ requestJson }),
now: () => now,
runtimeCacheTtlMs: 10_000,
});
const machine = await remoteService.add({ name: "Remote", baseUrl: "https://remote.example.test" });
const errorRuntime = await remoteService.runtime(machine.id);
now = new Date("2026-05-25T00:00:01.000Z");
const cachedErrorRuntime = await remoteService.runtime(machine.id);
await remoteService.update(machine.id, { name: "Remote Updated" });
now = new Date("2026-05-25T00:00:02.000Z");
const refreshedRuntime = await remoteService.runtime(machine.id);
expect(errorRuntime).toEqual({
machineId: machine.id,
ok: false,
checkedAt: "2026-05-25T00:00:00.000Z",
error: "network down",
});
expect(cachedErrorRuntime).toEqual(errorRuntime);
expect(refreshedRuntime).toEqual({
machineId: machine.id,
ok: true,
checkedAt: "2026-05-25T00:00:02.000Z",
packageName: body.packageName,
generatedAt: body.generatedAt,
components: body.components,
capabilities: body.capabilities,
});
expect(requestJson).toHaveBeenCalledTimes(2);
});
it("does not allow local machine mutation", async () => {
await expect(service.update("local", { name: "Other" })).rejects.toThrow("Local machine cannot be changed");
await expect(service.remove("local")).rejects.toThrow("Local machine cannot be deleted");
@@ -112,3 +249,36 @@ async function expectOwnerOnlyMachineStore(path: string): Promise<void> {
if (process.platform === "win32") return;
expect((await stat(path)).mode & 0o777).toBe(0o600);
}
function remoteRuntimeBody(): PiWebRuntimeResponse {
return {
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: {
component: "web",
label: "Remote Web",
runtimeVersion: "1.0.0",
available: true,
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage],
},
sessiond: {
component: "sessiond",
label: "Remote Session daemon",
runtimeVersion: "1.0.0",
available: true,
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
},
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage],
};
}
function fakeRemoteClient(overrides: Partial<MachineClient>): MachineClient {
return {
request: () => { throw new Error("HTTP request not configured for test"); },
requestJson: () => { throw new Error("JSON request not configured for test"); },
connectWebSocket: () => { throw new Error("WebSocket not configured for test"); },
...overrides,
};
}
+3 -25
View File
@@ -1,5 +1,5 @@
import type { Machine, MachineHealth, MachineRuntime, PiWebComponentStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebStatusResponse } from "../../shared/apiTypes.js";
import { isPiWebCapability } from "../../shared/capabilities.js";
import { parsePiWebRuntimeResponse } from "../../shared/piWebStatusParsing.js";
import { getPiWebRuntime } from "../piWebStatus.js";
import { DEFAULT_REMOTE_HEALTH_TIMEOUT_MS, RemoteMachineClient, type MachineClient, validateConfiguredMachineHeaders } from "./machineClient.js";
import { MachineStore, type StoredMachine } from "./machineStore.js";
@@ -151,7 +151,8 @@ export class MachineService {
const checkedAt = this.now().toISOString();
try {
const response = await this.clientFor(machine).requestJson("GET", "/api/pi-web/runtime", undefined, { timeoutMs: DEFAULT_REMOTE_HEALTH_TIMEOUT_MS });
if (response.statusCode >= 200 && response.statusCode < 300 && isPiWebRuntimeResponse(response.body)) return machineRuntime(id, checkedAt, response.body);
const runtime = parsePiWebRuntimeResponse(response.body);
if (response.statusCode >= 200 && response.statusCode < 300 && runtime !== undefined) return machineRuntime(id, checkedAt, runtime);
return { machineId: id, ok: false, checkedAt, error: `Remote runtime returned HTTP ${String(response.statusCode)}` };
} catch (error) {
return { machineId: id, ok: false, checkedAt, error: errorMessage(error) };
@@ -241,16 +242,6 @@ function isPiWebStatusResponse(value: unknown): value is PiWebStatusResponse {
return isPiWebComponentStatus(components["web"]) && isPiWebComponentStatus(components["sessiond"]);
}
function isPiWebRuntimeResponse(value: unknown): value is PiWebRuntimeResponse {
if (!isRecord(value)) return false;
const packageName = value["packageName"];
const generatedAt = value["generatedAt"];
const components = value["components"];
const capabilities = value["capabilities"];
if (typeof packageName !== "string" || typeof generatedAt !== "string" || !isRecord(components) || !isPiWebCapabilityArray(capabilities)) return false;
return isPiWebRuntimeComponent(components["web"]) && isPiWebRuntimeComponent(components["sessiond"]);
}
function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus {
if (!isRecord(value)) return false;
const component = value["component"];
@@ -260,19 +251,6 @@ function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus {
&& typeof value["available"] === "boolean";
}
function isPiWebRuntimeComponent(value: unknown): boolean {
if (!isRecord(value)) return false;
const component = value["component"];
return (component === "web" || component === "sessiond")
&& typeof value["label"] === "string"
&& typeof value["available"] === "boolean"
&& isPiWebCapabilityArray(value["capabilities"]);
}
function isPiWebCapabilityArray(value: unknown): boolean {
return Array.isArray(value) && value.every(isPiWebCapability);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+116
View File
@@ -0,0 +1,116 @@
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PiPackageInfo } from "../shared/apiTypes.js";
import type { PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
let app: FastifyInstance;
let service: PiPackageService;
let serviceMocks: ReturnType<typeof fakePiPackageService>;
beforeEach(async () => {
serviceMocks = fakePiPackageService();
service = serviceMocks.service;
app = Fastify({ logger: false });
registerPiPackageRoutes(app, service);
await app.ready();
});
afterEach(async () => {
await app.close();
});
describe("registerPiPackageRoutes", () => {
it("lists configured Pi packages", async () => {
const response = await app.inject({ method: "GET", url: "/api/pi-packages" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }] });
expect(serviceMocks.list).toHaveBeenCalledOnce();
});
it("registers package routes under a custom API prefix", async () => {
const prefixedApp = Fastify({ logger: false });
const prefixedMocks = fakePiPackageService();
registerPiPackageRoutes(prefixedApp, prefixedMocks.service, "/api/machines/local");
await prefixedApp.ready();
try {
const response = await prefixedApp.inject({ method: "GET", url: "/api/machines/local/pi-packages" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }] });
expect(prefixedMocks.list).toHaveBeenCalledOnce();
} finally {
await prefixedApp.close();
}
});
it("installs a trimmed Pi package source without accepting a scope", async () => {
const response = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: " npm:@acme/new-tools " } });
const scopedResponse = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools", scope: "project" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" });
expect(scopedResponse.statusCode).toBe(400);
expect(scopedResponse.json()).toEqual({ error: "Pi package install scope is not supported; installs use Pi's default package location" });
expect(serviceMocks.install).toHaveBeenCalledOnce();
expect(serviceMocks.install).toHaveBeenCalledWith("npm:@acme/new-tools");
});
it("removes from an explicitly listed package scope", async () => {
const response = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: "../project-tools", scope: "project" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ action: "remove", source: "../project-tools", scope: "project", removed: true });
expect(serviceMocks.remove).toHaveBeenCalledWith("../project-tools", "project");
});
it("updates all packages when source is omitted and one package when source is provided", async () => {
const allResponse = await app.inject({ method: "POST", url: "/api/pi-packages/update" });
const oneResponse = await app.inject({ method: "POST", url: "/api/pi-packages/update", payload: { source: " npm:@acme/tools " } });
expect(allResponse.statusCode).toBe(200);
expect(oneResponse.statusCode).toBe(200);
expect(serviceMocks.update).toHaveBeenNthCalledWith(1);
expect(serviceMocks.update).toHaveBeenNthCalledWith(2, "npm:@acme/tools");
});
it("returns stable 400 errors for invalid requests before calling the service", async () => {
const missingSource = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: {} });
const blankSource = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: " " } });
const invalidScope = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "temporary" } });
const invalidUpdate = await app.inject({ method: "POST", url: "/api/pi-packages/update", payload: { source: "" } });
expect(missingSource.statusCode).toBe(400);
expect(missingSource.json()).toEqual({ error: "Pi package source must be a non-empty string" });
expect(blankSource.statusCode).toBe(400);
expect(blankSource.json()).toEqual({ error: "Pi package source must be a non-empty string" });
expect(invalidScope.statusCode).toBe(400);
expect(invalidScope.json()).toEqual({ error: "Pi package scope must be \"user\" or \"project\"" });
expect(invalidUpdate.statusCode).toBe(400);
expect(invalidUpdate.json()).toEqual({ error: "Pi package source must be a non-empty string" });
expect(serviceMocks.install).not.toHaveBeenCalled();
expect(serviceMocks.remove).not.toHaveBeenCalled();
expect(serviceMocks.update).not.toHaveBeenCalled();
});
it("returns stable 500 errors for package-manager failures", async () => {
serviceMocks.install.mockRejectedValueOnce(new Error("install failed"));
const response = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/fails" } });
expect(response.statusCode).toBe(500);
expect(response.json()).toEqual({ error: "install failed" });
});
});
function fakePiPackageService() {
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
const list = vi.fn<PiPackageService["list"]>(() => Promise.resolve({ packages: [...packages] }));
const install = vi.fn<PiPackageService["install"]>((source) => Promise.resolve({ action: "install", source, packages: [...packages] }));
const remove = vi.fn<PiPackageService["remove"]>((source, scope = "user") => Promise.resolve({ action: "remove", source, scope, removed: true, packages: [...packages] }));
const update = vi.fn<PiPackageService["update"]>((source) => Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages: [...packages] }));
const service: PiPackageService = { list, install, remove, update };
return { service, list, install, remove, update };
}
+92
View File
@@ -0,0 +1,92 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { PiPackageScope } from "../shared/apiTypes.js";
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
class PiPackageRequestValidationError extends Error {}
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService(), prefix = "/api"): void {
const routePrefix = normalizeRoutePrefix(prefix);
app.get(`${routePrefix}/pi-packages`, async (_request, reply) => {
try {
return await service.list();
} catch (error) {
return sendPiPackageError(reply, error);
}
});
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/install`, async (request, reply) => {
try {
return await service.install(parseRequiredSourceRequest(request.body));
} catch (error) {
return sendPiPackageError(reply, error);
}
});
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/remove`, async (request, reply) => {
try {
const body = requireRequestObject(request.body);
return await service.remove(parseRequiredSource(body["source"]), parseOptionalScope(body["scope"]));
} catch (error) {
return sendPiPackageError(reply, error);
}
});
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/update`, async (request, reply) => {
try {
const source = parseOptionalUpdateSource(request.body);
return source === undefined ? await service.update() : await service.update(source);
} catch (error) {
return sendPiPackageError(reply, error);
}
});
}
function normalizeRoutePrefix(prefix: string): string {
const normalized = prefix.replace(/\/+$/u, "");
return normalized === "" ? "/api" : normalized;
}
function parseRequiredSourceRequest(body: unknown): string {
const request = requireRequestObject(body);
if (request["scope"] !== undefined || request["local"] !== undefined) {
throw new PiPackageRequestValidationError("Pi package install scope is not supported; installs use Pi's default package location");
}
return parseRequiredSource(request["source"]);
}
function parseRequiredSource(value: unknown): string {
if (typeof value !== "string" || value.trim() === "") throw new PiPackageRequestValidationError("Pi package source must be a non-empty string");
return value.trim();
}
function parseOptionalUpdateSource(body: unknown): string | undefined {
if (body === undefined) return undefined;
const source = requireRequestObject(body)["source"];
if (source === undefined) return undefined;
return parseRequiredSource(source);
}
function parseOptionalScope(value: unknown): PiPackageScope | undefined {
if (value === undefined) return undefined;
if (value !== "user" && value !== "project") throw new PiPackageRequestValidationError("Pi package scope must be \"user\" or \"project\"");
return value;
}
function requireRequestObject(value: unknown): Record<string, unknown> {
if (!isRecord(value)) throw new PiPackageRequestValidationError("Pi package request body must be an object");
return value;
}
function sendPiPackageError(reply: FastifyReply, error: unknown): FastifyReply {
const status = error instanceof PiPackageRequestValidationError ? 400 : 500;
return reply.code(status).send({ error: errorMessage(error) });
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+203
View File
@@ -0,0 +1,203 @@
import { describe, expect, it, vi } from "vitest";
import type { PiPackageInfo } from "../shared/apiTypes.js";
import { DefaultPiPackageService, type PiPackageManagerPort } from "./piPackageService.js";
function fakeManager(packages: PiPackageInfo[] = []) {
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => packages);
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(() => Promise.resolve());
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>(() => Promise.resolve(true));
const update = vi.fn<PiPackageManagerPort["update"]>(() => Promise.resolve());
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update };
return { manager, listConfiguredPackages, installAndPersist, removeAndPersist, update };
}
function deferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
describe("DefaultPiPackageService", () => {
it("lists configured Pi packages with source, scope, filtered status, and installed path", async () => {
const fake = fakeManager([
{ source: "npm:@acme/user-tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/user-tools" },
{ source: "../project-tools", scope: "project", filtered: true },
]);
const service = new DefaultPiPackageService(fake.manager);
await expect(service.list()).resolves.toEqual({
packages: [
{ source: "npm:@acme/user-tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/user-tools" },
{ source: "../project-tools", scope: "project", filtered: true },
],
});
});
it("installs through the default Pi package-manager behavior without a local option", async () => {
const fake = fakeManager([{ source: "npm:@acme/tools", scope: "user", filtered: false }]);
const service = new DefaultPiPackageService(fake.manager);
const response = await service.install("npm:@acme/tools");
expect(fake.installAndPersist).toHaveBeenCalledWith("npm:@acme/tools");
expect(response).toEqual({ action: "install", source: "npm:@acme/tools", packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }] });
});
it("removes user packages by default and project packages only when the known scope is supplied", async () => {
const fake = fakeManager();
const service = new DefaultPiPackageService(fake.manager);
await service.remove("npm:@acme/user-tools");
await service.remove("../project-tools", "project");
expect(fake.removeAndPersist).toHaveBeenNthCalledWith(1, "npm:@acme/user-tools");
expect(fake.removeAndPersist).toHaveBeenNthCalledWith(2, "../project-tools", { local: true });
});
it("updates all configured packages or a single source", async () => {
const fake = fakeManager();
const service = new DefaultPiPackageService(fake.manager);
await service.update();
await service.update("npm:@acme/tools");
expect(fake.update).toHaveBeenNthCalledWith(1);
expect(fake.update).toHaveBeenNthCalledWith(2, "npm:@acme/tools");
});
it("serializes package mutations in call order and lists after each mutation before starting the next", async () => {
const firstMutation = deferred();
const events: string[] = [];
let packages: PiPackageInfo[] = [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }];
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => {
events.push(`list:${packages.map((configuredPackage) => configuredPackage.source).join(",")}`);
return packages;
});
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(async (source) => {
events.push(`install:start:${source}`);
await firstMutation.promise;
packages = [{ source, scope: "user", filtered: false }];
events.push(`install:finish:${source}`);
});
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>((source) => {
events.push(`remove:start:${source}`);
packages = [];
events.push(`remove:finish:${source}`);
return Promise.resolve(true);
});
const update = vi.fn<PiPackageManagerPort["update"]>(() => Promise.resolve());
const flush = vi.fn<NonNullable<PiPackageManagerPort["flush"]>>(() => {
events.push("flush");
return Promise.resolve();
});
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update, flush };
const service = new DefaultPiPackageService(manager);
const installPromise = service.install("npm:@acme/new-tools");
const removePromise = service.remove("npm:@acme/new-tools");
await Promise.resolve();
expect(installAndPersist).toHaveBeenCalledOnce();
expect(removeAndPersist).not.toHaveBeenCalled();
expect(events).toEqual(["install:start:npm:@acme/new-tools"]);
firstMutation.resolve();
await expect(Promise.all([installPromise, removePromise])).resolves.toEqual([
{ action: "install", source: "npm:@acme/new-tools", packages: [{ source: "npm:@acme/new-tools", scope: "user", filtered: false }] },
{ action: "remove", source: "npm:@acme/new-tools", scope: "user", removed: true, packages: [] },
]);
expect(events).toEqual([
"install:start:npm:@acme/new-tools",
"install:finish:npm:@acme/new-tools",
"flush",
"list:npm:@acme/new-tools",
"remove:start:npm:@acme/new-tools",
"remove:finish:npm:@acme/new-tools",
"flush",
"list:",
]);
});
it("does not queue list requests behind an in-flight mutation", async () => {
const mutation = deferred();
const events: string[] = [];
let packages: PiPackageInfo[] = [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }];
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => {
events.push("list");
return packages;
});
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(async (source) => {
events.push(`install:start:${source}`);
await mutation.promise;
packages = [{ source, scope: "user", filtered: false }];
events.push(`install:finish:${source}`);
});
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>(() => Promise.resolve(true));
const update = vi.fn<PiPackageManagerPort["update"]>(() => Promise.resolve());
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update };
const service = new DefaultPiPackageService(manager);
const installPromise = service.install("npm:@acme/new-tools");
await Promise.resolve();
await expect(service.list()).resolves.toEqual({ packages: [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }] });
expect(events).toEqual(["install:start:npm:@acme/new-tools", "list"]);
mutation.resolve();
await expect(installPromise).resolves.toEqual({
action: "install",
source: "npm:@acme/new-tools",
packages: [{ source: "npm:@acme/new-tools", scope: "user", filtered: false }],
});
});
it("releases the mutation queue after a mutation fails", async () => {
const failingMutation = deferred();
const events: string[] = [];
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false }];
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => {
events.push("list");
return packages;
});
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(async (source) => {
events.push(`install:start:${source}`);
await failingMutation.promise;
});
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>(() => Promise.resolve(true));
const update = vi.fn<PiPackageManagerPort["update"]>((source) => {
events.push(`update:start:${source ?? "all"}`);
return Promise.resolve();
});
const flush = vi.fn<NonNullable<PiPackageManagerPort["flush"]>>(() => {
events.push("flush");
return Promise.resolve();
});
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update, flush };
const service = new DefaultPiPackageService(manager);
const installPromise = service.install("npm:@acme/fails");
const updatePromise = service.update("npm:@acme/tools");
await Promise.resolve();
expect(update).not.toHaveBeenCalled();
expect(events).toEqual(["install:start:npm:@acme/fails"]);
failingMutation.reject(new Error("install failed"));
await expect(installPromise).rejects.toThrow("install failed");
await expect(updatePromise).resolves.toEqual({
action: "update",
source: "npm:@acme/tools",
packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }],
});
expect(events).toEqual([
"install:start:npm:@acme/fails",
"update:start:npm:@acme/tools",
"flush",
"list",
]);
});
});
+97
View File
@@ -0,0 +1,97 @@
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../shared/apiTypes.js";
export interface PiPackageManagerPort {
listConfiguredPackages(): PiPackageInfo[];
installAndPersist(source: string, options?: { local?: boolean }): Promise<void>;
removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean>;
update(source?: string): Promise<void>;
flush?(): Promise<void>;
}
export interface PiPackageService {
list(): Promise<PiPackagesResponse>;
install(source: string): Promise<PiPackageMutationResponse>;
remove(source: string, scope?: PiPackageScope): Promise<PiPackageMutationResponse>;
update(source?: string): Promise<PiPackageMutationResponse>;
}
export class DefaultPiPackageService implements PiPackageService {
private mutationQueue: Promise<void> = Promise.resolve();
constructor(private readonly manager: PiPackageManagerPort) {}
list(): Promise<PiPackagesResponse> {
return Promise.resolve({ packages: this.listPackages() });
}
install(source: string): Promise<PiPackageMutationResponse> {
return this.enqueueMutation(async () => {
await this.manager.installAndPersist(source);
await this.flushSettings();
return this.mutationResponse("install", { source });
});
}
remove(source: string, scope: PiPackageScope = "user"): Promise<PiPackageMutationResponse> {
return this.enqueueMutation(async () => {
const removed = scope === "project"
? await this.manager.removeAndPersist(source, { local: true })
: await this.manager.removeAndPersist(source);
await this.flushSettings();
return this.mutationResponse("remove", { source, scope, removed });
});
}
update(source?: string): Promise<PiPackageMutationResponse> {
return this.enqueueMutation(async () => {
if (source === undefined) {
await this.manager.update();
await this.flushSettings();
return this.mutationResponse("update", {});
}
await this.manager.update(source);
await this.flushSettings();
return this.mutationResponse("update", { source });
});
}
private enqueueMutation<T>(operation: () => Promise<T>): Promise<T> {
const queuedMutation = this.mutationQueue.then(operation);
this.mutationQueue = queuedMutation.then(
() => undefined,
() => undefined,
);
return queuedMutation;
}
private mutationResponse(action: PiPackageMutationAction, metadata: Omit<PiPackageMutationResponse, "action" | "packages">): PiPackageMutationResponse {
return { action, ...metadata, packages: this.listPackages() };
}
private async flushSettings(): Promise<void> {
await this.manager.flush?.();
}
private listPackages(): PiPackageInfo[] {
return this.manager.listConfiguredPackages().map((configuredPackage) => ({
source: configuredPackage.source,
scope: configuredPackage.scope,
filtered: configuredPackage.filtered,
...(configuredPackage.installedPath === undefined ? {} : { installedPath: configuredPackage.installedPath }),
}));
}
}
export function createDefaultPiPackageService(cwd = process.cwd(), agentDir = getAgentDir()): PiPackageService {
const settingsManager = SettingsManager.create(cwd, agentDir);
const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
return new DefaultPiPackageService({
listConfiguredPackages: () => manager.listConfiguredPackages(),
installAndPersist: (source, options) => manager.installAndPersist(source, options),
removeAndPersist: (source, options) => manager.removeAndPersist(source, options),
update: (source) => manager.update(source),
flush: () => settingsManager.flush(),
});
}
+120 -9
View File
@@ -6,11 +6,20 @@ import { PiWebPluginService, type PiPackageProvider } from "./piWebPluginService
let tempDir: string;
const originalDockerRuntime = process.env["PI_WEB_DOCKER_RUNTIME"];
const originalDockerMode = process.env["PI_WEB_DOCKER_MODE"];
const originalDockerDevRepoRoot = process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"];
const originalDockerInstallDir = process.env["PI_WEB_DOCKER_INSTALL_DIR"];
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-plugin-service-test-"));
});
afterEach(async () => {
restoreEnv("PI_WEB_DOCKER_RUNTIME", originalDockerRuntime);
restoreEnv("PI_WEB_DOCKER_MODE", originalDockerMode);
restoreEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", originalDockerDevRepoRoot);
restoreEnv("PI_WEB_DOCKER_INSTALL_DIR", originalDockerInstallDir);
await rm(tempDir, { recursive: true, force: true });
});
@@ -28,13 +37,54 @@ describe("PiWebPluginService", () => {
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })],
});
const manifest = await service.manifest();
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/info\/pi-web-plugin\.js\?v=\d+$/u);
const module = manifest.plugins[0]?.module;
expect(module).toMatch(/^\/pi-web-plugins\/info\/pi-web-plugin\.js\?v=\d+$/u);
expect(new URL(module ?? "", "http://old-gateway.test/pi-web-plugins/info/").pathname).toBe("/pi-web-plugins/info/pi-web-plugin.js");
await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ module }] });
const asset = await service.readAsset("info", "pi-web-plugin.js");
expect(asset?.contentType).toBe("application/javascript; charset=utf-8");
expect(asset?.content.toString("utf8")).toContain("export default");
});
it("preserves content types for extension-only asset names", async () => {
const pluginDir = join(tempDir, "plugins", "extension-only");
await writePlugin(pluginDir, {
packageJson: { piWeb: { plugins: [{ id: "extension-only", module: ".js" }] } },
files: {
".js": "export default {};",
".svg": '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
},
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
await expect(service.readAsset("extension-only", ".js")).resolves.toMatchObject({ contentType: "application/javascript; charset=utf-8" });
await expect(service.readAsset("extension-only", ".svg")).resolves.toMatchObject({ contentType: "image/svg+xml" });
});
it("serves nested SVG assets with a browser-compatible content type", async () => {
const pluginDir = join(tempDir, "plugins", "icons");
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>';
await writePlugin(pluginDir, {
packageJson: { piWeb: { plugins: [{ id: "icons", module: "pi-web-plugin.js" }] } },
files: {
"pi-web-plugin.js": "export default {};",
"assets/icon.svg": svg,
"assets/uppercase.SVG": svg,
"assets/data.bin": "unknown",
},
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
const svgAsset = await service.readAsset("icons", "assets/icon.svg");
expect(svgAsset?.contentType).toBe("image/svg+xml");
expect(svgAsset?.content.toString("utf8")).toBe(svg);
await expect(service.readAsset("icons", "assets/uppercase.SVG")).resolves.toMatchObject({ contentType: "image/svg+xml" });
await expect(service.readAsset("icons", "assets/data.bin")).resolves.toMatchObject({ contentType: "application/octet-stream" });
});
it("includes machine-specific preferences in plugin manifests", async () => {
await writePlugin(join(tempDir, "plugins", "updates"), {
packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } },
@@ -47,6 +97,23 @@ describe("PiWebPluginService", () => {
await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true, enabled: true }] });
});
it("adds Docker runtime hints to the Updates plugin module URL", async () => {
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
process.env["PI_WEB_DOCKER_MODE"] = "dev";
await writePlugin(join(tempDir, "plugins", "updates"), {
packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
const manifest = await service.manifest();
const moduleUrl = new URL(manifest.plugins[0]?.module ?? "", "http://pi-web.test/pi-web-plugins/manifest.json");
expect(moduleUrl.pathname).toBe("/pi-web-plugins/updates/pi-web-plugin.js");
expect(moduleUrl.searchParams.get("v")).toMatch(/^\d+$/u);
expect(moduleUrl.searchParams.get("piWebDockerMode")).toBe("dev");
});
it("discovers Pi package plugins through an injected package provider", async () => {
const packageDir = join(tempDir, "pkg");
await writePlugin(packageDir, {
@@ -87,6 +154,29 @@ describe("PiWebPluginService", () => {
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "agent-package", source: packageDir, scope: "user" }] });
});
it("refreshes Pi package plugin discovery after Pi package settings change", async () => {
const agentDir = join(tempDir, "agent");
const firstPackageDir = join(tempDir, "first-package");
const secondPackageDir = join(tempDir, "second-package");
await writePlugin(firstPackageDir, {
packageJson: { piWeb: { plugins: [{ id: "first", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await writePlugin(secondPackageDir, {
packageJson: { piWeb: { plugins: [{ id: "second", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await writePiPackageSettings(agentDir, [firstPackageDir]);
const service = new PiWebPluginService({ roots: [], cwd: tempDir, agentDir });
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "first" }] });
await writePiPackageSettings(agentDir, [secondPackageDir]);
const manifest = await service.manifest();
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["second"]);
});
it("discovers source checkout plugin packages without symlinks", async () => {
await mkdir(join(tempDir, "src", "server"), { recursive: true });
await writeFile(join(tempDir, "src", "server", "index.ts"), "export {};\n");
@@ -147,19 +237,30 @@ describe("PiWebPluginService", () => {
});
it("skips duplicate plugin ids", async () => {
await writePlugin(join(tempDir, "plugins", "one"), {
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
const firstRoot = join(tempDir, "first-root");
const secondRoot = join(tempDir, "second-root");
await writePlugin(join(firstRoot, "duplicate"), {
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "first.js" }] } },
files: { "first.js": "export default {};" },
});
await writePlugin(join(tempDir, "plugins", "two"), {
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
await writePlugin(join(secondRoot, "duplicate"), {
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "second.js", machineSpecific: true }] } },
files: { "second.js": "export default {};" },
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
const service = new PiWebPluginService({
roots: [
{ path: firstRoot, source: "first", scope: "local" },
{ path: secondRoot, source: "second", scope: "local" },
],
packageProvider: false,
});
const manifest = await service.manifest();
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate"]);
expect(manifest.plugins).toEqual([
expect.objectContaining({ id: "duplicate", source: "first", machineSpecific: false }),
]);
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/duplicate\/first\.js\?v=\d+$/u);
});
it("skips legacy metadata shortcuts and unsafe module paths", async () => {
@@ -210,6 +311,11 @@ describe("PiWebPluginService", () => {
});
});
async function writePiPackageSettings(agentDir: string, packages: string[]): Promise<void> {
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages }, null, 2)}\n`);
}
async function writePlugin(root: string, options: { packageJson: unknown; files: Record<string, string> }): Promise<void> {
await mkdir(root, { recursive: true });
await writeFile(join(root, "package.json"), `${JSON.stringify(options.packageJson, null, 2)}\n`);
@@ -219,3 +325,8 @@ async function writePlugin(root: string, options: { packageJson: unknown; files:
await writeFile(filePath, content);
}
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) Reflect.deleteProperty(process.env, key);
else process.env[key] = value;
}
+50 -17
View File
@@ -70,23 +70,25 @@ interface PiWebPluginEntry {
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
export class DefaultPiPackageProvider implements PiPackageProvider {
private readonly packageManager: DefaultPackageManager;
constructor(cwd = process.cwd(), agentDir?: string) {
const resolvedAgentDir = agentDir ?? defaultAgentDirForCwd(cwd);
this.packageManager = new DefaultPackageManager({
cwd,
agentDir: resolvedAgentDir,
settingsManager: SettingsManager.create(cwd, resolvedAgentDir),
});
}
constructor(
private readonly cwd = process.cwd(),
private readonly agentDir = defaultAgentDirForCwd(cwd),
) {}
listPackages(): ConfiguredPiPackage[] {
return this.packageManager.listConfiguredPackages();
return this.createPackageManager().listConfiguredPackages();
}
getInstalledPath(source: string, scope: "user" | "project"): string | undefined {
return this.packageManager.getInstalledPath(source, scope);
return this.createPackageManager().getInstalledPath(source, scope);
}
private createPackageManager(): DefaultPackageManager {
return new DefaultPackageManager({
cwd: this.cwd,
agentDir: this.agentDir,
settingsManager: SettingsManager.create(this.cwd, this.agentDir),
});
}
}
@@ -154,7 +156,7 @@ export class PiWebPluginService {
private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo {
return {
id: plugin.id,
module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`,
module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?${pluginModuleQuery(plugin)}`,
source: plugin.source,
scope: plugin.scope,
machineSpecific: plugin.machineSpecific,
@@ -219,6 +221,35 @@ function bundledPluginRoot(packageRoot: string): string {
return join(packageRoot, "dist", "pi-web-plugins");
}
function pluginModuleQuery(plugin: PluginRecord): string {
const params = new URLSearchParams({ v: plugin.version });
const dockerMode = plugin.id === "updates" ? dockerModeFromEnv() : undefined;
if (dockerMode !== undefined) params.set("piWebDockerMode", dockerMode);
return params.toString();
}
function dockerModeFromEnv(): "runtime" | "dev" | undefined {
if (!isTruthyEnv("PI_WEB_DOCKER_RUNTIME")) return undefined;
const mode = process.env["PI_WEB_DOCKER_MODE"];
if (mode === "runtime" || mode === "dev") return mode;
if (firstNonEmptyEnv("PI_WEB_DOCKER_DEV_REPO_ROOT") !== undefined) return "dev";
if (firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR") !== undefined) return "runtime";
return undefined;
}
function firstNonEmptyEnv(...keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key];
if (value !== undefined && value !== "") return value;
}
return undefined;
}
function isTruthyEnv(key: string): boolean {
const value = process.env[key];
return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
}
function sourceCheckoutPluginRoots(cwd: string): LocalPluginRoot[] {
const pluginsRoot = join(cwd, "plugins");
if (!existsSync(join(cwd, "src", "server", "index.ts")) || !existsSync(pluginsRoot)) return [];
@@ -338,10 +369,12 @@ function isWithin(root: string, candidate: string): boolean {
}
function contentTypeFor(path: string): string {
if (path.endsWith(".js")) return "application/javascript; charset=utf-8";
if (path.endsWith(".json")) return "application/json; charset=utf-8";
if (path.endsWith(".css")) return "text/css; charset=utf-8";
if (path.endsWith(".html")) return "text/html; charset=utf-8";
const lowerPath = path.toLowerCase();
if (lowerPath.endsWith(".js")) return "application/javascript; charset=utf-8";
if (lowerPath.endsWith(".json")) return "application/json; charset=utf-8";
if (lowerPath.endsWith(".css")) return "text/css; charset=utf-8";
if (lowerPath.endsWith(".html")) return "text/html; charset=utf-8";
if (lowerPath.endsWith(".svg")) return "image/svg+xml";
return "application/octet-stream";
}
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { createPiWebReleaseLookupCache } from "./piWebReleaseLookupCache.js";
describe("createPiWebReleaseLookupCache", () => {
it("serves a fresh cached release lookup", async () => {
let now = 1_000;
const load = vi.fn(() => Promise.resolve("1.0.0"));
const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now });
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 });
now = 1_050;
await expect(cache.get("0.9.1")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 });
expect(load).toHaveBeenCalledOnce();
expect(load).toHaveBeenCalledWith("0.9.0");
});
it("bypasses a fresh lookup when forced", async () => {
let now = 1_000;
const load = vi.fn()
.mockResolvedValueOnce("1.0.0")
.mockResolvedValueOnce("1.1.0");
const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now });
await cache.get("0.9.0");
now = 1_050;
await expect(cache.get("0.9.0", { force: true })).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 });
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 });
expect(load).toHaveBeenCalledTimes(2);
});
it.each(["forced-first", "regular-first"] as const)("does not let an older regular lookup replace a forced result when %s completes", async (completionOrder) => {
const regular = createDeferred<string>();
const forced = createDeferred<string>();
const load = vi.fn()
.mockImplementationOnce(() => regular.promise)
.mockImplementationOnce(() => forced.promise);
const cache = createPiWebReleaseLookupCache(load);
const regularLookup = cache.get("0.9.0");
const forcedLookup = cache.get("0.9.0", { force: true });
if (completionOrder === "forced-first") {
forced.resolve("2.0.0");
await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
regular.resolve("1.0.0");
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" });
} else {
regular.resolve("1.0.0");
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" });
forced.resolve("2.0.0");
await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
}
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "2.0.0" });
expect(load).toHaveBeenCalledTimes(2);
});
it("makes regular callers join a pending forced lookup", async () => {
const forced = createDeferred<string>();
const load = vi.fn(() => forced.promise);
const cache = createPiWebReleaseLookupCache(load);
const forcedLookup = cache.get("0.9.0", { force: true });
const regularLookup = cache.get("0.9.0");
expect(regularLookup).toBe(forcedLookup);
forced.resolve("2.0.0");
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
expect(load).toHaveBeenCalledOnce();
});
});
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
+57
View File
@@ -0,0 +1,57 @@
const DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
export interface PiWebReleaseLookup {
checkedAtMs: number;
latestVersion?: string;
error?: string;
}
export interface PiWebReleaseLookupCacheOptions {
ttlMs?: number;
now?: () => number;
}
export interface PiWebReleaseLookupOptions {
force?: boolean;
}
export interface PiWebReleaseLookupCache {
get(currentVersion: string, options?: PiWebReleaseLookupOptions): Promise<PiWebReleaseLookup>;
}
export function createPiWebReleaseLookupCache(
load: (currentVersion: string) => Promise<string>,
options: PiWebReleaseLookupCacheOptions = {},
): PiWebReleaseLookupCache {
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS;
const now = options.now ?? Date.now;
let cached: PiWebReleaseLookup | undefined;
let pending: { promise: Promise<PiWebReleaseLookup>; force: boolean; sequence: number } | undefined;
let loadSequence = 0;
return {
get(currentVersion: string, lookupOptions: PiWebReleaseLookupOptions = {}): Promise<PiWebReleaseLookup> {
const force = lookupOptions.force === true;
if (pending?.force === true) return pending.promise;
const checkedAtMs = now();
if (!force && cached !== undefined && checkedAtMs - cached.checkedAtMs < ttlMs) return Promise.resolve(cached);
if (!force && pending !== undefined) return pending.promise;
const sequence = ++loadSequence;
const promise = Promise.resolve()
.then(() => load(currentVersion))
.then((latestVersion): PiWebReleaseLookup => ({ checkedAtMs, latestVersion }))
.catch((error: unknown): PiWebReleaseLookup => ({ checkedAtMs, error: error instanceof Error ? error.message : String(error) }))
.then((lookup) => {
if (sequence === loadSequence) cached = lookup;
return lookup;
})
.finally(() => {
if (pending?.sequence === sequence) pending = undefined;
});
pending = { promise, force, sequence };
return promise;
},
};
}
+143 -6
View File
@@ -1,17 +1,28 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { comparePackageVersions, getPiWebStatus, getPiWebVersionStatus, updateCommandFor } from "./piWebStatus.js";
import { comparePackageVersions, getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus, updateCommandFor } from "./piWebStatus.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
import type { PiWebComponentStatus, PiWebRuntimeComponent } from "../shared/apiTypes.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
const originalHome = process.env["HOME"];
const originalPath = process.env["PATH"];
const originalDockerRuntime = process.env["PI_WEB_DOCKER_RUNTIME"];
const originalDockerMode = process.env["PI_WEB_DOCKER_MODE"];
const originalDockerInstallDir = process.env["PI_WEB_DOCKER_INSTALL_DIR"];
const originalDockerDevRepoRoot = process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"];
afterEach(() => {
restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck);
restoreEnv("HOME", originalHome);
restoreEnv("PATH", originalPath);
restoreEnv("PI_WEB_DOCKER_RUNTIME", originalDockerRuntime);
restoreEnv("PI_WEB_DOCKER_MODE", originalDockerMode);
restoreEnv("PI_WEB_DOCKER_INSTALL_DIR", originalDockerInstallDir);
restoreEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", originalDockerDevRepoRoot);
vi.restoreAllMocks();
});
@@ -41,6 +52,7 @@ describe("PI WEB status", () => {
});
it("detects session daemon package installs from the configured agent dir for runtime responses", async () => {
disableDockerRuntimeEnv();
const agentDir = await tempHome();
try {
await installConfiguredPiWebPackage(agentDir);
@@ -60,8 +72,54 @@ describe("PI WEB status", () => {
}
});
it("reports web-only capabilities from the web runtime", async () => {
const daemon = daemonWithComponent({
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202605.7",
installedVersion: "1.202605.8",
stale: true,
available: true,
});
const runtime = await getPiWebRuntime(daemon);
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
});
it("bypasses cached npm release data for a forced check", async () => {
Reflect.deleteProperty(process.env, "PI_WEB_SKIP_VERSION_CHECK");
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
process.env["PI_WEB_DOCKER_MODE"] = "runtime";
const fetchMock = vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(npmVersionResponse("1.202607.1"))
.mockResolvedValueOnce(npmVersionResponse("1.202607.2"));
const daemon = daemonWithComponent({
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202607.0",
installedVersion: "1.202607.0",
stale: false,
available: true,
installation: { kind: "docker", dockerMode: "runtime" },
});
const first = await getPiWebStatus(daemon, { forceReleaseCheck: true });
const cached = await getPiWebStatus(daemon);
const forced = await getPiWebStatus(daemon, { forceReleaseCheck: true });
expect(first.release.latestVersion).toBe("1.202607.1");
expect(cached.release.latestVersion).toBe("1.202607.1");
expect(forced.release.latestVersion).toBe("1.202607.2");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("reports stale session daemon versions as messages", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
disableDockerRuntimeEnv();
const daemon = daemonWithComponent({
component: "sessiond",
label: "Session daemon",
@@ -72,7 +130,7 @@ describe("PI WEB status", () => {
installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
});
const status = await getPiWebStatus(daemon);
const status = await getPiWebStatus(daemon, { forceReleaseCheck: true });
expect(status.release.skipped).toBe(true);
expect(status.components.sessiond.stale).toBe(true);
@@ -93,12 +151,15 @@ describe("PI WEB status", () => {
expect(updateCommand).toBe("'/tmp/agent'\\''s/alt-agent' update 'npm:@jmfederico/pi-web' && pi-web restart");
});
it("suggests native systemd commands for local development services", async () => {
if (process.platform !== "linux") return;
it.skipIf(process.platform !== "linux")("suggests native systemd commands for local development services", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
disableDockerRuntimeEnv();
const home = await tempHome();
const binDir = await tempHome();
try {
process.env["HOME"] = home;
await installExecutable(binDir, "systemctl");
process.env["PATH"] = `${binDir}:${process.env["PATH"] ?? ""}`;
await installSystemdServiceFiles(home, ["pi-web-sessiond.service", "pi-web-ui-dev.service"]);
const daemon = daemonWithComponent(staleLocalSessiond());
@@ -109,12 +170,72 @@ describe("PI WEB status", () => {
expect(status.commands.restartSessiond).toBe("systemd-run --user --collect --unit=pi-web-restart-sessiond -- systemctl --user restart pi-web-sessiond.service");
expect(status.messages.find((message) => message.id === "sessiond-stale")?.command).toBe("systemd-run --user --collect --unit=pi-web-restart-sessiond -- systemctl --user restart pi-web-sessiond.service");
} finally {
await rm(home, { recursive: true, force: true });
await Promise.all([
rm(home, { recursive: true, force: true }),
rm(binDir, { recursive: true, force: true }),
]);
}
});
it("suggests Docker commands when running inside the Docker runtime", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
process.env["PI_WEB_DOCKER_MODE"] = "runtime";
process.env["PI_WEB_DOCKER_INSTALL_DIR"] = "/srv/pi-web-docker";
process.env["PATH"] = "";
const daemon = daemonWithComponent({ ...staleLocalSessiond(), installation: { kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" } });
const status = await getPiWebStatus(daemon);
expect(status.components.web.installation).toEqual({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" });
expect(status.commands).toEqual({
update: "pi-web-docker update",
restart: "pi-web-docker restart",
restartWeb: "pi-web-docker restart-web",
restartSessiond: "pi-web-docker restart-sessiond",
status: "pi-web-docker status",
});
expect(JSON.stringify(status)).not.toContain("npm install -g");
expect(JSON.stringify(status)).not.toContain("pi-web restart");
});
it("suggests explicit Docker development commands when running inside the Docker dev runtime", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
process.env["PI_WEB_DOCKER_MODE"] = "dev";
process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"] = "/workspace/pi-web";
process.env["PATH"] = "";
const daemon = daemonWithComponent({ ...staleLocalSessiond(), installation: { kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" } });
const status = await getPiWebStatus(daemon);
expect(status.commands).toEqual({
update: "pi-web-docker --dev update",
restart: "pi-web-docker --dev restart",
restartWeb: "pi-web-docker --dev restart-web",
restartSessiond: "pi-web-docker --dev restart-sessiond",
status: "pi-web-docker --dev status",
});
});
it("infers explicit Docker development commands from the generated dev root when mode is omitted", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_MODE");
process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"] = "/workspace/pi-web";
process.env["PATH"] = "";
const daemon = daemonWithComponent(staleLocalSessiond());
const status = await getPiWebStatus(daemon);
expect(status.components.web.installation).toEqual({ kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" });
expect(status.commands.update).toBe("pi-web-docker --dev update");
expect(status.commands.status).toBe("pi-web-docker --dev status");
});
it("omits local restart commands when no native service command is known", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
disableDockerRuntimeEnv();
const home = await tempHome();
try {
process.env["HOME"] = home;
@@ -132,6 +253,10 @@ describe("PI WEB status", () => {
});
});
function npmVersionResponse(version: string): Response {
return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } });
}
function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClient {
const daemon = new SessionDaemonClient();
vi.spyOn(daemon, "request").mockResolvedValue({
@@ -178,6 +303,18 @@ async function installConfiguredPiWebPackage(agentDir: string): Promise<void> {
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8");
}
async function installExecutable(dir: string, name: string): Promise<void> {
const path = join(dir, name);
await writeFile(path, "#!/usr/bin/env sh\nexit 0\n");
await chmod(path, 0o755);
}
function disableDockerRuntimeEnv(): void {
process.env["PI_WEB_DOCKER_RUNTIME"] = "0";
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_MODE");
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_INSTALL_DIR");
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_DEV_REPO_ROOT");
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) Reflect.deleteProperty(process.env, key);
+67 -19
View File
@@ -8,14 +8,15 @@ import { fileURLToPath } from "node:url";
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js";
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
import { effectiveAgentConfig } from "../config.js";
import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`;
const DEFAULT_VERSION = "0.0.0-dev";
const LATEST_RELEASE_CACHE_MS = 6 * 60 * 60 * 1000;
const VERSION_CHECK_TIMEOUT_MS = 5000;
type ServiceId = "sessiond" | "web" | "uiDev";
@@ -74,7 +75,8 @@ interface PiWebStatusDaemon {
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
}
interface PiWebStatusOptions {
export interface PiWebStatusOptions {
forceReleaseCheck?: boolean;
agentCommand?: string;
agentDir?: string;
hasCommand?: (command: string) => Promise<boolean>;
@@ -90,8 +92,7 @@ function effectiveStatusAgentConfig(options: PiWebStatusOptions): { command: str
return { command: agent.command, dir: agent.dir };
}
let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined;
const latestReleaseLookupCache = createPiWebReleaseLookupCache(fetchLatestNpmVersion);
const runtimePackageInfo = readPackageInfoSync();
export function getPiWebRuntimeComponent(component: PiWebServiceComponent, capabilities: readonly PiWebCapability[] = []): PiWebRuntimeComponent {
@@ -149,7 +150,7 @@ export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaem
const agent = effectiveStatusAgentConfig(options);
const versionStatus = await getPiWebVersionStatus(daemon, { ...options, agentDir: agent.dir });
const { web, sessiond } = versionStatus.components;
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true);
const components = { web, sessiond };
const commands = await commandsFor(components, { agentCommand: agent.command, hasCommand: options.hasCommand ?? hasCommand });
const messages = buildMessages(components, release, commands);
@@ -205,16 +206,59 @@ function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined
return { name, version, path };
}
async function detectPiWebInstallation(agentDir = effectiveAgentConfig().dir): Promise<PiWebInstallationInfo> {
async function detectPiWebInstallation(agentDir?: string): Promise<PiWebInstallationInfo> {
const docker = detectDockerInstallation();
if (docker !== undefined) return docker;
const resolvedAgentDir = agentDir ?? effectiveAgentConfig().dir;
const root = packageRootPath();
const realRoot = await realPathOrSelf(root);
const piPackage = await detectPiPackageInstallation(realRoot, root, agentDir);
const piPackage = await detectPiPackageInstallation(realRoot, root, resolvedAgentDir);
if (piPackage !== undefined) return piPackage;
const npmGlobal = await detectNpmGlobalInstallation(realRoot, root);
if (npmGlobal !== undefined) return npmGlobal;
return { kind: "local", path: root };
}
function detectDockerInstallation(): PiWebInstallationInfo | undefined {
if (!isTruthyEnv("PI_WEB_DOCKER_RUNTIME")) return undefined;
const dockerMode = dockerModeFromEnv(process.env["PI_WEB_DOCKER_MODE"]) ?? inferredDockerModeFromRoots();
const path = dockerRootPathFromEnv(dockerMode);
return {
kind: "docker",
...(path === undefined ? {} : { path }),
...(dockerMode === undefined ? {} : { dockerMode }),
};
}
function dockerModeFromEnv(value: string | undefined): PiWebInstallationInfo["dockerMode"] | undefined {
return value === "runtime" || value === "dev" ? value : undefined;
}
function inferredDockerModeFromRoots(): PiWebInstallationInfo["dockerMode"] | undefined {
if (firstNonEmptyEnv("PI_WEB_DOCKER_DEV_REPO_ROOT") !== undefined) return "dev";
if (firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR") !== undefined) return "runtime";
return undefined;
}
function dockerRootPathFromEnv(mode: PiWebInstallationInfo["dockerMode"] | undefined): string | undefined {
if (mode === "dev") return firstNonEmptyEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", "PI_WEB_DOCKER_INSTALL_DIR");
if (mode === "runtime") return firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR", "PI_WEB_DOCKER_DEV_REPO_ROOT");
return firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR", "PI_WEB_DOCKER_DEV_REPO_ROOT");
}
function firstNonEmptyEnv(...keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key];
if (value !== undefined && value !== "") return value;
}
return undefined;
}
function isTruthyEnv(key: string): boolean {
const value = process.env[key];
return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
}
async function detectPiPackageInstallation(realRoot: string, displayPath: string, agentDir: string): Promise<PiWebInstallationInfo | undefined> {
try {
const packageManager = new DefaultPackageManager({
@@ -349,25 +393,16 @@ function unavailableSessiond(error: string): PiWebComponentStatus {
};
}
async function getLatestReleaseStatus(currentVersion: string): Promise<PiWebReleaseStatus> {
async function getLatestReleaseStatus(currentVersion: string, force: boolean): Promise<PiWebReleaseStatus> {
const checkedAtMs = Date.now();
if (skipVersionCheck()) {
return { packageName: PI_WEB_PACKAGE_NAME, updateAvailable: false, checkedAt: new Date(checkedAtMs).toISOString(), skipped: true };
}
if (latestReleaseCache !== undefined && checkedAtMs - latestReleaseCache.checkedAtMs < LATEST_RELEASE_CACHE_MS) {
return releaseStatusFromCache(latestReleaseCache, currentVersion);
}
try {
latestReleaseCache = { checkedAtMs, latestVersion: await fetchLatestNpmVersion(currentVersion) };
} catch (error) {
latestReleaseCache = { checkedAtMs, error: error instanceof Error ? error.message : String(error) };
}
return releaseStatusFromCache(latestReleaseCache, currentVersion);
return releaseStatusFromCache(await latestReleaseLookupCache.get(currentVersion, { force }), currentVersion);
}
function releaseStatusFromCache(cache: { checkedAtMs: number; latestVersion?: string; error?: string }, currentVersion: string): PiWebReleaseStatus {
function releaseStatusFromCache(cache: PiWebReleaseLookup, currentVersion: string): PiWebReleaseStatus {
return {
packageName: PI_WEB_PACKAGE_NAME,
...(cache.latestVersion === undefined ? {} : { latestVersion: cache.latestVersion }),
@@ -394,6 +429,8 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
async function commandsFor(components: PiWebStatusResponse["components"], options: { agentCommand: string; hasCommand: (command: string) => Promise<boolean> }): Promise<PiWebStatusResponse["commands"]> {
const installation = preferredInstallation(components);
if (installation?.kind === "docker") return dockerCommands(installation);
const [serviceCommands, cliCommands] = await Promise.all([
nativeServiceCommands(),
piWebCliCommands(installation),
@@ -416,10 +453,21 @@ async function commandsFor(components: PiWebStatusResponse["components"], option
function preferredInstallation(components: PiWebStatusResponse["components"]): PiWebInstallationInfo | undefined {
const web = components.web.installation;
const sessiond = components.sessiond.installation;
if (web?.kind === "docker" || sessiond?.kind === "docker") return web?.kind === "docker" ? web : sessiond;
if (web?.kind === "local" || sessiond?.kind === "local") return web?.kind === "local" ? web : sessiond;
return web ?? sessiond;
}
function dockerCommands(installation: PiWebInstallationInfo): PiWebStatusResponse["commands"] {
return {
update: piWebDockerCommand(installation.dockerMode, "update"),
restart: piWebDockerCommand(installation.dockerMode, "restart"),
restartWeb: piWebDockerCommand(installation.dockerMode, "restart-web"),
restartSessiond: piWebDockerCommand(installation.dockerMode, "restart-sessiond"),
status: piWebDockerCommand(installation.dockerMode, "status"),
};
}
async function piWebCliCommands(installation: PiWebInstallationInfo | undefined): Promise<NativeServiceCommands> {
if (installation?.kind !== "npm-global" || !(await hasCommand("pi-web"))) return {};
return { restart: "pi-web restart", status: "pi-web status" };
+119 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
import { createPiWebStatusCache, type PiWebStatusCacheLoadOptions } from "./piWebStatusCache.js";
describe("createPiWebStatusCache", () => {
it("serves cached status while it is fresh", async () => {
@@ -31,6 +31,124 @@ describe("createPiWebStatusCache", () => {
expect(load).toHaveBeenCalledTimes(2);
});
it("explicitly refreshes and replaces a fresh cached status", async () => {
let now = 1_000;
const load = vi.fn()
.mockResolvedValueOnce(status("first"))
.mockResolvedValueOnce(status("second"));
const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now });
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
now = 1_050;
await expect(cache.refresh()).resolves.toMatchObject({ generatedAt: "second" });
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" });
expect(load).toHaveBeenCalledTimes(2);
});
it.each(["forced-first", "regular-first"] as const)("does not let an older refresh replace a forced result when %s completes", async (completionOrder) => {
const regular = createDeferred<PiWebStatusResponse>();
const forced = createDeferred<PiWebStatusResponse>();
const load = vi.fn(({ force }: PiWebStatusCacheLoadOptions) => force ? forced.promise : regular.promise);
const cache = createPiWebStatusCache(load);
const regularRefresh = cache.refresh();
const forcedRefresh = cache.refresh({ force: true });
if (completionOrder === "forced-first") {
forced.resolve(status("forced"));
await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" });
regular.resolve(status("regular"));
await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" });
} else {
regular.resolve(status("regular"));
await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" });
forced.resolve(status("forced"));
await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" });
}
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "forced" });
expect(load).toHaveBeenNthCalledWith(1, { force: false });
expect(load).toHaveBeenNthCalledWith(2, { force: true });
});
it("makes regular refreshes join a pending forced refresh", async () => {
const deferred = createDeferred<PiWebStatusResponse>();
const load = vi.fn(() => deferred.promise);
const cache = createPiWebStatusCache(load);
const forced = cache.refresh({ force: true });
const regular = cache.refresh();
expect(regular).toBe(forced);
deferred.resolve(status("forced"));
await forced;
expect(load).toHaveBeenCalledOnce();
});
it("retains stale status and reports background refresh errors", async () => {
let now = 1_000;
const refreshError = new Error("refresh failed");
const errorReported = createDeferred<unknown>();
const onError = vi.fn((error: unknown) => {
errorReported.resolve(error);
});
const load = vi.fn()
.mockResolvedValueOnce(status("first"))
.mockRejectedValueOnce(refreshError)
.mockResolvedValueOnce(status("second"));
const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now, onError });
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
now = 1_101;
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
await expect(errorReported.promise).resolves.toBe(refreshError);
expect(onError).toHaveBeenCalledTimes(1);
expect(load).toHaveBeenCalledTimes(2);
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
await waitForMicrotasks();
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" });
expect(load).toHaveBeenCalledTimes(3);
});
it("loads again after a fresh cache is invalidated", async () => {
const load = vi.fn()
.mockResolvedValueOnce(status("first"))
.mockResolvedValueOnce(status("second"));
const cache = createPiWebStatusCache(load);
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
cache.invalidate();
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" });
expect(load).toHaveBeenCalledTimes(2);
});
it("abandons an in-flight load when invalidated", async () => {
const firstLoad = createDeferred<PiWebStatusResponse>();
const secondLoad = createDeferred<PiWebStatusResponse>();
const load = vi.fn()
.mockImplementationOnce(() => firstLoad.promise)
.mockImplementationOnce(() => secondLoad.promise);
const cache = createPiWebStatusCache(load);
const first = cache.get();
await waitForMicrotasks();
cache.invalidate();
const second = cache.get();
await waitForMicrotasks();
secondLoad.resolve(status("second"));
await expect(second).resolves.toMatchObject({ generatedAt: "second" });
firstLoad.resolve(status("first"));
await expect(first).resolves.toMatchObject({ generatedAt: "first" });
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" });
expect(load).toHaveBeenCalledTimes(2);
});
it("deduplicates concurrent cold loads", async () => {
const deferred = createDeferred<PiWebStatusResponse>();
const load = vi.fn(() => deferred.promise);
+25 -9
View File
@@ -8,29 +8,43 @@ export interface PiWebStatusCacheOptions {
onError?: (error: unknown) => void;
}
export interface PiWebStatusCacheLoadOptions {
force: boolean;
}
export interface PiWebStatusCacheRefreshOptions {
force?: boolean;
}
export interface PiWebStatusCache {
get(): Promise<PiWebStatusResponse>;
refresh(): Promise<PiWebStatusResponse>;
refresh(options?: PiWebStatusCacheRefreshOptions): Promise<PiWebStatusResponse>;
invalidate(): void;
}
export function createPiWebStatusCache(load: () => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
export function createPiWebStatusCache(load: (options: PiWebStatusCacheLoadOptions) => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS;
const now = options.now ?? Date.now;
let cached: { status: PiWebStatusResponse; expiresAt: number } | undefined;
let pending: Promise<PiWebStatusResponse> | undefined;
let pending: { promise: Promise<PiWebStatusResponse>; force: boolean; sequence: number } | undefined;
let loadSequence = 0;
const refresh = (): Promise<PiWebStatusResponse> => {
pending ??= Promise.resolve()
.then(load)
const refresh = (refreshOptions: PiWebStatusCacheRefreshOptions = {}): Promise<PiWebStatusResponse> => {
const force = refreshOptions.force === true;
if (pending !== undefined && (!force || pending.force)) return pending.promise;
const sequence = ++loadSequence;
const promise = Promise.resolve()
.then(() => load({ force }))
.then((status) => {
cached = { status, expiresAt: now() + ttlMs };
if (sequence === loadSequence) cached = { status, expiresAt: now() + ttlMs };
return status;
})
.finally(() => {
pending = undefined;
if (pending?.sequence === sequence) pending = undefined;
});
return pending;
pending = { promise, force, sequence };
return promise;
};
return {
@@ -45,6 +59,8 @@ export function createPiWebStatusCache(load: () => Promise<PiWebStatusResponse>,
refresh,
invalidate(): void {
cached = undefined;
loadSequence += 1;
pending = undefined;
},
};
}
@@ -22,6 +22,22 @@ describe("SessionEventHub", () => {
expect(otherSocket.send).not.toHaveBeenCalled();
});
it("omits thinking signatures from final-message payloads without mutating source events", () => {
const hub = new SessionEventHub();
const socket = new FakeSocket();
hub.add("s1", socket);
const thinkingBlock = { type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true };
const message = { role: "assistant", content: [thinkingBlock, { type: "text", text: "visible answer" }] };
hub.publish("s1", { type: "message.end", message });
expect(socket.send).toHaveBeenCalledWith(JSON.stringify({
type: "message.end",
message: { role: "assistant", content: [{ type: "thinking", thinking: "private chain", redacted: true }, { type: "text", text: "visible answer" }] },
}));
expect(thinkingBlock.thinkingSignature).toBe("opaque-provider-payload");
});
it("removes session sockets on close and skips non-open sockets", () => {
const hub = new SessionEventHub();
const closed = new FakeSocket();
+2 -1
View File
@@ -1,4 +1,5 @@
import type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes.js";
import { projectBrowserSessionEvent } from "../browserMessageProjection.js";
export interface RealtimeSocket {
readonly OPEN: number;
@@ -29,7 +30,7 @@ export class SessionEventHub {
}
publish(sessionId: string, event: SessionUiEvent): void {
const payload = JSON.stringify(event);
const payload = JSON.stringify(projectBrowserSessionEvent(event));
for (const socket of this.socketsBySession.get(sessionId) ?? []) {
if (socket.readyState === socket.OPEN) socket.send(payload);
}
+53 -2
View File
@@ -36,6 +36,40 @@ describe("machine-scoped session proxy routes", () => {
expect(daemon.requests).toEqual([{ method: "POST", path: "/auth/api-key", body: { providerId: "p", key: "k" } }]);
});
it("forwards sessiond health and runtime aliases to daemon endpoints", async () => {
const healthResponse = await app.inject({ method: "GET", url: "/api/machines/local/sessiond/health" });
const runtimeResponse = await app.inject({ method: "GET", url: "/api/machines/local/sessiond/runtime" });
expect(healthResponse.statusCode).toBe(200);
expect(healthResponse.json()).toEqual({ ok: true });
expect(runtimeResponse.statusCode).toBe(200);
expect(runtimeResponse.json()).toEqual({ ok: true });
expect(daemon.requests).toEqual([
{ method: "GET", path: "/health", body: undefined },
{ method: "GET", path: "/runtime", body: undefined },
]);
});
it("forwards empty upstream responses without parsing a body", async () => {
daemon.respondWith({ statusCode: 204, headers: {}, body: "" });
const response = await app.inject({ method: "DELETE", url: "/api/machines/local/sessions/session-1" });
expect(response.statusCode).toBe(204);
expect(response.body).toBe("");
expect(daemon.requests).toEqual([{ method: "DELETE", path: "/sessions/session-1", body: undefined }]);
});
it("returns a 502 response when the daemon request fails", async () => {
daemon.failWith(new Error("connection refused"));
const response = await app.inject({ method: "GET", url: "/api/machines/local/sessions" });
expect(response.statusCode).toBe(502);
expect(response.json()).toEqual({ error: "Session daemon unavailable: connection refused" });
expect(daemon.requests).toEqual([{ method: "GET", path: "/sessions", body: undefined }]);
});
it("preserves cwd query context when forwarding session event websockets", async () => {
await app.listen({ host: "127.0.0.1", port: 0 });
const socket = new WebSocket(`${serverUrl(app)}/api/machines/local/sessions/session-1/events?cwd=${encodeURIComponent("/repo")}`);
@@ -49,9 +83,16 @@ describe("machine-scoped session proxy routes", () => {
});
});
interface FakeSessionDaemonResponse {
statusCode: number;
headers: Record<string, string>;
body: string;
}
class FakeSessionDaemon {
readonly requests: { method: string; path: string; body: unknown }[] = [];
readonly websocketPaths: string[] = [];
private readonly queuedResponses: (FakeSessionDaemonResponse | Error)[] = [];
private readonly sockets = new Set<WebSocket>();
private constructor(private readonly upstream: WebSocketServer) {
@@ -67,9 +108,19 @@ class FakeSessionDaemon {
return new FakeSessionDaemon(upstream);
}
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> {
respondWith(response: FakeSessionDaemonResponse): void {
this.queuedResponses.push(response);
}
failWith(error: Error): void {
this.queuedResponses.push(error);
}
request(method: string, path: string, body?: unknown): Promise<FakeSessionDaemonResponse> {
this.requests.push({ method, path, body });
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) });
const queuedResponse = this.queuedResponses.shift();
if (queuedResponse instanceof Error) return Promise.reject(queuedResponse);
return Promise.resolve(queuedResponse ?? { statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) });
}
connectWebSocket(path: string): WebSocket {
+89 -3
View File
@@ -1,13 +1,21 @@
import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises";
import { join } from "node:path";
import { basename, join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_ATTACHMENT_FOLDER, saveAttachmentsToWorkspace } from "./attachmentService.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { formatDimensionNote, resizeImage, type ResizedImage } from "@earendil-works/pi-coding-agent";
import { DEFAULT_ATTACHMENT_FOLDER, attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
vi.mock("@earendil-works/pi-coding-agent", () => ({
formatDimensionNote: vi.fn(),
resizeImage: vi.fn(),
}));
let workspace: string;
let externalDirectories: string[] = [];
beforeEach(async () => {
vi.mocked(formatDimensionNote).mockReset();
vi.mocked(resizeImage).mockReset();
workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-"));
externalDirectories = [];
});
@@ -22,6 +30,63 @@ afterEach(async () => {
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
const pngBase64 = pngBytes.toString("base64");
function resizedImage(overrides: Partial<ResizedImage> = {}): ResizedImage {
return {
data: "resized-data",
mimeType: "image/png",
originalWidth: 2400,
originalHeight: 1200,
width: 1200,
height: 600,
wasResized: true,
...overrides,
};
}
describe("attachmentsToInlineImages", () => {
it("resizes images, drops unresizable images, and preserves dimension notes", async () => {
const firstInput = Buffer.from("first image");
const droppedInput = Buffer.from("too large");
const thirdInput = Buffer.from("third image");
const firstResized = resizedImage({ data: "first-resized", mimeType: "image/webp" });
const thirdResized = resizedImage({
data: "third-resized",
mimeType: "image/jpeg",
originalWidth: 640,
originalHeight: 480,
width: 640,
height: 480,
wasResized: false,
});
vi.mocked(resizeImage)
.mockResolvedValueOnce(firstResized)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(thirdResized);
vi.mocked(formatDimensionNote)
.mockReturnValueOnce("[Image dimensions changed.]")
.mockReturnValueOnce(undefined);
await expect(attachmentsToInlineImages([
{ kind: "image", mimeType: "image/png", data: firstInput.toString("base64"), name: "first.png" },
{ kind: "image", mimeType: "image/png", data: droppedInput.toString("base64"), name: "huge.png" },
{ kind: "image", mimeType: "image/jpeg", data: thirdInput.toString("base64"), name: "photo.jpg" },
])).resolves.toEqual([
{
image: { type: "image", data: "first-resized", mimeType: "image/webp" },
dimensionNote: "[Image dimensions changed.]",
},
{ image: { type: "image", data: "third-resized", mimeType: "image/jpeg" } },
]);
expect(resizeImage).toHaveBeenNthCalledWith(1, firstInput, "image/png");
expect(resizeImage).toHaveBeenNthCalledWith(2, droppedInput, "image/png");
expect(resizeImage).toHaveBeenNthCalledWith(3, thirdInput, "image/jpeg");
expect(formatDimensionNote).toHaveBeenNthCalledWith(1, firstResized);
expect(formatDimensionNote).toHaveBeenNthCalledWith(2, thirdResized);
});
});
describe("saveAttachmentsToWorkspace", () => {
it("writes attachments into the default folder and returns relative paths", async () => {
const fixedNow = () => new Date("2026-06-13T12:05:01.123Z");
@@ -69,6 +134,27 @@ describe("saveAttachmentsToWorkspace", () => {
expect(await readFile(join(workspace, saved[1]?.path ?? ""))).toHaveLength(0);
});
it("falls back, strips controls, and truncates unsafe attachment names", async () => {
const longStem = "a".repeat(140);
const saved = await saveAttachmentsToWorkspace(
workspace,
[
{ kind: "image", mimeType: "image/jpeg", data: pngBase64 },
{ kind: "file", mimeType: "application/octet-stream", data: "QUJD", name: "\u0000\u001f\u007f" },
{ kind: "file", mimeType: "text/plain", data: "REVG", name: "nested/bad\u0000\u007fname\n.txt" },
{ kind: "file", mimeType: "application/pdf", data: "R0hJ", name: `${longStem}.pdf` },
],
{ now: () => new Date(2026, 5, 13, 12, 5, 1, 123) },
);
expect(saved.map((attachment) => basename(attachment.path))).toEqual([
"attachment-20260613-120501-123-1-image.jpg",
"attachment-20260613-120501-123-2-file.bin",
"attachment-20260613-120501-123-3-badname.txt",
`attachment-20260613-120501-123-4-${"a".repeat(92)}.pdf`,
]);
});
it("does not overwrite an existing attachment name", async () => {
const fixedNow = () => new Date("2026-06-13T12:05:01.123Z");
const first = await saveAttachmentsToWorkspace(
@@ -33,7 +33,7 @@ describe("auth provider options", () => {
expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true);
});
it("includes Anthropic in both OAuth and API key login options", () => {
it("builds login options for OAuth-only, dual-auth, and API-key providers", () => {
const options = getLoginProviderOptions(registry());
expect(options).toEqual(expect.arrayContaining([
expect.objectContaining({ id: "anthropic", authType: "oauth" }),
@@ -44,7 +44,7 @@ describe("auth provider options", () => {
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })]));
});
it("returns only stored credentials for logout", () => {
it("returns only currently stored credentials for logout", () => {
expect(getLogoutProviderOptions(registry())).toEqual([
expect.objectContaining({ id: "openai", authType: "api_key" }),
]);
+50 -1
View File
@@ -2,8 +2,10 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OAuthFlowState } from "../../shared/apiTypes.js";
import { AuthService, type AuthChange } from "./authService.js";
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
const tempDirs: string[] = [];
@@ -49,6 +51,39 @@ describe("AuthService", () => {
await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-test");
auth.dispose();
});
it("refreshes auth state after OAuth login completes", () => {
const authStorage = AuthStorage.inMemory();
const modelRegistry = ModelRegistry.create(authStorage);
const authFlows = new CapturingOAuthLoginFlowService();
const auth = new AuthService({ modelRegistry, authFlows });
const changes: AuthChange[] = [];
auth.subscribe((change) => { changes.push(change); });
const reload = vi.spyOn(authStorage, "reload");
const refresh = vi.spyOn(modelRegistry, "refresh");
const provider = authStorage.getOAuthProviders().find((option) => option.id === "anthropic");
if (provider === undefined) throw new Error("Expected built-in OAuth provider");
expect(auth.startOAuthLogin(provider.id)).toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" });
const startOptions = authFlows.startCalls.at(0);
if (startOptions === undefined) throw new Error("Expected OAuth flow to start");
expect(startOptions.providerId).toBe(provider.id);
expect(startOptions.providerName).toBe(provider.name);
expect(startOptions.authStorage).toBe(authStorage);
expect(changes).toEqual([]);
reload.mockClear();
refresh.mockClear();
if (startOptions.onComplete === undefined) throw new Error("Expected OAuth completion callback");
startOptions.onComplete();
expect(reload).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledOnce();
expect(changes).toEqual([{}]);
auth.dispose();
expect(authFlows.disposed).toBe(true);
});
});
function createAuthService(data: Parameters<typeof AuthStorage.inMemory>[0] = {}) {
@@ -65,3 +100,17 @@ async function tempAgentDir(): Promise<string> {
tempDirs.push(dir);
return dir;
}
class CapturingOAuthLoginFlowService extends OAuthLoginFlowService {
readonly startCalls: Parameters<OAuthLoginFlowService["start"]>[0][] = [];
disposed = false;
override start(options: Parameters<OAuthLoginFlowService["start"]>[0]): OAuthFlowState {
this.startCalls.push(options);
return { flowId: "flow-1", providerId: options.providerId, providerName: options.providerName, status: "running", progress: [] };
}
override dispose(): void {
this.disposed = true;
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ export const BUILTIN_COMMANDS: ClientCommand[] = [
{ name: "new", description: "Start a new session", source: "builtin" },
{ name: "compact", description: "Manually compact session context", source: "builtin" },
{ name: "resume", description: "Resume a different session", source: "builtin" },
{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes", source: "builtin" },
{ name: "reload", description: "Reload Pi runtime resources for this session", source: "builtin" },
{ name: "quit", description: "Quit pi", source: "builtin" },
];
@@ -12,6 +12,7 @@ afterEach(() => {
describe("OAuthLoginFlowService", () => {
it("round-trips prompt responses and completes the flow", async () => {
let promptValue: string | undefined;
const onComplete = vi.fn();
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
@@ -22,6 +23,7 @@ describe("OAuthLoginFlowService", () => {
promptValue = await callbacks.onPrompt({ message: "Paste code", placeholder: "code" });
callbacks.onProgress?.(`Got ${promptValue}`);
}),
onComplete,
});
const prompt = state.prompt;
@@ -35,6 +37,7 @@ describe("OAuthLoginFlowService", () => {
expect(promptValue).toBe("abc123");
expect(service.get(state.flowId)).toMatchObject({ status: "complete", progress: ["Waiting for code", "Got abc123", "Login complete"] });
expect(onComplete).toHaveBeenCalledOnce();
service.dispose();
});
@@ -113,6 +116,30 @@ describe("OAuthLoginFlowService", () => {
service.dispose();
});
it("rejects pending prompts when disposed", async () => {
const promptRejected = deferred<Error>();
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
try {
await callbacks.onPrompt({ message: "Paste code" });
} catch (error) {
promptRejected.resolve(toError(error));
throw error;
}
}),
});
expect(state.prompt).toBeDefined();
service.dispose();
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login cancelled" });
expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found");
});
it("rejects stale or duplicate responses", () => {
const service = new OAuthLoginFlowService();
const state = service.start({
@@ -0,0 +1,358 @@
import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js";
describe("PiSessionService archive and cleanup", () => {
it("archives a session subtree within the root workspace", async () => {
const archivedInputs: string[] = [];
const root = sessionRecord("root");
const directChild = { ...sessionRecord("direct-child"), path: "/sessions/direct-child.jsonl", parentSessionPath: root.path };
const archivedChild = { ...sessionRecord("archived-child"), path: "/sessions/archived-child.jsonl", parentSessionPath: root.path };
const grandchild = { ...sessionRecord("grandchild"), path: "/sessions/grandchild.jsonl", parentSessionPath: archivedChild.path };
const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path };
const fake = fakeRuntime("root", { sessionFile: root.path });
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]),
get: () => Promise.resolve(undefined),
archive: (input) => {
archivedInputs.push(input.sessionId);
return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" });
},
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager(),
list: (cwd) => Promise.resolve(cwd === "/workspace" ? [root, directChild, archivedChild, grandchild] : [otherWorkspaceChild]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
await expect(service.archiveTree(sessionRef("root"))).resolves.toEqual({
archived: true,
sessionIds: ["root", "direct-child", "grandchild"],
archivedCount: 3,
skippedAlreadyArchivedCount: 1,
});
expect(archivedInputs).toEqual(["root", "direct-child", "grandchild"]);
await service.dispose();
});
it("permanently deletes archived sessions through the archive store", async () => {
const deletedSessionIds: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" }
: undefined),
archive: () => { throw new Error("archive should not be called for records that already have archive files"); },
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: (sessionId) => {
deletedSessionIds.push(sessionId);
return Promise.resolve();
},
},
sessionManager: sessionGateway([sessionRecord("active")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.deleteArchived("arch")).resolves.toBeUndefined();
await expect(service.deleteArchived("active")).rejects.toThrow("Archived session not found");
expect(deletedSessionIds).toEqual(["archived"]);
await service.dispose();
});
it("bulk archives inactive sessions by cwd without opening runtimes", async () => {
const recordsByCwd = new Map([
["/one", [sessionRecord("a", "/one"), sessionRecord("b", "/one")]],
["/two", [sessionRecord("c", "/two")]],
]);
const listCalls: string[] = [];
const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); });
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
archiveMany,
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager(),
list: (cwd) => {
listCalls.push(cwd);
return Promise.resolve(recordsByCwd.get(cwd) ?? []);
},
open,
},
heartbeatIntervalMs: 60_000,
});
const result = await service.archiveMany([{ id: "a", cwd: "/one" }, { id: "b", cwd: "/one" }, { id: "c", cwd: "/two" }]);
expect(result).toMatchObject({ archived: true, archivedSessionIds: ["a", "b", "c"], failures: [] });
expect(listCalls).toEqual(["/one", "/two"]);
expect(open).not.toHaveBeenCalled();
expect(archiveMany).toHaveBeenCalledTimes(1);
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["a", "b", "c"]);
await service.dispose();
});
it("bulk archive reports per-session failures without aborting other archives", async () => {
const busy = fakeRuntime("busy", { isStreaming: true });
let createCalls = 0;
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
createCalls += 1;
return Promise.resolve(busy.runtime);
},
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
archiveMany,
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([sessionRecord("busy"), sessionRecord("ok")]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("busy"));
const result = await service.archiveMany([{ id: "busy", cwd: "/workspace" }, { id: "ok", cwd: "/workspace" }, { id: "missing", cwd: "/workspace" }]);
expect(createCalls).toBe(1);
expect(busy.calls.abort).toBe(0);
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["ok"]);
expect(result.archivedSessionIds).toEqual(["ok"]);
expect(result.failures).toEqual([
{ sessionId: "busy", error: "Stop current session activity before archiving" },
{ sessionId: "missing", error: "Session not found" },
]);
await service.dispose();
});
it("bulk deletes only archived sessions and skips busy active archived runtimes", async () => {
const busyRecord = { sessionId: "busy-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/busy.jsonl" };
const idleRecord = { sessionId: "idle-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/idle.jsonl" };
const busy = fakeRuntime("busy-archived", { isStreaming: true });
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(busy.runtime),
archiveStore: {
list: () => Promise.resolve([busyRecord, idleRecord]),
get: (sessionId) => Promise.resolve(sessionId === "busy-archived" ? busyRecord : undefined),
archive: () => { throw new Error("archive should not be called for records that already have archive files"); },
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: () => Promise.resolve(),
deleteArchivedMany,
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([sessionRecord("unarchived")]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("busy-archived"));
const result = await service.deleteArchivedMany([{ id: "busy-archived", cwd: "/workspace" }, { id: "idle-archived", cwd: "/workspace" }, { id: "unarchived", cwd: "/workspace" }]);
expect(busy.calls.abort).toBe(0);
expect(deleteArchivedMany).toHaveBeenCalledWith(["idle-archived"]);
expect(result.deletedSessionIds).toEqual(["idle-archived"]);
expect(result.failures).toEqual([
{ sessionId: "busy-archived", error: "Stop current session activity before deleting archived session" },
{ sessionId: "unarchived", error: "Archived session not found" },
]);
await service.dispose();
});
it("bulk delete moves legacy archived records with one workspace scan before deleting", async () => {
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const listCalls: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([
{ sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
{ sessionId: "legacy-b", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
{ sessionId: "moved", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/moved.jsonl" },
]),
get: () => Promise.resolve(undefined),
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
archiveMany,
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: () => Promise.resolve(),
deleteArchivedMany,
},
sessionManager: {
create: () => fakeSessionManager(),
list: (cwd) => {
listCalls.push(cwd);
return Promise.resolve([sessionRecord("legacy-a"), sessionRecord("legacy-b"), sessionRecord("unarchived")]);
},
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
const result = await service.deleteArchivedMany([{ id: "legacy-a", cwd: "/workspace" }, { id: "legacy-b", cwd: "/workspace" }, { id: "moved", cwd: "/workspace" }]);
expect(listCalls).toEqual(["/workspace"]);
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]);
expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b", "moved"]);
expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b", "moved"]);
expect(result.failures).toEqual([]);
await service.dispose();
});
it("previews session cleanup without mutating and executes a recomputed plan", async () => {
const archivedInputs: string[] = [];
const deletedSessionIds: string[] = [];
let listAllCalls = 0;
const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" };
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
const service = new PiSessionService(new CapturingSessionEventHub(), {
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([archived, otherArchived]),
get: () => Promise.resolve(undefined),
archive: () => Promise.reject(new Error("cleanup should use archiveMany")),
archiveMany: (inputs) => {
archivedInputs.push(...inputs.map((input) => input.sessionId));
return Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" })));
},
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")),
deleteArchivedMany: (sessionIds) => {
deletedSessionIds.push(...sessionIds);
return Promise.resolve([...sessionIds]);
},
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
listAll: () => {
listAllCalls += 1;
return Promise.resolve([
listAllCalls === 1 ? sessionRecord("preview-only", "/old-project") : sessionRecord("execute-only", "/old-project"),
listAllCalls === 1 ? sessionRecord("preview-other", "/other-project") : sessionRecord("execute-other", "/other-project"),
]);
},
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
const preview = await service.cleanupPreview({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
expect(preview.totals).toEqual({ archiveCount: 1, deleteCount: 1 });
expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]);
expect(archivedInputs).toEqual([]);
expect(deletedSessionIds).toEqual([]);
const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
expect(result.archivedSessionIds).toEqual(["execute-only"]);
expect(result.deletedSessionIds).toEqual(["archived-old"]);
expect(archivedInputs).toEqual(["execute-only"]);
expect(deletedSessionIds).toEqual(["archived-old"]);
await service.dispose();
});
it("moves legacy cleanup delete records with one workspace scan before batch deleting", async () => {
const listCalls: string[] = [];
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), {
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([
{ sessionId: "legacy-a", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" },
{ sessionId: "legacy-b", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" },
]),
get: () => Promise.resolve(undefined),
archive: () => Promise.reject(new Error("cleanup should use archiveMany")),
archiveMany,
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")),
deleteArchivedMany,
},
sessionManager: {
create: () => fakeSessionManager(),
list: (cwd) => {
listCalls.push(cwd);
return Promise.resolve([sessionRecord("legacy-a", cwd), sessionRecord("legacy-b", cwd)]);
},
listAll: () => Promise.resolve([]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
const result = await service.cleanup({ thresholds: { deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
expect(listCalls).toEqual(["/old-project"]);
expect(archiveMany).toHaveBeenCalledTimes(1);
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]);
expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b"]);
expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b"]);
await service.dispose();
});
it("skips busy active sessions during cleanup execution", async () => {
const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" });
const archivedInputs: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
now: () => new Date("2026-06-25T00:00:00.000Z"),
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
archive: (input) => {
archivedInputs.push(input.sessionId);
return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" });
},
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager("/old-project"),
list: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]),
listAll: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]),
open: () => fakeSessionManager("/old-project"),
},
heartbeatIntervalMs: 60_000,
});
await service.status("busy-open");
const result = await service.cleanup({ thresholds: { archiveIdleDays: 1 } });
expect(result.archivedSessionIds).toEqual([]);
expect(result.skippedBusySessionIds).toEqual(["busy-open"]);
expect(archivedInputs).toEqual([]);
expect(fake.calls.abort).toBe(0);
await service.dispose();
});
});
@@ -0,0 +1,109 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPiWebCustomToolDefinitions, sessionAllowsDelegationTools, type PiSessionManager } from "./piSessionService.js";
import type { SubsessionToolDeps } from "./spawnSubsessionTool.js";
import { fakeSessionManager } from "./piSessionService.testSupport.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});
function delegationDeps() {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "independent-1", cwd: "/workspace" }));
const subsessions: SubsessionToolDeps = {
spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace" })),
list: vi.fn(() => Promise.resolve([])),
check: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, finalText: "", messageCount: 0 })),
read: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, entries: [], total: 0, matched: 0, start: 0, hasMore: false })),
};
return { spawn, subsessions };
}
function toolNames(definitions: ReturnType<typeof createPiWebCustomToolDefinitions>): string[] {
return definitions.map((definition) => definition.name);
}
function manager(id: string, file: string | undefined, entries: readonly unknown[] = []): PiSessionManager {
return fakeSessionManager("/workspace", {
getSessionId: () => id,
getSessionFile: () => file,
getEntries: () => entries,
});
}
describe("delegation tool capability boundary", () => {
it("provides every globally enabled delegation tool to unrestricted sessions", () => {
const { spawn, subsessions } = delegationDeps();
expect(toolNames(createPiWebCustomToolDefinitions("/workspace", true, spawn, subsessions))).toEqual([
"edit",
"spawn_session",
"spawn_subsession",
"list_subsessions",
"check_subsession",
"read_subsession",
]);
});
it("continues to honor global delegation feature flags for unrestricted sessions", () => {
const { spawn } = delegationDeps();
expect(toolNames(createPiWebCustomToolDefinitions("/workspace", true, spawn))).toEqual(["edit", "spawn_session"]);
expect(toolNames(createPiWebCustomToolDefinitions("/workspace", true))).toEqual(["edit"]);
});
it("removes every delegation tool but retains ordinary tools for restricted tracked children", () => {
const { spawn, subsessions } = delegationDeps();
expect(toolNames(createPiWebCustomToolDefinitions("/workspace", false, spawn, subsessions))).toEqual(["edit"]);
});
it.each(["human-created", "spawn_session-created"])("allows delegation for a %s session without tracked-child provenance", async () => {
const sessionManager = manager("session-1", undefined);
const open = vi.fn(() => { throw new Error("no parent session should be opened"); });
await expect(sessionAllowsDelegationTools(sessionManager, { open })).resolves.toBe(true);
expect(open).not.toHaveBeenCalled();
});
it("removes delegation when persisted records verify exact tracked-child provenance", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-web-delegation-provenance-"));
tempDirs.push(dir);
const parentFile = join(dir, "parent.jsonl");
const childFile = join(dir, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace", parentSession: parentFile })}\n`, "utf8");
const childManager = manager("child-1", childFile, [
{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } },
]);
const parentManager = manager("parent-1", parentFile, [
{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace" } },
]);
await expect(sessionAllowsDelegationTools(childManager, { open: () => parentManager })).resolves.toBe(false);
});
it("does not treat a copied child marker as tracked provenance without an exact reciprocal file link", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-web-delegation-copy-"));
tempDirs.push(dir);
const parentFile = join(dir, "parent.jsonl");
const originalChildFile = join(dir, "original-child.jsonl");
const copiedChildFile = join(dir, "copied-child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace", parentSession: parentFile })}\n`, "utf8");
const copiedChildManager = manager("child-1", copiedChildFile, [
{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } },
]);
const parentManager = manager("parent-1", parentFile, [
{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace" } },
]);
await expect(sessionAllowsDelegationTools(copiedChildManager, { open: () => parentManager })).resolves.toBe(true);
});
});
@@ -0,0 +1,546 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js";
function deferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
describe("PiSessionService lifecycle, listing, and reload", () => {
it("starts sessions through an injected runtime creator", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime();
let createCalls = 0;
const createAgentRuntime: RuntimeCreator = async () => {
createCalls += 1;
await Promise.resolve();
return fake.runtime;
};
const service = new PiSessionService(hub, {
createAgentRuntime,
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
const session = await service.start("/workspace");
expect(createCalls).toBe(1);
expect(fake.calls.bindExtensions).toHaveLength(1);
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
expect(service.activeCount()).toBe(1);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true);
expect(hub.globalEvents.some((event) => event.type === "session.created" && event.session.id === "session-1" && event.session.cwd === "/workspace")).toBe(true);
await service.dispose();
expect(fake.calls.abort).toBe(1);
expect(fake.calls.dispose).toBe(1);
});
it("reports persistence from actual session-file existence for fresh active sessions", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-web-persisted-"));
const sessionFile = join(dir, "new-session.jsonl");
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("new-session", { sessionFile });
let service: PiSessionService | undefined;
try {
service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
const session = await service.start("/workspace");
const createdEvent = hub.globalEvents.find((event) => event.type === "session.created");
expect(session).toMatchObject({ id: "new-session", path: sessionFile, persisted: false });
expect(createdEvent).toMatchObject({ type: "session.created", session: { id: "new-session", persisted: false } });
await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: false });
await writeFile(sessionFile, '{"type":"session","id":"new-session"}\n', "utf8");
await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: true });
} finally {
await service?.dispose();
await rm(dir, { recursive: true, force: true });
}
});
it("opens legacy id-only lookups from the default session store gateway", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("legacy-session");
const open = vi.fn(() => fakeSessionManager());
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
listAll: () => Promise.resolve([sessionRecord("legacy-session")]),
open,
},
heartbeatIntervalMs: 60_000,
});
await expect(service.status("legacy")).resolves.toMatchObject({ sessionId: "legacy-session" });
expect(open).toHaveBeenCalledWith("/sessions/legacy-session.jsonl");
await service.dispose();
});
it("shares one runtime when concurrent cold lookups resolve to the same session", async () => {
const sessionId = "single-flight-session";
const createStarted = deferred();
const releaseCreate = deferred();
const winnerUnsubscribe = vi.fn();
const loserUnsubscribe = vi.fn();
const winnerSubscribe = vi.fn(() => winnerUnsubscribe);
const loserSubscribe = vi.fn(() => loserUnsubscribe);
const winner = fakeRuntime(sessionId, {
sessionManager: fakeSessionManager("/workspace", {
getSessionId: () => sessionId,
getBranch: () => [{ type: "message", message: { role: "user", content: "shared runtime" } }],
}),
subscribe: winnerSubscribe,
});
const loser = fakeRuntime(sessionId, {
sessionManager: fakeSessionManager("/workspace", { getSessionId: () => sessionId }),
subscribe: loserSubscribe,
});
const runtimes = [winner.runtime, loser.runtime];
let createCalls = 0;
const createAgentRuntime: RuntimeCreator = async () => {
const runtime = runtimes[createCalls];
createCalls += 1;
createStarted.resolve();
await releaseCreate.promise;
if (runtime === undefined) throw new Error("unexpected runtime creation");
return runtime;
};
const gateway = sessionGateway([sessionRecord(sessionId)]);
const open = vi.spyOn(gateway, "open");
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: emptyArchiveStore(),
createAgentRuntime,
sessionManager: gateway,
heartbeatIntervalMs: 60_000,
});
const messagesPromise = service.messages(sessionRef(sessionId));
await createStarted.promise;
const statusPromise = service.status(sessionRef("single-flight"));
await new Promise<void>((resolve) => setImmediate(resolve));
const callsWhileOpening = createCalls;
releaseCreate.resolve();
const [messages, status] = await Promise.all([messagesPromise, statusPromise]);
const activeCount = service.activeCount();
await service.dispose();
expect(callsWhileOpening).toBe(1);
expect(createCalls).toBe(1);
expect(open).toHaveBeenCalledOnce();
expect(activeCount).toBe(1);
expect(messages).toEqual([{ role: "user", content: "shared runtime" }]);
expect(status).toMatchObject({ sessionId });
expect(winnerSubscribe).toHaveBeenCalledOnce();
expect(winnerUnsubscribe).toHaveBeenCalledOnce();
expect(winner.calls.dispose).toBe(1);
expect(loserSubscribe).not.toHaveBeenCalled();
expect(loserUnsubscribe).not.toHaveBeenCalled();
expect(loser.calls.dispose).toBe(0);
});
it("clears a failed pending open so the session can be retried", async () => {
const sessionId = "retry-open-session";
const bindStarted = deferred();
const bindResult = deferred();
const openingError = new Error("extension binding failed");
const failed = fakeRuntime(sessionId, {
bindExtensions: () => {
bindStarted.resolve();
return bindResult.promise;
},
});
const retried = fakeRuntime(sessionId);
const runtimes = [failed.runtime, retried.runtime];
let createCalls = 0;
const createAgentRuntime: RuntimeCreator = () => {
const runtime = runtimes[createCalls];
createCalls += 1;
return runtime === undefined
? Promise.reject(new Error("unexpected runtime creation"))
: Promise.resolve(runtime);
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: emptyArchiveStore(),
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord(sessionId)]),
heartbeatIntervalMs: 60_000,
});
const messagesPromise = service.messages(sessionRef(sessionId));
await bindStarted.promise;
const statusPromise = service.status(sessionRef("retry-open"));
await new Promise<void>((resolve) => setImmediate(resolve));
const callsWhileOpening = createCalls;
const failedLookups = Promise.allSettled([messagesPromise, statusPromise]);
bindResult.reject(openingError);
const outcomes = await failedLookups;
expect(callsWhileOpening).toBe(1);
expect(outcomes).toHaveLength(2);
for (const outcome of outcomes) {
expect(outcome.status).toBe("rejected");
if (outcome.status === "rejected") expect(outcome.reason).toBe(openingError);
}
expect(service.activeCount()).toBe(0);
expect(failed.calls.abort).toBe(1);
expect(failed.calls.dispose).toBe(1);
await expect(service.status(sessionRef(sessionId))).resolves.toMatchObject({ sessionId });
expect(createCalls).toBe(2);
expect(service.activeCount()).toBe(1);
await service.dispose();
expect(retried.calls.dispose).toBe(1);
});
it("waits for an in-flight open before disposing the service", async () => {
const sessionId = "dispose-opening-session";
const createStarted = deferred();
const runtimeResult = deferred<PiSessionRuntime>();
const fake = fakeRuntime(sessionId);
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: emptyArchiveStore(),
createAgentRuntime: () => {
createStarted.resolve();
return runtimeResult.promise;
},
sessionManager: sessionGateway([sessionRecord(sessionId)]),
heartbeatIntervalMs: 60_000,
});
const statusPromise = service.status(sessionRef(sessionId));
await createStarted.promise;
let disposeSettled = false;
const disposePromise = service.dispose().then(() => { disposeSettled = true; });
await new Promise<void>((resolve) => setImmediate(resolve));
const settledWhileOpening = disposeSettled;
runtimeResult.resolve(fake.runtime);
await expect(statusPromise).resolves.toMatchObject({ sessionId });
await disposePromise;
expect(settledWhileOpening).toBe(false);
expect(service.activeCount()).toBe(0);
expect(fake.calls.abort).toBe(1);
expect(fake.calls.dispose).toBe(1);
});
it("binds extensions again when the SDK runtime replaces the active session", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("session-1");
const replacement = fakeRuntime("session-2");
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
Object.defineProperty(fake.runtime, "session", { configurable: true, value: replacement.session });
await rebindSession?.(replacement.session);
expect(fake.calls.bindExtensions).toHaveLength(1);
expect(replacement.calls.bindExtensions).toHaveLength(1);
expect(service.activeCount()).toBe(1);
expect(await service.status("session-2")).toMatchObject({ sessionId: "session-2" });
await service.dispose();
});
it("publishes extension errors reported while binding session extensions", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("extension-session", {
bindExtensions: (bindings) => {
bindings.onError?.({ extensionPath: "pi-mcp-adapter", event: "session_start", error: "MCP failed" });
return Promise.resolve();
},
});
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
expect(hub.sessionEvents).toContainEqual({
sessionId: "extension-session",
event: { type: "session.error", message: "pi-mcp-adapter: MCP failed" },
});
const extensionErrorActivity = hub.globalEvents.find((event) => event.type === "activity.update" && event.activity.sessionId === "extension-session");
expect(extensionErrorActivity).toMatchObject({
type: "activity.update",
activity: { sessionId: "extension-session", phase: "error", label: "extension error", detail: "pi-mcp-adapter: MCP failed" },
});
await service.dispose();
});
it("clears stale active activity once a previously active session becomes idle", async () => {
vi.useFakeTimers();
let service: PiSessionService | undefined;
try {
const hub = new CapturingSessionEventHub();
let listener: ((event: unknown) => void) | undefined;
const fake = fakeRuntime("idle-session", {
isStreaming: true,
subscribe: (next) => {
listener = next;
return () => undefined;
},
});
service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("idle-session")]),
heartbeatIntervalMs: 1_000,
});
await service.status(sessionRef("idle-session"));
hub.globalEvents.length = 0;
listener?.({ type: "agent_start" });
const activityPhases = () => hub.globalEvents
.filter((event) => event.type === "activity.update")
.map((event) => event.activity.phase);
expect(activityPhases()).toEqual(["active"]);
fake.session.isStreaming = false;
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(1_000);
expect(activityPhases()).toEqual(["active", "idle"]);
} finally {
await service?.dispose();
vi.useRealTimers();
}
});
it("publishes idle activity for SDK completion events", async () => {
const hub = new CapturingSessionEventHub();
let listener: ((event: unknown) => void) | undefined;
const fake = fakeRuntime("completion-session", {
subscribe: (next) => {
listener = next;
return () => undefined;
},
});
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("completion-session")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("completion-session"));
hub.globalEvents.length = 0;
listener?.({ type: "tool_execution_end", toolName: "read", isError: false });
expect(hub.globalEvents.filter((event) => event.type === "activity.update")).toMatchObject([
{ activity: { sessionId: "completion-session", phase: "idle", label: "tool complete", detail: "read" } },
]);
await service.dispose();
});
it("uses injected archive and session-manager gateways for listing", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]),
get: () => Promise.resolve(undefined),
archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }),
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([
{ ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" },
{ ...sessionRecord("archived"), messageCount: 2, firstMessage: "bye", allMessagesText: "bye" },
]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
const sessions = await service.list("/workspace");
expect(sessions).toHaveLength(2);
expect(sessions[0]).toMatchObject({ id: "active", persisted: true });
expect(sessions[0]?.archived).toBeUndefined();
expect(sessions[1]).toMatchObject({ id: "archived", archived: true, archivedAt: "2026-01-01T00:00:00.000Z" });
await service.dispose();
});
it("lists archived records that have been moved out of the active session directory", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
get: () => Promise.resolve(undefined),
archive: () => { throw new Error("archive should not be called for moved records"); },
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([{ ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" }]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
const sessions = await service.list("/workspace");
expect(sessions).toHaveLength(2);
expect(sessions[0]).toMatchObject({ id: "active" });
expect(sessions[0]?.archived).toBeUndefined();
expect(sessions[1]).toMatchObject({ id: "archived", path: "/sessions/archived.jsonl", archived: true, archivedAt: "2026-01-02T00:00:00.000Z" });
await service.dispose();
});
it("runs /reload by refreshing the active runtime resources in place", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("runtime-reload-session");
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.runCommand(sessionRef("runtime-reload-session"), "/reload")).resolves.toEqual({
type: "done",
message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.",
});
expect(fake.calls.reload).toBe(1);
expect(fake.calls.abort).toBe(0);
expect(fake.calls.dispose).toBe(0);
expect(hub.globalEvents.some((event) => event.type === "activity.update" && event.activity.sessionId === "runtime-reload-session" && event.activity.label === "resources reloaded")).toBe(true);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "runtime-reload-session")).toBe(true);
await service.dispose();
});
it("reloads a session by closing the active runtime and re-opening it from disk", async () => {
const first = fakeRuntime("reload-session");
const second = fakeRuntime("reload-session");
const runtimes = [first.runtime, second.runtime];
let createCalls = 0;
const createAgentRuntime: RuntimeCreator = async () => {
await Promise.resolve();
const runtime = runtimes[createCalls];
createCalls += 1;
if (runtime === undefined) throw new Error("unexpected runtime creation");
return runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("reload-session")]),
heartbeatIntervalMs: 60_000,
});
// Open once so there is an active runtime to reload.
await service.status(sessionRef("reload-session"));
expect(createCalls).toBe(1);
await expect(service.reload(sessionRef("reload-session"))).resolves.toBeUndefined();
// The original runtime was torn down and a fresh one opened from disk.
expect(first.calls.abort).toBe(1);
expect(first.calls.dispose).toBe(1);
expect(createCalls).toBe(2);
expect(service.activeCount()).toBe(1);
await service.dispose();
});
it("refuses to reload a session that has active work in progress", async () => {
const fake = fakeRuntime("busy-session", { isStreaming: true });
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("busy-session")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.reload(sessionRef("busy-session"))).rejects.toThrow("Stop current session activity before reloading");
expect(fake.calls.abort).toBe(0);
expect(fake.calls.dispose).toBe(0);
await service.dispose();
});
it("refuses to reload an archived session", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" }
: undefined),
archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }),
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(true),
},
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await expect(service.reload(sessionRef("archived"))).rejects.toThrow("Archived sessions are read-only");
await service.dispose();
});
it("reconciles workspace activity when listing only archived sessions", async () => {
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
get: () => Promise.resolve(undefined),
archive: () => { throw new Error("archive should not be called for moved records"); },
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
open: () => fakeSessionManager(),
},
workspaceActivity: {
applySessionStatus: () => undefined,
applySessionActivity: () => undefined,
removeSession: () => undefined,
reconcileSessionActivity: (cwd, sessionIds) => { reconciliations.push({ cwd, sessionIds: [...sessionIds] }); },
},
heartbeatIntervalMs: 60_000,
});
const sessions = await service.list("/workspace");
expect(sessions).toHaveLength(1);
expect(sessions[0]).toMatchObject({ id: "archived", archived: true });
expect(reconciliations).toEqual([{ cwd: "/workspace", sessionIds: [] }]);
await service.dispose();
});
});
@@ -0,0 +1,299 @@
import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
describe("PiSessionService prompt, queue, and auth warnings", () => {
it("sends prompts to an injected runtime without touching the SDK runtime", async () => {
const fake = fakeRuntime("prompt-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("prompt-session"), "Build the thing");
expect(fake.calls.prompt).toEqual([{ text: "Build the thing", options: undefined }]);
await service.dispose();
});
it("echoes the user message for direct prompts but not command-forwarded ones", async () => {
const fake = fakeRuntime("echo-session", {
resourceLoader: { getSkills: () => ({ skills: [{ name: "skill-creator" }] }) },
});
const hub = new CapturingSessionEventHub();
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("echo-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("echo-session"), "Build the thing");
expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1);
// The client optimistically renders command-forwarded prompts (e.g. /skill:*),
// so the server must not publish a second copy via message.append.
await service.runCommand(sessionRef("echo-session"), "/skill:skill-creator");
expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1);
expect(fake.calls.prompt).toEqual([
{ text: "Build the thing", options: undefined },
{ text: "/skill:skill-creator", options: undefined },
]);
await service.dispose();
});
it("rejects malformed prompt text before opening the runtime", async () => {
const fake = fakeRuntime("prompt-session");
let createCalls = 0;
const createAgentRuntime: RuntimeCreator = async () => {
createCalls += 1;
await Promise.resolve();
return fake.runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required");
expect(createCalls).toBe(0);
expect(fake.calls.prompt).toEqual([]);
await service.dispose();
});
it("generates a session name for the first prompt via the session's agent.streamFn", async () => {
const model = testModel();
const streamCalls: unknown[] = [];
const streamFn: StreamFn = (streamModel, context, options) => {
streamCalls.push({ streamModel, context, options });
const stream = createAssistantMessageEventStream();
const message: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Fix login bug" }],
api: "anthropic-messages",
provider: "anthropic",
model: model.id,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "stop",
timestamp: Date.now(),
};
stream.push({ type: "done", reason: "stop", message });
stream.end(message);
return stream;
};
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("name-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("name-session"), "Please fix the login bug");
await vi.waitFor(() => { expect(fake.session.sessionName).toBe("Fix login bug"); });
expect(streamCalls).toHaveLength(1);
expect(hub.sessionEvents.some(({ event }) => event.type === "session.name" && event.name === "Fix login bug")).toBe(true);
await service.dispose();
});
it("includes queued message details in session status", async () => {
const fake = fakeRuntime("status-session", {
messages: [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }],
pendingMessageCount: 2,
getSteeringMessages: () => ["adjust this turn"],
getFollowUpMessages: () => ["then do this"],
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("status-session")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.status(sessionRef("status-session"))).resolves.toMatchObject({
pendingMessageCount: 2,
queuedMessages: [{ kind: "steer", text: "adjust this turn" }, { kind: "followUp", text: "then do this" }],
messageCount: 2,
});
await service.dispose();
});
it("does not enqueue duplicate queued message text", async () => {
const fake = fakeRuntime("dedupe-session", {
isStreaming: true,
pendingMessageCount: 1,
getFollowUpMessages: () => ["already queued"],
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("dedupe-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("dedupe-session"), "already queued", "followUp");
expect(fake.calls.prompt).toEqual([]);
await service.dispose();
});
it("does not append queued prompts to the transcript before delivery", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("queued-session", { isStreaming: true });
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("queued-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("queued-session"), "Wait for the current turn", "followUp");
expect(fake.calls.prompt).toEqual([{ text: "Wait for the current turn", options: { streamingBehavior: "followUp" } }]);
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false);
await service.dispose();
});
it("holds prompts sent during compaction until compaction finishes", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("compacting-session", { isCompacting: true });
let resolveFirstPrompt: (() => void) | undefined;
fake.session.prompt = (text: string, options?: { streamingBehavior?: "steer" | "followUp" }) => {
fake.calls.prompt.push({ text, options });
if (options === undefined) {
fake.session.isStreaming = true;
return new Promise<void>((resolve) => { resolveFirstPrompt = resolve; });
}
return Promise.resolve();
};
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("compacting-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("compacting-session"), "Start task 1", "followUp");
await service.prompt(sessionRef("compacting-session"), "Then task 2", "followUp");
expect(fake.calls.prompt).toEqual([]);
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false);
await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({
pendingMessageCount: 2,
queuedMessages: [{ kind: "followUp", text: "Start task 1" }, { kind: "followUp", text: "Then task 2" }],
});
fake.session.isCompacting = false;
fake.emit({ type: "compaction_end" });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(fake.calls.prompt).toEqual([{ text: "Start task 1", options: undefined }]);
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append" && JSON.stringify(event.message).includes("Start task 1"))).toBe(true);
await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({
pendingMessageCount: 1,
queuedMessages: [{ kind: "followUp", text: "Then task 2" }],
});
fake.emit({ type: "agent_start" });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(fake.calls.prompt).toEqual([
{ text: "Start task 1", options: undefined },
{ text: "Then task 2", options: { streamingBehavior: "followUp" } },
]);
await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({
pendingMessageCount: 0,
queuedMessages: [],
});
resolveFirstPrompt?.();
await service.dispose();
});
it("clears queued messages when aborting active work", async () => {
const fake = fakeRuntime("abort-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("abort-session")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("abort-session"));
await service.abort(sessionRef("abort-session"));
expect(fake.calls.clearQueue).toBe(1);
expect(fake.calls.abort).toBe(1);
await service.dispose();
});
it("clears prompts queued during compaction when aborting active work", async () => {
const fake = fakeRuntime("abort-compaction-session", { isCompacting: true });
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("abort-compaction-session"), "Do not deliver after abort", "followUp");
await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 1 });
await service.abort(sessionRef("abort-compaction-session"));
expect(fake.calls.clearQueue).toBe(1);
expect(fake.calls.prompt).toEqual([]);
await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 0, queuedMessages: [] });
await service.dispose();
});
it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => {
const hub = new CapturingSessionEventHub();
const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } });
const modelRegistry = ModelRegistry.inMemory(authStorage);
const model = modelRegistry.find(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
if (model === undefined) throw new Error("Expected Anthropic model fixture");
const fake = fakeRuntime("auth-session", { model, modelRegistry });
const service = new PiSessionService(hub, {
modelRegistry,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("auth-session")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("auth-session"));
hub.sessionEvents.length = 0;
hub.globalEvents.length = 0;
authStorage.logout("anthropic");
service.applyAuthChange({ removedProviderId: "anthropic" });
service.applyAuthChange({ removedProviderId: "anthropic" });
const warningCount = () => hub.sessionEvents.filter(({ event }) => event.type === "command.output" && event.level === "error" && event.message.includes(`${TEST_MODEL_PROVIDER}/${TEST_MODEL_ID}`)).length;
expect(warningCount()).toBe(1);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true);
authStorage.set("anthropic", { type: "api_key", key: "sk-new" });
service.applyAuthChange();
authStorage.logout("anthropic");
service.applyAuthChange({ removedProviderId: "anthropic" });
expect(warningCount()).toBe(2);
await service.dispose();
});
it("clears queued messages when stopping a session runtime", async () => {
const fake = fakeRuntime("stop-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("stop-session")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("stop-session"));
service.stop(sessionRef("stop-session"));
expect(fake.calls.clearQueue).toBe(1);
await service.dispose();
});
});
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
describe("PiSessionService", () => {
describe("spawnSession", () => {
function spawnService(decision: SpawnTargetDecision) {
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
const log: { details: Record<string, unknown>; message: string }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
logger: { info: (details, message) => { log.push({ details, message }); } },
heartbeatIntervalMs: 60_000,
});
return { fake, service, log };
}
it("starts a session at the resolved target, delivers the prompt, and logs the spawn", async () => {
const { fake, service, log } = spawnService({ allowed: true, cwd: "/workspace-feature" });
const result = await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue the plan", cwd: "/workspace-feature" });
expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature" });
expect(fake.calls.prompt).toEqual([{ text: "continue the plan", options: undefined }]);
expect(log).toEqual([{ details: { spawningCwd: "/workspace", sessionId: "spawned-1", cwd: "/workspace-feature", promptLength: 17 }, message: "spawn_session started a new session" }]);
await service.dispose();
});
it("uses the dispatching session's model as the spawned session's initial model", async () => {
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
const model = testModel();
let initialModel: PiAgentSession["model"];
let delegationToolsEnabled: boolean | undefined;
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve();
initialModel = options.initialModel;
delegationToolsEnabled = options.delegationToolsEnabled;
return fake.runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
heartbeatIntervalMs: 60_000,
});
await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue", cwd: "/workspace-feature", model });
expect(initialModel).toBe(model);
expect(delegationToolsEnabled).toBe(true);
await service.dispose();
});
it("rejects an out-of-project target without starting a session", async () => {
const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] });
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: "/elsewhere" }))
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /workspace");
expect(fake.calls.prompt).toEqual([]);
expect(service.activeCount()).toBe(0);
await service.dispose();
});
it("rejects when the spawning session is not in a registered project", async () => {
const { service } = spawnService({ allowed: false, reason: "not-registered" });
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined }))
.rejects.toThrow("Spawning session is not in a registered project");
await service.dispose();
});
it("is disabled when no spawn target resolver is configured", async () => {
const fake = fakeRuntime("spawned-x");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined }))
.rejects.toThrow("Spawning sessions is disabled");
await service.dispose();
});
});
});
@@ -0,0 +1,167 @@
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { PiAgentSession, PiSessionManager, PiSessionRuntime, PiSessionServiceDependencies } from "./piSessionService.js";
export class CapturingSessionEventHub extends SessionEventHub {
readonly sessionEvents: { sessionId: string; event: SessionUiEvent }[] = [];
readonly globalEvents: GlobalSessionEvent[] = [];
override publish(sessionId: string, event: SessionUiEvent): void {
this.sessionEvents.push({ sessionId, event });
}
override publishGlobal(event: GlobalSessionEvent): void {
this.globalEvents.push(event);
}
}
export type SessionGateway = NonNullable<PiSessionServiceDependencies["sessionManager"]>;
export type RuntimeCreator = NonNullable<PiSessionServiceDependencies["createAgentRuntime"]>;
export interface TestSession extends PiAgentSession {
sessionName: string | undefined;
model: PiAgentSession["model"];
isStreaming: boolean;
isCompacting: boolean;
isBashRunning: boolean;
pendingMessageCount: number;
getSteeringMessages: () => readonly string[];
getFollowUpMessages: () => readonly string[];
}
export function fakeSessionManager(cwd = "/workspace", patch: Partial<PiSessionManager> = {}): PiSessionManager {
return {
getCwd: () => cwd,
getSessionId: () => "session-1",
getSessionFile: () => undefined,
getBranch: () => [],
getLeafId: () => "leaf-1",
...patch,
};
}
export function sessionRecord(id: string, cwd = "/workspace") {
return { id, path: `/sessions/${id}.jsonl`, cwd, created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" };
}
export function sessionRef(id: string, cwd = "/workspace") {
return { id, cwd };
}
export const TEST_MODEL_PROVIDER = "anthropic";
export const TEST_MODEL_ID = "claude-sonnet-4-5-20250929";
export function testModel(): NonNullable<PiAgentSession["model"]> {
const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
if (model === undefined) throw new Error("test model not found");
return model;
}
export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
const promptCalls: { text: string; options: unknown }[] = [];
const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = [];
const bindExtensionCalls: unknown[] = [];
const listeners: ((event: unknown) => void)[] = [];
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls };
const session: TestSession = {
sessionId,
sessionFile: `/tmp/${sessionId}.jsonl`,
messages: [],
sessionName: undefined,
model: undefined,
thinkingLevel: "off",
isStreaming: false,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
sessionManager: fakeSessionManager(),
modelRegistry: ModelRegistry.create(AuthStorage.inMemory()),
scopedModels: [],
extensionRunner: { getRegisteredCommands: () => [] },
promptTemplates: [],
resourceLoader: { getSkills: () => ({ skills: [] }) },
subscribe: (listener: (event: unknown) => void) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index !== -1) listeners.splice(index, 1);
};
},
bindExtensions: (bindings: unknown) => {
calls.bindExtensions.push(bindings);
return Promise.resolve();
},
getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }),
getContextUsage: () => undefined,
reload: () => {
calls.reload += 1;
return Promise.resolve();
},
prompt: (text: string, options: unknown) => {
calls.prompt.push({ text, options });
return Promise.resolve();
},
sendCustomMessage: (message: { customType: string; content: string; display: boolean; details?: unknown }, options: unknown) => {
calls.sendCustomMessage.push({ message, options });
return Promise.resolve();
},
executeBash: () => Promise.resolve({ output: "", exitCode: 0, cancelled: false, truncated: false }),
abort: () => {
calls.abort += 1;
return Promise.resolve();
},
clearQueue: () => {
calls.clearQueue += 1;
return { steering: [], followUp: [] };
},
getSteeringMessages: () => [],
getFollowUpMessages: () => [],
setModel: () => Promise.resolve(),
cycleModel: () => Promise.resolve(undefined),
getAvailableThinkingLevels: () => [],
setThinkingLevel: () => undefined,
cycleThinkingLevel: () => undefined,
setSessionName: (name: string) => { session.sessionName = name; },
compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }),
getUserMessagesForForking: () => [],
agent: { streamFn: () => { throw new Error("streamFn should not be called in this test"); } },
...patch,
};
const runtime: PiSessionRuntime = {
cwd: session.sessionManager.getCwd(),
session,
setRebindSession: () => undefined,
fork: () => Promise.resolve({ cancelled: false }),
dispose: () => {
calls.dispose += 1;
return Promise.resolve();
},
};
return { runtime, session, calls, emit: (event: unknown) => { for (const listener of [...listeners]) listener(event); } };
}
export function runtimeCreator(runtime: PiSessionRuntime): RuntimeCreator {
return async () => {
await Promise.resolve();
return runtime;
};
}
export function sessionGateway(records: ReturnType<typeof sessionRecord>[]): SessionGateway {
return {
create: () => fakeSessionManager(),
list: () => Promise.resolve(records),
open: () => fakeSessionManager(),
};
}
export function emptyArchiveStore(): NonNullable<PiSessionServiceDependencies["archiveStore"]> {
return {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
archive: () => Promise.reject(new Error("archive should not be called")),
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
};
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { constants } from "node:fs";
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, describe, expect, it } from "vitest";
import { SessionArchiveStore } from "./sessionArchiveStore.js";
@@ -71,6 +71,101 @@ describe("SessionArchiveStore", () => {
expect(await exists(record.archivePath)).toBe(false);
await expect(store.list()).resolves.toEqual([]);
});
it("archives and permanently deletes sessions in batches", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-batch-"));
tempRoots.push(root);
const activeDir = join(root, "active");
await mkdir(activeDir, { recursive: true });
const sourceA = join(activeDir, "2026-01-01_a.jsonl");
const sourceB = join(activeDir, "2026-01-01_b.jsonl");
await writeFile(sourceA, "a\n", "utf8");
await writeFile(sourceB, "b\n", "utf8");
const store = new SessionArchiveStore(join(root, "archived-sessions.json"), join(root, "archived-files"));
const records = await store.archiveMany([
{
sessionId: "a",
cwd: "/workspace",
path: sourceA,
created: "2026-01-01T00:00:00.000Z",
modified: "2026-01-01T00:01:00.000Z",
messageCount: 1,
firstMessage: "a",
},
{
sessionId: "b",
cwd: "/workspace",
path: sourceB,
created: "2026-01-01T00:00:00.000Z",
modified: "2026-01-01T00:02:00.000Z",
messageCount: 2,
firstMessage: "b",
},
]);
expect(records.map((record) => record.sessionId)).toEqual(["a", "b"]);
expect(await exists(sourceA)).toBe(false);
expect(await exists(sourceB)).toBe(false);
await expect(store.list()).resolves.toMatchObject([{ sessionId: "a" }, { sessionId: "b" }]);
const archivePaths = records.map((record) => record.archivePath);
if (archivePaths.some((path) => path === undefined)) throw new Error("Expected archive paths");
await expect(store.deleteArchivedMany(["a", "b", "missing"])).resolves.toEqual(["a", "b"]);
for (const archivePath of archivePaths) {
if (archivePath === undefined) throw new Error("Expected archive path");
expect(await exists(archivePath)).toBe(false);
}
await expect(store.list()).resolves.toEqual([]);
});
it("prefers exact persisted session IDs over prefix matches and canonicalizes stored cwd", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-prefix-"));
tempRoots.push(root);
const archiveFile = join(root, "archived-sessions.json");
const rawCwd = join(root, "workspace", "..", "workspace");
await writeFile(archiveFile, JSON.stringify({
sessions: [
{
sessionId: "abc123",
cwd: rawCwd,
archivedAt: "2026-01-01T00:00:00.000Z",
originalPath: "/sessions/abc123.jsonl",
archivePath: "/archive/abc123.jsonl",
messageCount: 3,
firstMessage: "prefix",
name: "Prefix match",
parentSessionPath: "/sessions/root.jsonl",
},
{
sessionId: "abc",
cwd: rawCwd,
archivedAt: "2026-01-01T00:00:00.000Z",
originalPath: "/sessions/abc.jsonl",
archivePath: "/archive/abc.jsonl",
messageCount: 1,
firstMessage: "exact",
},
],
}), "utf8");
const store = new SessionArchiveStore(archiveFile, join(root, "archived-files"));
await expect(store.get("abc")).resolves.toMatchObject({
sessionId: "abc",
cwd: resolve(rawCwd),
firstMessage: "exact",
});
await expect(store.get("abc1")).resolves.toMatchObject({
sessionId: "abc123",
cwd: resolve(rawCwd),
firstMessage: "prefix",
name: "Prefix match",
parentSessionPath: "/sessions/root.jsonl",
});
await expect(store.isArchived("abc1")).resolves.toBe(true);
await expect(store.isArchived("missing")).resolves.toBe(false);
});
});
async function exists(path: string): Promise<boolean> {
+49 -19
View File
@@ -53,24 +53,39 @@ export class SessionArchiveStore {
}
async archive(session: ArchiveSessionInput): Promise<ArchivedSessionRecord> {
const [record] = await this.archiveMany([session]);
if (record === undefined) throw new Error("Archive operation did not produce a record");
return record;
}
async archiveMany(sessions: readonly ArchiveSessionInput[]): Promise<ArchivedSessionRecord[]> {
if (sessions.length === 0) return [];
return this.exclusive(async () => {
const data = await this.read();
const existingIndex = data.sessions.findIndex((record) => record.sessionId === session.sessionId);
const existing = existingIndex === -1 ? undefined : data.sessions[existingIndex];
const archivePath = existing?.archivePath ?? this.archivePathFor(session);
const record = archiveRecordFromInput(session, {
archivedAt: existing?.archivedAt ?? new Date().toISOString(),
originalPath: existing?.originalPath ?? session.path,
archivePath,
});
const records: ArchivedSessionRecord[] = [];
const filesToRemove: { source: string; archivePath: string }[] = [];
await copySessionFileToArchive(session.path, archivePath);
for (const session of sessions) {
const existingIndex = data.sessions.findIndex((record) => record.sessionId === session.sessionId);
const existing = existingIndex === -1 ? undefined : data.sessions[existingIndex];
const archivePath = existing?.archivePath ?? this.archivePathFor(session);
const record = archiveRecordFromInput(session, {
archivedAt: existing?.archivedAt ?? new Date().toISOString(),
originalPath: existing?.originalPath ?? session.path,
archivePath,
});
await copySessionFileToArchive(session.path, archivePath);
if (existingIndex === -1) data.sessions.push(record);
else data.sessions[existingIndex] = record;
records.push(record);
filesToRemove.push({ source: session.path, archivePath });
}
if (existingIndex === -1) data.sessions.push(record);
else data.sessions[existingIndex] = record;
await this.write(data);
await removeActiveSessionFile(session.path, archivePath);
return record;
for (const file of filesToRemove) await removeActiveSessionFile(file.source, file.archivePath);
return records;
});
}
@@ -90,14 +105,25 @@ export class SessionArchiveStore {
}
async deleteArchived(sessionId: string): Promise<void> {
await this.exclusive(async () => {
const data = await this.read();
const record = data.sessions.find((session) => session.sessionId === sessionId);
if (record === undefined) return;
await this.deleteArchivedMany([sessionId]);
}
if (record.archivePath !== undefined && await pathExists(record.archivePath)) await unlink(record.archivePath);
const sessions = data.sessions.filter((session) => session.sessionId !== sessionId);
async deleteArchivedMany(sessionIds: readonly string[]): Promise<string[]> {
const targetIds = uniqueStrings(sessionIds);
if (targetIds.length === 0) return [];
return this.exclusive(async () => {
const data = await this.read();
const targetIdSet = new Set(targetIds);
const records = data.sessions.filter((session) => targetIdSet.has(session.sessionId));
if (records.length === 0) return [];
for (const record of records) {
if (record.archivePath !== undefined && await pathExists(record.archivePath)) await unlink(record.archivePath);
}
const sessions = data.sessions.filter((session) => !targetIdSet.has(session.sessionId));
await this.write({ sessions });
const deletedIds = new Set(records.map((record) => record.sessionId));
return targetIds.filter((sessionId) => deletedIds.has(sessionId));
});
}
@@ -254,6 +280,10 @@ function safeFileName(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "session";
}
function uniqueStrings(values: readonly string[]): string[] {
return [...new Set(values)];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -11,11 +11,11 @@ function candidate(id: string, options: Partial<SessionArchiveTreeCandidate> = {
}
describe("session archive tree planning", () => {
it("finds candidates by full id or prefix", () => {
const candidates = [candidate("abcdef"), candidate("xyz")];
it("finds candidates by exact id before falling back to a prefix", () => {
const candidates = [candidate("abcdef"), candidate("abc"), candidate("xyz")];
expect(findArchiveCandidateByIdOrPrefix(candidates, "abcdef")?.id).toBe("abcdef");
expect(findArchiveCandidateByIdOrPrefix(candidates, "abc")?.id).toBe("abcdef");
expect(findArchiveCandidateByIdOrPrefix(candidates, "abc")?.id).toBe("abc");
expect(findArchiveCandidateByIdOrPrefix(candidates, "abcd")?.id).toBe("abcdef");
expect(findArchiveCandidateByIdOrPrefix(candidates, "missing")).toBeUndefined();
});
@@ -66,11 +66,15 @@ describe("SessionCommandService", () => {
await expect(service.run("s1", "/template arg")).resolves.toMatchObject({ type: "done" });
await expect(service.run("s1", "/skill:skill-a arg")).resolves.toMatchObject({ type: "done" });
expect(prompt).toHaveBeenCalledTimes(3);
expect(prompt).toHaveBeenNthCalledWith(1, "s1", "/ext arg");
expect(prompt).toHaveBeenNthCalledWith(2, "s1", "/template arg");
expect(prompt).toHaveBeenNthCalledWith(3, "s1", "/skill:skill-a arg");
});
it("renames sessions and returns updated client session metadata", async () => {
it("renames sessions, publishes the name update, and returns updated client session metadata", async () => {
const active = activeSession();
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher());
const events = eventPublisher();
const service = new SessionCommandService(() => getActive(active), vi.fn(), events);
await expect(service.run("s1", "/name Useful name")).resolves.toMatchObject({
type: "done",
@@ -78,6 +82,7 @@ describe("SessionCommandService", () => {
session: { id: "s1", cwd: "/work", name: "Useful name", messageCount: 2 },
});
expect(active.runtime.session.setSessionName).toHaveBeenCalledWith("Useful name");
expect(events.publish).toHaveBeenCalledWith("s1", { type: "session.name", sessionId: "s1", name: "Useful name" });
});
it("formats session stats", async () => {
@@ -90,22 +95,50 @@ describe("SessionCommandService", () => {
});
});
it("starts compaction and publishes completion", async () => {
it("starts compaction, updates lifecycle hooks, and publishes completion", async () => {
const active = activeSession();
const events = eventPublisher();
const service = new SessionCommandService(() => getActive(active), vi.fn(), events);
const onCompactionStart = vi.fn();
const onCompactionEnd = vi.fn();
const service = new SessionCommandService(() => getActive(active), vi.fn(), events, { onCompactionStart, onCompactionEnd });
await expect(service.run("s1", "/compact focus on tests")).resolves.toEqual({ type: "done", message: "Compaction started…" });
expect(onCompactionStart).toHaveBeenCalledWith(active.runtime.session);
await vi.waitFor(() => {
expect(events.publish).toHaveBeenCalledWith("s1", {
type: "command.output",
level: "success",
message: "Compaction complete.\nTokens before: 123\n\nshort summary",
});
expect(onCompactionEnd).toHaveBeenCalledWith(active.runtime.session, "success");
});
expect(active.runtime.session.compact).toHaveBeenCalledWith("focus on tests");
});
it("reloads runtime resources through the injected lifecycle callback", async () => {
const active = activeSession();
const reloadSession = vi.fn(async () => { await Promise.resolve(); });
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), { reloadSession });
await expect(service.run("s1", "/reload")).resolves.toEqual({
type: "done",
message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.",
});
expect(reloadSession).toHaveBeenCalledWith(active.runtime.session);
});
it("rejects runtime reload while the session has active work", async () => {
const active = activeSession({ isBashRunning: true });
const reloadSession = vi.fn(async () => { await Promise.resolve(); });
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), { reloadSession });
await expect(service.run("s1", "/reload")).resolves.toEqual({
type: "unsupported",
message: "Cannot reload while the session is active. Stop current activity before reloading.",
});
expect(reloadSession).not.toHaveBeenCalled();
});
it("creates fork selection requests from newest message to oldest and responds with selected entry", async () => {
const active = activeSession({
getUserMessagesForForking: vi.fn(() => [
+21 -4
View File
@@ -47,6 +47,12 @@ export interface CommandEventPublisher {
publishGlobal?(event: Extract<SessionUiEvent, { type: "session.name" }>): void;
}
export interface SessionCommandLifecycle<TSession extends CommandSession = CommandSession> {
onCompactionStart?: (session: TSession) => void;
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
reloadSession?: (session: TSession) => Promise<void>;
}
export interface SessionCommandNaming {
listSessionNames?: (cwd: string) => Promise<readonly string[]>;
}
@@ -65,10 +71,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
private readonly getActive: GetCommandActiveSession<TSession>,
private readonly prompt: (sessionId: string, text: string) => Promise<void>,
private readonly events: CommandEventPublisher,
private readonly lifecycle: {
onCompactionStart?: (session: TSession) => void;
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
} = {},
private readonly lifecycle: SessionCommandLifecycle<TSession> = {},
private readonly naming: SessionCommandNaming = {},
) {}
@@ -93,6 +96,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
if (name === "session") return { type: "done", message: formatSessionStats(session) };
if (name === "name") return this.nameSession(active, rest);
if (name === "compact") return this.compact(session, rest);
if (name === "reload") return this.reload(session);
if (name === "clone") return this.clone(active);
if (name === "fork") return this.fork(active);
@@ -140,6 +144,19 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
return { type: "done", message: "Compaction started…" };
}
private async reload(session: TSession): Promise<ClientCommandResult> {
if (sessionHasActiveWork(session)) return { type: "unsupported", message: "Cannot reload while the session is active. Stop current activity before reloading." };
if (this.lifecycle.reloadSession === undefined) return { type: "unsupported", message: "/reload is not available for this session runtime." };
try {
await this.lifecycle.reloadSession(session);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { type: "unsupported", message: `Reload failed: ${message}` };
}
return { type: "done", message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes." };
}
private async clone(active: CommandActiveSession<TSession>): Promise<ClientCommandResult> {
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
const leafId = active.runtime.session.sessionManager.getLeafId();
@@ -1,11 +1,89 @@
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { describe, expect, it } from "vitest";
import { cleanSessionName, fallbackSessionName } from "./sessionNameGenerator.js";
import { cleanSessionName, deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
function fakeModel(): Model<Api> {
return { id: "fake-model", name: "Fake Model", api: "anthropic-messages", provider: "anthropic", baseUrl: "https://example.test", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 100 };
}
function fakeAssistantMessage(overrides: Partial<AssistantMessage> = {}): AssistantMessage {
return {
role: "assistant",
content: [],
api: "anthropic-messages",
provider: "anthropic",
model: "fake-model",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "stop",
timestamp: Date.now(),
...overrides,
};
}
function streamThatCompletes(text: string): StreamFn {
return () => {
const stream = createAssistantMessageEventStream();
const message = fakeAssistantMessage({ content: [{ type: "text", text }] });
stream.push({ type: "done", reason: "stop", message });
stream.end(message);
return stream;
};
}
function streamThatErrors(): StreamFn {
return () => {
const stream = createAssistantMessageEventStream();
const message = fakeAssistantMessage({ stopReason: "error", errorMessage: "boom" });
stream.push({ type: "error", reason: "error", error: message });
stream.end(message);
return stream;
};
}
describe("sessionNameGenerator", () => {
it("generates a session name by calling the injected streamFn", async () => {
const calls: unknown[] = [];
const stream = streamThatCompletes('Title: "Fix the bug"');
const streamFn: StreamFn = (model, context, options) => {
calls.push({ model, context, options });
return stream(model, context, options);
};
const name = await generateShortSessionName(streamFn, fakeModel(), "Please fix the login bug");
expect(name).toBe("Fix the bug");
expect(calls).toHaveLength(1);
});
it("returns undefined when the stream reports an error", async () => {
const streamFn = streamThatErrors();
const name = await generateShortSessionName(streamFn, fakeModel(), "Please fix the login bug");
expect(name).toBeUndefined();
});
it("cleans model-generated titles", () => {
expect(cleanSessionName('Title: "Fix Session Naming."\nextra')).toBe("Fix Session Naming");
});
it("builds deterministic names for relay handoff prompts", () => {
expect(deterministicSessionName('Relay "handoff-check" leg 2 begins now.\n\nYou are the next runner.'))
.toBe("Relay handoff-check leg 2");
});
it("preserves the relay leg when truncating deterministic relay names", () => {
expect(deterministicSessionName('Relay "very-long-relay-name-that-would-otherwise-push-the-leg-number-out-of-view" leg 42 begins now.'))
.toBe("Relay very-long-relay-name-that-would-otherwise-push leg 42");
});
it("does not build deterministic names for non-canonical relay prompts", () => {
expect(deterministicSessionName('You are continuing Relay "handoff-check" under the Relay method.'))
.toBeUndefined();
});
it("builds a concise fallback from the first request", () => {
expect(fallbackSessionName("Seems like auto name for sessions is not working, I still get the first message as a name."))
.toBe("Seems like auto name for sessions");
+28 -54
View File
@@ -1,33 +1,20 @@
import type { Api, AssistantMessage, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
const SESSION_NAME_TIMEOUT_MS = 10_000;
const SESSION_NAME_MAX_INPUT_CHARS = 4_000;
const SESSION_NAME_MAX_LENGTH = 60;
const FALLBACK_SESSION_NAME_MAX_WORDS = 6;
const PI_AI_COMPAT_MODULE = ["@earendil-works/pi-ai", "compat"].join("/");
const RELAY_HANDOFF_FIRST_LINE = /^Relay\s+"([^"\n]+)"\s+leg\s+(\d+)\s+begins now\.?\s*(?:\n|$)/;
interface SessionNameApiProvider {
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
export function deterministicSessionName(firstMessage: unknown): string | undefined {
if (typeof firstMessage !== "string") return undefined;
return relayHandoffSessionName(firstMessage.trimStart());
}
interface PiAiProviderRegistryModule {
getApiProvider?: (api: Api) => SessionNameApiProvider | undefined;
}
type ModuleImporter = (specifier: string) => Promise<unknown>;
let piAiProviderRegistryModulePromise: Promise<PiAiProviderRegistryModule> | undefined;
export async function generateShortSessionName<TApi extends Api>(modelRegistry: ModelRegistry, model: Model<TApi>, firstMessage: string): Promise<string | undefined> {
const providerRegistry = await getPiAiProviderRegistryModule();
const provider = providerRegistry.getApiProvider?.(model.api);
if (provider === undefined) return undefined;
const auth = await modelRegistry.getApiKeyAndHeaders(model);
if (!auth.ok) return undefined;
const stream = provider.streamSimple(
export async function generateShortSessionName<TApi extends Api>(streamFn: StreamFn, model: Model<TApi>, firstMessage: string): Promise<string | undefined> {
const stream = await streamFn(
model,
{
systemPrompt: "Generate a concise title for a coding-agent chat session. Return only the title, with no quotes or punctuation wrapper.",
@@ -41,8 +28,6 @@ export async function generateShortSessionName<TApi extends Api>(modelRegistry:
maxTokens: 24,
reasoning: "minimal",
signal: AbortSignal.timeout(SESSION_NAME_TIMEOUT_MS),
...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }),
...(auth.headers === undefined ? {} : { headers: auth.headers }),
},
);
@@ -81,40 +66,29 @@ export function cleanSessionName(value: string): string | undefined {
return title === "" ? undefined : title;
}
async function getPiAiProviderRegistryModule(importer: ModuleImporter = (specifier) => import(specifier)): Promise<PiAiProviderRegistryModule> {
piAiProviderRegistryModulePromise ??= loadPiAiProviderRegistryModule(importer);
return piAiProviderRegistryModulePromise;
function relayHandoffSessionName(firstMessage: string): string | undefined {
const match = RELAY_HANDOFF_FIRST_LINE.exec(firstMessage);
if (match === null) return undefined;
const relayName = match[1]?.replace(/\s+/g, " ").trim();
const legNumber = match[2];
if (relayName === undefined || relayName === "" || legNumber === undefined) return undefined;
return cleanSessionName(formatRelaySessionName(relayName, legNumber));
}
async function loadPiAiProviderRegistryModule(importer: ModuleImporter): Promise<PiAiProviderRegistryModule> {
const compatModule = await importOptionalPiAiModule(PI_AI_COMPAT_MODULE, importer);
if (hasGetApiProvider(compatModule)) return compatModule;
const rootModule = await importer("@earendil-works/pi-ai");
if (hasGetApiProvider(rootModule)) return rootModule;
return {};
function formatRelaySessionName(relayName: string, legNumber: string): string {
const prefix = "Relay ";
const suffix = ` leg ${legNumber}`;
const maxRelayNameLength = Math.max(1, SESSION_NAME_MAX_LENGTH - prefix.length - suffix.length);
const displayedRelayName = truncateRelayName(relayName, maxRelayNameLength);
return `${prefix}${displayedRelayName}${suffix}`;
}
async function importOptionalPiAiModule(specifier: string, importer: ModuleImporter): Promise<unknown> {
try {
return await importer(specifier);
} catch (error) {
if (isModuleUnavailableError(error)) return undefined;
throw error;
}
}
function hasGetApiProvider(moduleValue: unknown): moduleValue is PiAiProviderRegistryModule {
return typeof moduleValue === "object"
&& moduleValue !== null
&& "getApiProvider" in moduleValue
&& typeof moduleValue.getApiProvider === "function";
}
function isModuleUnavailableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const code = "code" in error ? error.code : undefined;
return code === "ERR_MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED";
function truncateRelayName(relayName: string, maxLength: number): string {
if (relayName.length <= maxLength) return relayName;
const truncated = relayName.slice(0, maxLength).replace(/[\s._-]+$/g, "").trim();
return truncated === "" ? relayName.slice(0, maxLength).trim() : truncated;
}
function textFromAssistant(message: AssistantMessage): string {
+86 -2
View File
@@ -2,7 +2,7 @@ import { resolve } from "node:path";
import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js";
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
@@ -56,6 +56,32 @@ describe("session routes", () => {
}
});
it("omits thinking signatures from browser history without mutating service messages", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
const thinkingBlock = { type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true };
const message = { role: "assistant", content: [thinkingBlock, { type: "text", text: "visible answer" }] };
routeService.messagesResponse = { messages: [message], start: 0, total: 1 };
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const response = await routeApp.inject({ method: "GET", url: "/sessions/session-1/messages?limit=20" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "private chain", redacted: true }, { type: "text", text: "visible answer" }] }],
start: 0,
total: 1,
});
expect(thinkingBlock.thinkingSignature).toBe("opaque-provider-payload");
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("forwards prompt attachments and supports the save-attachments route", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
@@ -179,13 +205,59 @@ describe("session routes", () => {
await routeApp.close();
}
});
it("routes bulk archive and delete requests with normalized session refs", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const requestCwd = resolve("/repo");
const archiveResponse = await routeApp.inject({ method: "POST", url: "/sessions/bulk/archive", payload: { sessions: [{ id: "s1", cwd: requestCwd }, { id: "s2" }] } });
const deleteResponse = await routeApp.inject({ method: "POST", url: "/sessions/bulk/delete-archived", payload: { sessions: [{ id: "s1", cwd: requestCwd }] } });
expect(archiveResponse.statusCode).toBe(200);
expect(archiveResponse.json()).toMatchObject({ archived: true, archivedSessionIds: ["s1", "s2"], failures: [] });
expect(deleteResponse.statusCode).toBe(200);
expect(deleteResponse.json()).toMatchObject({ deleted: true, deletedSessionIds: ["s1"], failures: [] });
expect(routeService.bulkArchiveCalls).toEqual([[{ id: "s1", cwd: requestCwd }, { id: "s2" }]]);
expect(routeService.bulkDeleteCalls).toEqual([[{ id: "s1", cwd: requestCwd }]]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("rejects malformed bulk mutation bodies before calling the service", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const response = await routeApp.inject({ method: "POST", url: "/sessions/bulk/archive", payload: { sessions: [{ cwd: "/repo" }] } });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "id field must be a string" });
expect(routeService.bulkArchiveCalls).toEqual([]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
});
class CapturingRouteSessionService implements SessionRouteService {
readonly calls: unknown[] = [];
readonly reloadCalls: SessionRouteLookup[] = [];
messagesResponse: unknown[] | MessagePage = [];
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
reloadError: Error | undefined;
cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
@@ -198,6 +270,16 @@ class CapturingRouteSessionService implements SessionRouteService {
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: [], deletedSessionIds: [] });
}
archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse> {
this.bulkArchiveCalls.push([...refs]);
return Promise.resolve({ archived: true, archivedSessionIds: refs.map((ref) => ref.id), failures: [], generatedAt: "2026-06-25T00:00:00.000Z" });
}
deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkDeleteArchivedResponse> {
this.bulkDeleteCalls.push([...refs]);
return Promise.resolve({ deleted: true, deletedSessionIds: refs.map((ref) => ref.id), failures: [], generatedAt: "2026-06-25T00:00:00.000Z" });
}
reload(lookup: SessionRouteLookup): Promise<void> {
this.reloadCalls.push(lookup);
if (this.reloadError !== undefined) return Promise.reject(this.reloadError);
@@ -210,7 +292,9 @@ class CapturingRouteSessionService implements SessionRouteService {
list(): never { throw unusedRouteMethod("list"); }
start(): never { throw unusedRouteMethod("start"); }
messages(): Promise<unknown[]> { return Promise.resolve([]); }
messages(): Promise<unknown[] | MessagePage> {
return Promise.resolve(this.messagesResponse);
}
status(lookup: SessionRouteLookup) {
this.calls.push(lookup);
+37 -2
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance } from "fastify";
import type { SessionCleanupRequest } from "../../shared/apiTypes.js";
import type { SessionBulkMutationRequest, SessionBulkMutationRef, SessionCleanupRequest } from "../../shared/apiTypes.js";
import { projectBrowserMessageResponse } from "../browserMessageProjection.js";
import { normalizeRequestCwd } from "../workingDirectory.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
@@ -64,10 +65,27 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
}
});
app.post<{ Body: SessionBulkMutationRequest | undefined }>(`${prefix}/sessions/bulk/archive`, async (request, reply) => {
try {
return await sessions.archiveMany(bulkMutationRefsFromBody(request.body));
} catch (error) {
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Body: SessionBulkMutationRequest | undefined }>(`${prefix}/sessions/bulk/delete-archived`, async (request, reply) => {
try {
return await sessions.deleteArchivedMany(bulkMutationRefsFromBody(request.body));
} catch (error) {
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string }; Querystring: MessageQuery }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => {
try {
const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) };
return await sessions.messages(sessionLookupFromQuery(request.params.sessionId, request.query), page);
const messages = await sessions.messages(sessionLookupFromQuery(request.params.sessionId, request.query), page);
return projectBrowserMessageResponse(messages);
} catch (error) {
return reply.code(404).send({ error: errorMessage(error) });
}
@@ -281,6 +299,23 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
});
}
function bulkMutationRefsFromBody(body: SessionBulkMutationRequest | undefined): SessionBulkMutationRef[] {
const record = requireRecord(body);
const sessions = record["sessions"];
if (!Array.isArray(sessions)) throw new Error("sessions field must be an array");
return sessions.map(parseBulkMutationRef);
}
function parseBulkMutationRef(value: unknown): SessionBulkMutationRef {
const record = requireRecord(value);
const id = requireString(record, "id").trim();
if (id === "") throw new Error("id field must not be empty");
const cwd = record["cwd"];
if (cwd === undefined || cwd === "") return { id };
if (typeof cwd !== "string") throw new Error("cwd field must be a string");
return { id, cwd: normalizeRequestCwd(cwd) };
}
function sessionLookupFromQuery(id: string, query: SessionQuery): SessionLookup {
return sessionLookupFromCwd(id, query.cwd);
}
+8 -1
View File
@@ -1,4 +1,9 @@
import type { SavedPromptAttachment } from "../../shared/apiTypes.js";
import type {
SavedPromptAttachment,
SessionBulkArchiveResponse,
SessionBulkDeleteArchivedResponse,
SessionBulkMutationRef,
} from "../../shared/apiTypes.js";
import type {
ClientArchiveSessionsResponse,
ClientCommand,
@@ -40,6 +45,8 @@ export interface SessionRouteService {
saveAttachments(ref: SessionRouteLookup, attachments: unknown, folder?: string): Promise<SavedPromptAttachment[]>;
cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupPreviewResponse>;
cleanup(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupExecuteResponse>;
archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse>;
deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkDeleteArchivedResponse>;
shell(ref: SessionRouteLookup, text: string): Promise<void>;
runCommand(ref: SessionRouteLookup, text: string): Promise<ClientCommandResult>;
respondToCommand(ref: SessionRouteLookup, requestId: string, value: string): Promise<ClientCommandResult>;
+17 -8
View File
@@ -2,23 +2,32 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
// The spawn tool's execute() never reads ctx, so an empty stub is sufficient.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub; execute() does not use ctx.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads.
const ctx = {} as ExtensionContext;
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads.
const ctxWithModel = { model: dispatchModel } as ExtensionContext;
describe("createSpawnSessionToolDefinition", () => {
it("passes the spawning cwd and params to the spawn callback and reports success", async () => {
it("passes the spawning cwd, explicit cwd, dispatching model, and prompt to spawn callback", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" }));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctx);
const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctxWithModel);
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "do the thing", cwd: "/repos/a-feature" });
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "do the thing", cwd: "/repos/a-feature", model: dispatchModel });
expect(result.details).toEqual({ sessionId: "new-1", cwd: "/repos/a-feature" });
expect(result.content[0]).toMatchObject({ type: "text", text: "Started session new-1 in /repos/a-feature." });
expect(result.content[0]).toMatchObject({ type: "text", text: "Started independent session new-1 in /repos/a-feature." });
});
it("defaults cwd to undefined so the service falls back to the spawning cwd", async () => {
it("describes the independent-session capability without workflow policy", () => {
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn: vi.fn() });
expect(tool.description).toBe("Start a new independent pi-web session and send it an initial prompt. The session is not tracked by the caller, can be opened by a human, and runs without returning its later output to the caller.");
expect(tool.description).not.toMatch(/use this|continue work|follow a plan|relay/i);
});
it("forwards omitted cwd as undefined and omits a missing dispatching model", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" }));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
@@ -31,7 +40,7 @@ describe("createSpawnSessionToolDefinition", () => {
const spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a")));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
await expect(tool.execute("call-3", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctx))
await expect(tool.execute("call-4", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctx))
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /repos/a");
});
});
+14 -5
View File
@@ -1,15 +1,19 @@
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-agent";
export interface SpawnSessionResult {
sessionId: string;
cwd: string;
}
export type SpawnSessionModel = NonNullable<ExtensionContext["model"]>;
export interface SpawnSessionInvocation {
spawningCwd: string;
prompt: string;
cwd: string | undefined;
/** Current model from the dispatching session, used as the spawned session's default. */
model?: SpawnSessionModel;
}
export interface SpawnSessionToolDeps {
@@ -37,16 +41,21 @@ export function createSpawnSessionToolDefinition(spawningCwd: string, deps: Spaw
return defineTool<typeof SpawnSessionParams, SpawnSessionToolDetails>({
name: "spawn_session",
label: "Spawn session",
description: "Start a new, independent pi-web session and send it an initial prompt. Use this to dispatch a fresh agent to continue work or follow a plan. The new session runs on its own and a human can interact with it; you do not receive its output.",
description: "Start a new independent pi-web session and send it an initial prompt. The session is not tracked by the caller, can be opened by a human, and runs without returning its later output to the caller.",
promptSnippet: "spawn_session: start a new independent session with a first prompt",
parameters: SpawnSessionParams,
async execute(_toolCallId, params) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
// Failures throw: the agent loop turns the thrown message into an error
// tool result the model sees, so the spawning agent can adapt (e.g. pick a
// valid workspace) rather than crash.
const result = await deps.spawn({ spawningCwd, prompt: params.prompt, cwd: params.cwd });
const result = await deps.spawn({
spawningCwd,
prompt: params.prompt,
cwd: params.cwd,
...(ctx.model === undefined ? {} : { model: ctx.model }),
});
return {
content: [{ type: "text", text: `Started session ${result.sessionId} in ${result.cwd}.` }],
content: [{ type: "text", text: `Started independent session ${result.sessionId} in ${result.cwd}.` }],
details: result,
};
},
@@ -3,11 +3,13 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { createSubsessionToolDefinitions, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
function ctxFor(sessionId: string, sessionFile: string | undefined): ExtensionContext {
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown): ExtensionContext {
const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => sessionFile };
// The subsession tools only read sessionManager.getSessionId/getSessionFile.
// The subsession tools only read sessionManager.getSessionId/getSessionFile and model.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools use.
return { sessionManager } as unknown as ExtensionContext;
return { sessionManager, ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext;
}
function tools(deps: Partial<SubsessionToolDeps>) {
@@ -36,7 +38,7 @@ describe("createSubsessionToolDefinitions", () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" }));
const { spawn: spawnTool } = tools({ spawn });
const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel));
expect(spawn).toHaveBeenCalledWith({
spawningCwd: "/repos/a",
@@ -44,9 +46,48 @@ describe("createSubsessionToolDefinitions", () => {
parentSessionFile: "/sessions/parent-1.jsonl",
prompt: "do it",
cwd: "/repos/a-feature",
model: dispatchModel,
});
expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" });
expect(firstText(result.content)).toContain("Started subsession child-1");
expect(firstText(result.content)).toContain("Started tracked subsession child-1");
});
it("guides the parent to join all required subsessions without polling", async () => {
const { spawn: spawnTool } = tools({
spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" })),
});
expect(spawnTool.description).toBe("Start a tracked child and return after dispatch. Track required children as pending: continue independent work, then yield at a join point until all have notified completion. Notifications queue while the parent is busy; do not poll for completion.");
expect(spawnTool.promptSnippet).toBe("spawn_subsession: delegate parallel work; yield at a join point until all required children complete.");
const result = await spawnTool.execute("call-contract", { prompt: "do it" }, undefined, undefined, ctxFor("parent-1", undefined));
expect(firstText(result.content)).toBe("Started tracked subsession child-1 in /repos/a-feature. Track it as pending and, before finalizing dependent work, yield until all required children have notified completion.");
});
it("keeps subsession inspection tool descriptions capability-oriented", () => {
const definitions = tools({});
expect(definitions.list.description).toBe("List tracked child sessions owned by the calling session, with each child's current status (working, idle, error, or unknown).");
expect(definitions.check.description).toBe("Return a tracked subsession's current status, message count, and most recent assistant output.");
expect(definitions.read.description).toBe("Return a filtered, paginated transcript of a tracked subsession. Filters select message roles and content kinds, search full message content, optionally include raw tool arguments, and cap or page the returned entries.");
for (const definition of [definitions.list, definitions.check, definitions.read]) {
expect(definition.description).not.toMatch(/use this|do not poll|continue working|start narrow|for just the final|relay/i);
}
});
it("spawn_subsession omits the inherited model when the dispatching session has no current model", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-2", cwd: "/repos/a" }));
const { spawn: spawnTool } = tools({ spawn });
await spawnTool.execute("call-modeless", { prompt: "do it" }, undefined, undefined, ctxFor("parent-1", undefined));
expect(spawn).toHaveBeenCalledWith({
spawningCwd: "/repos/a",
parentSessionId: "parent-1",
parentSessionFile: undefined,
prompt: "do it",
cwd: undefined,
});
});
it("list_subsessions reports the caller's subsessions and their status", async () => {
@@ -69,7 +110,7 @@ describe("createSubsessionToolDefinitions", () => {
it("list_subsessions reports an empty state", async () => {
const { list: listTool } = tools({ list: vi.fn(() => Promise.resolve([])) });
const result = await listTool.execute("call-3", {}, undefined, undefined, ctxFor("parent-1", undefined));
expect(result.content[0]).toMatchObject({ type: "text", text: "You have not spawned any subsessions." });
expect(result.content[0]).toMatchObject({ type: "text", text: "No tracked subsessions." });
});
it("check_subsession scopes by parent and returns the final result", async () => {
+27 -16
View File
@@ -1,5 +1,5 @@
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { TranscriptContentKind, TranscriptEntry, TranscriptRole, TranscriptView } from "./subsessionTranscript.js";
/** Lifecycle phase of a tracked subsession as seen by its parent. */
@@ -10,6 +10,8 @@ export interface SpawnSubsessionResult {
cwd: string;
}
export type SpawnSubsessionModel = NonNullable<ExtensionContext["model"]>;
export interface SpawnSubsessionInvocation {
/** cwd of the session that invoked the tool (used for project-scope checks). */
spawningCwd: string;
@@ -19,6 +21,8 @@ export interface SpawnSubsessionInvocation {
parentSessionFile: string | undefined;
prompt: string;
cwd: string | undefined;
/** Current model from the dispatching session, used as the spawned session's default. */
model?: SpawnSubsessionModel;
}
export interface SubsessionSummary {
@@ -74,13 +78,13 @@ const ListSubsessionsParams = Type.Object({});
const CheckSubsessionParams = Type.Object({
sessionId: Type.String({
description: "Id of a subsession you spawned (as returned by spawn_subsession or list_subsessions).",
description: "Id of a tracked subsession owned by the calling session, as returned by spawn_subsession or list_subsessions.",
}),
});
const ReadSubsessionParams = Type.Object({
sessionId: Type.String({
description: "Id of a subsession you spawned (as returned by spawn_subsession or list_subsessions).",
description: "Id of a tracked subsession owned by the calling session, as returned by spawn_subsession or list_subsessions.",
}),
roles: Type.Optional(Type.Array(
Type.Union([Type.Literal("assistant"), Type.Literal("user"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("custom")]),
@@ -122,7 +126,7 @@ function renderEntry(entry: TranscriptEntry): string {
function clipNotice(part: TranscriptEntry["parts"][number]): string {
if ((part.kind === "text" || part.kind === "thinking" || part.kind === "tool_result") && part.truncated !== undefined) {
return ` [+${String(part.truncated.full - part.truncated.shown)} chars truncated; re-read with a larger maxChars]`;
return ` [+${String(part.truncated.full - part.truncated.shown)} chars truncated]`;
}
return "";
}
@@ -149,15 +153,15 @@ function renderTranscript(result: SubsessionReadResult): string {
? "no messages matched your filters"
: `no messages in this window (${String(result.matched)} matched outside it)`)
: `messages ${String(result.start)}${String(last.index)} of ${String(result.total)} (${String(result.matched)} matched)`;
const more = result.hasMore ? `\n\nMore matching messages exist earlier; page back with before: ${String(result.start)}.` : "";
const more = result.hasMore ? `\n\nEarlier matching messages exist before index ${String(result.start)}.` : "";
// Empty entries with matches means the `before` cursor excluded every match
// (they all sit at index >= before): the agent paged too far back and should
// raise `before` or omit it, not page back further.
const body = result.entries.length > 0
? result.entries.map(renderEntry).join("\n\n")
: (result.matched === 0
? "(nothing matched; try widening roles/include, dropping search, or raising limit)"
: `(no messages before index ${String(result.start)}; all ${String(result.matched)} matches are later — raise 'before' or omit it)`);
? "(no messages matched the filters)"
: `(no messages before index ${String(result.start)}; all ${String(result.matched)} matches have later indexes)`);
return `Subsession ${result.sessionId} [${result.status}] — ${range}:\n\n${body}${more}`;
}
@@ -174,15 +178,22 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const spawnTool = defineTool<typeof SpawnSubsessionParams, SpawnSubsessionResult>({
name: "spawn_subsession",
label: "Spawn subsession",
description: "Start a tracked child session and send it an initial prompt. The subsession runs independently and a human can interact with it, but unlike spawn_session it is linked to you: you are notified when it stops working (finished, idle, or errored), and you can inspect it with list_subsessions, check_subsession (a quick glance at its latest output), and read_subsession (read through its transcript). Use this to delegate work you intend to follow up on.",
promptSnippet: "spawn_subsession: start a tracked child session you will be notified about",
description: "Start a tracked child and return after dispatch. Track required children as pending: continue independent work, then yield at a join point until all have notified completion. Notifications queue while the parent is busy; do not poll for completion.",
promptSnippet: "spawn_subsession: delegate parallel work; yield at a join point until all required children complete.",
parameters: SpawnSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const result = await deps.spawn({ spawningCwd, parentSessionId, parentSessionFile, prompt: params.prompt, cwd: params.cwd });
const result = await deps.spawn({
spawningCwd,
parentSessionId,
parentSessionFile,
prompt: params.prompt,
cwd: params.cwd,
...(ctx.model === undefined ? {} : { model: ctx.model }),
});
return {
content: [{ type: "text", text: `Started subsession ${result.sessionId} in ${result.cwd}. You will be notified when it stops working.` }],
content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. Track it as pending and, before finalizing dependent work, yield until all required children have notified completion.` }],
details: result,
};
},
@@ -191,7 +202,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const listTool = defineTool<typeof ListSubsessionsParams, { subsessions: SubsessionSummary[] }>({
name: "list_subsessions",
label: "List subsessions",
description: "List the tracked subsessions you spawned, with their current status (working, idle, error, or unknown).",
description: "List tracked child sessions owned by the calling session, with each child's current status (working, idle, error, or unknown).",
promptSnippet: "list_subsessions: see the tracked child sessions you spawned",
parameters: ListSubsessionsParams,
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
@@ -199,8 +210,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const subsessions = await deps.list(parentSessionId, parentSessionFile);
const text = subsessions.length === 0
? "You have not spawned any subsessions."
: `Your subsessions:\n${subsessions.map(statusLine).join("\n")}`;
? "No tracked subsessions."
: `Tracked subsessions:\n${subsessions.map(statusLine).join("\n")}`;
return { content: [{ type: "text", text }], details: { subsessions } };
},
});
@@ -208,7 +219,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const checkTool = defineTool<typeof CheckSubsessionParams, SubsessionCheckResult>({
name: "check_subsession",
label: "Check subsession",
description: "Quick glance at a subsession you spawned: its current status and most recent assistant output. Use this to react to what a subsession produced. When the summary is not enough, use read_subsession to look through its full transcript.",
description: "Return a tracked subsession's current status, message count, and most recent assistant output.",
promptSnippet: "check_subsession: glance at a subsession's status and latest output",
parameters: CheckSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -226,7 +237,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const readTool = defineTool<typeof ReadSubsessionParams, SubsessionReadResult>({
name: "read_subsession",
label: "Read subsession",
description: "Read through the transcript of a subsession you spawned. Returns its messages filtered and paginated however you ask: choose which roles (assistant, user, tool, system, custom) and content kinds (text, thinking, tool_call, tool_result, image) to include, search for a substring (always over full content), cap each value's length with maxChars (omit for full text; clipped parts are flagged so truncation is never silent), optionally include raw tool args, and page backward with 'before'/'limit'. Start narrow (e.g. assistant text with a small maxChars) and widen the filters, raise maxChars, or page further back if you don't find what you need. For just the final result, use check_subsession instead.",
description: "Return a filtered, paginated transcript of a tracked subsession. Filters select message roles and content kinds, search full message content, optionally include raw tool arguments, and cap or page the returned entries.",
promptSnippet: "read_subsession: read through a subsession's transcript with filters",
parameters: ReadSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -81,12 +81,12 @@ describe("buildTranscriptView", () => {
expect(callPart.args).toEqual({ command: "ls" });
});
it("search keeps only matching entries across text and tool names", () => {
const messages = [assistant("the auth flow"), assistant("unrelated"), toolResult("error in auth.ts", "read")];
it("search keeps only entries matching text or tool-call names", () => {
const messages = [assistant("the auth flow"), assistant("unrelated"), toolResult("error in auth.ts", "read"), toolCall("auth-search")];
const view = buildTranscriptView(messages, { search: "auth" });
expect(view.matched).toBe(2);
expect(view.entries.map((entry) => entry.index)).toEqual([0, 2]);
expect(view.matched).toBe(3);
expect(view.entries.map((entry) => entry.index)).toEqual([0, 2, 3]);
});
it("search runs against full content even when maxChars would clip the match away", () => {
+27 -2
View File
@@ -43,7 +43,7 @@ describe("terminal routes", () => {
expect(terminals.events).toEqual([`close-cwd:${requestCwd}`]);
});
it("creates and lists terminal command runs with filters", async () => {
it("routes command-run create, get, filter, cancel, and terminal continue requests", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/terminal-command-runs",
@@ -51,7 +51,16 @@ describe("terminal routes", () => {
});
expect(createResponse.statusCode).toBe(200);
expect(createResponse.json<TerminalCommandRun>()).toMatchObject({ id: "run1", terminalId: "t-run", status: "running" });
const createdRun = createResponse.json<TerminalCommandRun>();
expect(createdRun).toMatchObject({ id: "run1", terminalId: "t-run", status: "running" });
const getResponse = await app.inject({ method: "GET", url: "/terminal-command-runs/run1" });
expect(getResponse.statusCode).toBe(200);
expect(getResponse.json<TerminalCommandRun>()).toEqual(createdRun);
const missingGetResponse = await app.inject({ method: "GET", url: "/terminal-command-runs/missing" });
expect(missingGetResponse.statusCode).toBe(404);
expect(missingGetResponse.json()).toEqual({ error: "Terminal command run not found" });
const listResponse = await app.inject({ method: "GET", url: `/terminal-command-runs?projectId=p1&statuses=running&metadata=${encodeURIComponent(JSON.stringify({ "pi.operation": "test" }))}` });
@@ -67,6 +76,22 @@ describe("terminal routes", () => {
expect(continueResponse.statusCode).toBe(200);
expect(terminals.events).toContain("continue:t-run");
});
it("rejects invalid command-run filter and metadata queries", async () => {
const invalidStatusResponse = await app.inject({ method: "GET", url: "/terminal-command-runs?statuses=running,stuck" });
expect(invalidStatusResponse.statusCode).toBe(400);
expect(invalidStatusResponse.json()).toEqual({ error: "Invalid command run status: stuck" });
const arrayMetadataResponse = await app.inject({ method: "GET", url: `/terminal-command-runs?metadata=${encodeURIComponent(JSON.stringify(["not", "an", "object"]))}` });
expect(arrayMetadataResponse.statusCode).toBe(400);
expect(arrayMetadataResponse.json()).toEqual({ error: "metadata filter must be an object" });
const nonStringMetadataResponse = await app.inject({ method: "GET", url: `/terminal-command-runs?metadata=${encodeURIComponent(JSON.stringify({ "pi.operation": 42 }))}` });
expect(nonStringMetadataResponse.statusCode).toBe(400);
expect(nonStringMetadataResponse.json()).toEqual({ error: "metadata filter value must be a string: pi.operation" });
expect(terminals.filters).toEqual([]);
});
});
class FakeTerminals implements TerminalRouteService {
@@ -1,4 +1,7 @@
import { describe, expect, it } from "vitest";
import type { RealtimeEvent, TerminalInfo } from "../../shared/apiTypes.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { TerminalService } from "./terminalService";
// TerminalService spawns a POSIX shell (/bin/bash with -lc and commands like
@@ -90,8 +93,93 @@ describe.skipIf(process.platform === "win32")("TerminalService command runs", ()
service.dispose();
}
});
it("publishes terminal lifecycle events and workspace activity updates", async () => {
const events = new RecordingEventHub();
const workspaceActivity = createWorkspaceActivityRecorder();
const service = new TerminalService(events, workspaceActivity);
const cwd = process.cwd();
try {
const run = service.runCommand({
origin: "core",
projectId: "p1",
workspaceId: "w1",
cwd,
title: "Lifecycle command",
command: "true",
});
const runningTerminal = requireTerminal(service, run.terminalId);
expect(workspaceActivity.updated).toEqual([{ id: run.terminalId, cwd, exited: false }]);
expect(events.events).toEqual([{ type: "terminal.created", terminal: runningTerminal }]);
await terminalExit(service, run.terminalId);
const exitedTerminal = requireTerminal(service, run.terminalId);
expect(workspaceActivity.updated).toEqual([
{ id: run.terminalId, cwd, exited: false },
{ id: run.terminalId, cwd, exited: true },
]);
expect(events.events).toEqual([
{ type: "terminal.created", terminal: runningTerminal },
{ type: "terminal.exited", terminal: exitedTerminal },
]);
service.close(run.terminalId);
expect(workspaceActivity.removed).toEqual([{ terminalId: run.terminalId, cwd }]);
expect(events.events).toEqual([
{ type: "terminal.created", terminal: runningTerminal },
{ type: "terminal.exited", terminal: exitedTerminal },
{ type: "terminal.closed", terminalId: run.terminalId, cwd },
]);
} finally {
service.dispose();
}
});
});
class RecordingEventHub extends SessionEventHub {
readonly events: RealtimeEvent[] = [];
override publishRealtime(event: RealtimeEvent): void {
this.events.push(event);
}
}
interface WorkspaceActivityRecorder extends Pick<WorkspaceActivityService, "updateTerminal" | "removeTerminal"> {
readonly updated: TerminalActivityUpdate[];
readonly removed: TerminalActivityRemoval[];
}
type TerminalActivityUpdate = Pick<TerminalInfo, "id" | "cwd" | "exited">;
interface TerminalActivityRemoval {
terminalId: string;
cwd: string | undefined;
}
function createWorkspaceActivityRecorder(): WorkspaceActivityRecorder {
const updated: TerminalActivityUpdate[] = [];
const removed: TerminalActivityRemoval[] = [];
return {
updated,
removed,
updateTerminal: (terminal) => {
updated.push({ id: terminal.id, cwd: terminal.cwd, exited: terminal.exited });
},
removeTerminal: (terminalId, cwd) => {
removed.push({ terminalId, cwd });
},
};
}
function requireTerminal(service: TerminalService, terminalId: string): TerminalInfo {
const terminal = service.get(terminalId);
if (terminal === undefined) throw new Error(`Expected terminal ${terminalId} to exist`);
return terminal;
}
function terminalReplay(service: TerminalService, terminalId: string): Promise<string> {
let output = "";
const detach = service.attach(terminalId, {
+107 -12
View File
@@ -1,38 +1,115 @@
import { afterEach, describe, expect, it } from "vitest";
import { WebSocket, WebSocketServer, type RawData } from "ws";
import { createBufferedSender } from "./webSocketBridge.js";
import { bridgeSockets, createBufferedSender } from "./webSocketBridge.js";
let server: WebSocketServer | undefined;
const servers = new Set<WebSocketServer>();
const sockets = new Set<WebSocket>();
afterEach(async () => {
const socketServer = server;
if (socketServer === undefined) return;
await new Promise<void>((resolve) => {
socketServer.close(() => { resolve(); });
for (const socket of sockets) closeSocket(socket);
await Promise.all(Array.from(servers, closeSocketServer));
sockets.clear();
servers.clear();
});
describe("bridgeSockets", () => {
it("forwards messages in both directions while sockets are open", async () => {
const clientSide = await createSocketPair();
const upstreamSide = await createSocketPair();
bridgeSockets(clientSide.bridgeSocket, upstreamSide.bridgeSocket);
const forwardedToUpstream = nextMessage(upstreamSide.peerSocket);
clientSide.peerSocket.send("to-upstream");
await expect(forwardedToUpstream).resolves.toBe("to-upstream");
const forwardedToClient = nextMessage(clientSide.peerSocket);
upstreamSide.peerSocket.send("to-client");
await expect(forwardedToClient).resolves.toBe("to-client");
});
it("propagates close and error events to the opposite socket", async () => {
const closeCaseClientSide = await createSocketPair();
const closeCaseUpstreamSide = await createSocketPair();
bridgeSockets(closeCaseClientSide.bridgeSocket, closeCaseUpstreamSide.bridgeSocket);
const upstreamClosed = nextClose(closeCaseUpstreamSide.peerSocket);
closeCaseClientSide.peerSocket.close();
await upstreamClosed;
const errorCaseClientSide = await createSocketPair();
const errorCaseUpstreamSide = await createSocketPair();
bridgeSockets(errorCaseClientSide.bridgeSocket, errorCaseUpstreamSide.bridgeSocket);
const clientClosed = nextClose(errorCaseClientSide.peerSocket);
errorCaseUpstreamSide.bridgeSocket.emit("error", new Error("upstream failed"));
await clientClosed;
});
server = undefined;
});
describe("createBufferedSender", () => {
it("queues messages while a WebSocket is still connecting", async () => {
const socketServer = new WebSocketServer({ host: "127.0.0.1", port: 0 });
server = socketServer;
const socketServer = createServer();
const connected = new Promise<WebSocket>((resolve) => {
socketServer.once("connection", resolve);
socketServer.once("connection", (socket) => {
sockets.add(socket);
resolve(socket);
});
});
await waitForListening(socketServer);
const client = new WebSocket(serverUrl(socketServer));
sockets.add(client);
const send = createBufferedSender(client);
send("queued-before-open");
const serverSocket = await connected;
await expect(nextMessage(serverSocket)).resolves.toBe("queued-before-open");
client.close();
serverSocket.close();
closeSocket(client);
closeSocket(serverSocket);
});
});
interface SocketPair {
bridgeSocket: WebSocket;
peerSocket: WebSocket;
}
async function createSocketPair(): Promise<SocketPair> {
const socketServer = createServer();
const connected = new Promise<WebSocket>((resolve) => {
socketServer.once("connection", (socket) => {
sockets.add(socket);
resolve(socket);
});
});
await waitForListening(socketServer);
const peerSocket = new WebSocket(serverUrl(socketServer));
sockets.add(peerSocket);
const opened = nextOpen(peerSocket);
const bridgeSocket = await connected;
await opened;
return { bridgeSocket, peerSocket };
}
function createServer(): WebSocketServer {
const socketServer = new WebSocketServer({ host: "127.0.0.1", port: 0 });
servers.add(socketServer);
return socketServer;
}
function closeSocket(socket: WebSocket): void {
if (socket.readyState !== WebSocket.CONNECTING && socket.readyState !== WebSocket.OPEN) return;
socket.close();
}
function closeSocketServer(socketServer: WebSocketServer): Promise<void> {
return new Promise<void>((resolve) => {
socketServer.close(() => { resolve(); });
});
}
function waitForListening(socketServer: WebSocketServer): Promise<void> {
if (socketServer.address() !== null) return Promise.resolve();
return new Promise((resolve, reject) => {
@@ -50,6 +127,24 @@ function serverUrl(socketServer: WebSocketServer): string {
return `ws://127.0.0.1:${String(address.port)}`;
}
function nextOpen(socket: WebSocket): Promise<void> {
if (socket.readyState === WebSocket.OPEN) return Promise.resolve();
return new Promise((resolve, reject) => {
socket.once("error", reject);
socket.once("open", () => {
socket.off("error", reject);
resolve();
});
});
}
function nextClose(socket: WebSocket): Promise<void> {
if (socket.readyState === WebSocket.CLOSED) return Promise.resolve();
return new Promise((resolve) => {
socket.once("close", () => { resolve(); });
});
}
function nextMessage(socket: WebSocket): Promise<string> {
return new Promise((resolve) => {
socket.once("message", (data) => {
+2 -4
View File
@@ -13,8 +13,7 @@ describe("normalizeRequestCwd", () => {
expect(normalizeRequestCwd(join(absoluteBase, ".", "nested", ".."))).toBe(absoluteBase);
});
it("treats Windows backslash and forward-slash paths as equal", () => {
if (process.platform !== "win32") return;
it.skipIf(process.platform !== "win32")("treats Windows backslash and forward-slash paths as equal", () => {
expect(normalizeRequestCwd("C:/Users/dev/project")).toBe("C:\\Users\\dev\\project");
});
@@ -47,8 +46,7 @@ describe("cwdPathsEqual", () => {
expect(cwdPathsEqual(absoluteBase, join(absoluteBase, "."))).toBe(true);
});
it("treats Windows backslash and forward-slash paths as equal", () => {
if (process.platform !== "win32") return;
it.skipIf(process.platform !== "win32")("treats Windows backslash and forward-slash paths as equal", () => {
expect(cwdPathsEqual("C:\\Users\\dev\\project", "C:/Users/dev/project")).toBe(true);
});
@@ -0,0 +1,81 @@
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { deleteWorkspaceFile, readWorkspaceFile } from "./fileContentService.js";
import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js";
afterEach(async () => {
await cleanupTempWorkspaces();
});
describe("deleteWorkspaceFile", () => {
it("deletes an existing file and returns existed: true", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "notes.txt"), "hello");
const result = await deleteWorkspaceFile(root, "notes.txt");
expect(result).toMatchObject({ path: "notes.txt", existed: true });
await expect(readWorkspaceFile(root, "notes.txt")).rejects.toThrow("Path does not exist");
});
it("returns existed: false when deleting a non-existent file", async () => {
const root = await createTempWorkspace();
const result = await deleteWorkspaceFile(root, "missing.txt");
expect(result).toMatchObject({ path: "missing.txt", existed: false });
});
it("rejects deleting a directory", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory");
});
it("rejects traversal and absolute paths", async () => {
const root = await createTempWorkspace();
await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
await expect(deleteWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
});
it("rejects missing path", async () => {
const root = await createTempWorkspace();
await expect(deleteWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
await expect(deleteWorkspaceFile(root, "")).rejects.toThrow("path query parameter is required");
});
it("deletes a symlink itself, not its target", async () => {
const root = await createTempWorkspace();
const outsideDir = await createTempWorkspace("pi-web-outside-delete-");
await writeFile(join(outsideDir, "real.txt"), "real content");
// Create a symlink inside the workspace pointing outside
await symlink(join(outsideDir, "real.txt"), join(root, "link.txt"));
const result = await deleteWorkspaceFile(root, "link.txt");
expect(result).toMatchObject({ path: "link.txt", existed: true });
// The symlink should be gone, but the target file should still exist
await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow("Path does not exist");
const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
expect(realContent).toBe("real content");
});
it("prevents deleting through a symlinked parent directory that escapes the workspace", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
// A real file living outside the workspace that must not be deletable.
const outsideDir = await createTempWorkspace("pi-web-outside-delete-parent-");
await writeFile(join(outsideDir, "victim.txt"), "important");
// A symlinked parent directory inside the workspace pointing outside.
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
await expect(deleteWorkspaceFile(root, "subdir/escape/victim.txt")).rejects.toThrow("Path escapes workspace");
// The outside file must survive.
const realContent = await readFile(join(outsideDir, "victim.txt"), "utf8");
expect(realContent).toBe("important");
});
});
@@ -0,0 +1,127 @@
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { moveWorkspaceFile, readWorkspaceFile } from "./fileContentService.js";
import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js";
afterEach(async () => {
await cleanupTempWorkspaces();
});
describe("moveWorkspaceFile", () => {
it("moves a file to a new path", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "original.txt"), "content");
const result = await moveWorkspaceFile(root, "original.txt", "moved.txt");
expect(result).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
expect(result.size).toBe(7);
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
// Source should no longer exist
await expect(readWorkspaceFile(root, "original.txt")).rejects.toThrow("Path does not exist");
// Target should exist
const target = await readWorkspaceFile(root, "moved.txt");
expect(target.content).toBe("content");
});
it("creates intermediate directories by default", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "file.txt"), "data");
await moveWorkspaceFile(root, "file.txt", "deep/nested/dir/file.txt");
const target = await readWorkspaceFile(root, "deep/nested/dir/file.txt");
expect(target.content).toBe("data");
});
it("fails when createDirs is false and parent directory does not exist", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "file.txt"), "data");
await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow();
const source = await readWorkspaceFile(root, "file.txt");
expect(source.content).toBe("data");
});
it("overwrites target when overwrite is true", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "source.txt"), "source content");
await writeFile(join(root, "target.txt"), "target content");
const result = await moveWorkspaceFile(root, "source.txt", "target.txt", { overwrite: true });
expect(result.toPath).toBe("target.txt");
const target = await readWorkspaceFile(root, "target.txt");
expect(target.content).toBe("source content");
});
it("throws when target exists and overwrite is false (default)", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "source.txt"), "source");
await writeFile(join(root, "target.txt"), "target");
await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists");
// Source and target should remain unchanged
const source = await readWorkspaceFile(root, "source.txt");
expect(source.content).toBe("source");
const target = await readWorkspaceFile(root, "target.txt");
expect(target.content).toBe("target");
});
it("rejects source path traversal", async () => {
const root = await createTempWorkspace();
await expect(moveWorkspaceFile(root, "../secret.txt", "target.txt")).rejects.toThrow("Path traversal is not allowed");
});
it("rejects target path traversal", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "source.txt"), "data");
await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
const source = await readWorkspaceFile(root, "source.txt");
expect(source.content).toBe("data");
});
it("rejects moving a directory", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(moveWorkspaceFile(root, "mydir", "newdir")).rejects.toThrow("Source path is not a file");
});
it("rejects missing fromPath or toPath", async () => {
const root = await createTempWorkspace();
await expect(moveWorkspaceFile(root, undefined, "target.txt")).rejects.toThrow("fromPath query parameter is required");
await expect(moveWorkspaceFile(root, "source.txt", undefined)).rejects.toThrow("toPath query parameter is required");
await expect(moveWorkspaceFile(root, "", "target.txt")).rejects.toThrow("fromPath query parameter is required");
await expect(moveWorkspaceFile(root, "source.txt", "")).rejects.toThrow("toPath query parameter is required");
});
it("prevents moving through symlinks that escape the workspace", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
await writeFile(join(root, "subdir", "file.txt"), "data");
// Create a symlink inside the workspace that points outside
const outsideDir = await createTempWorkspace("pi-web-move-outside-");
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow("Path escapes workspace");
const source = await readWorkspaceFile(root, "subdir/file.txt");
expect(source.content).toBe("data");
await expect(readFile(join(outsideDir, "evil.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
});
it("prevents moving a source symlink that escapes the workspace", async () => {
const root = await createTempWorkspace();
const outsideDir = await createTempWorkspace("pi-web-move-source-outside-");
await writeFile(join(outsideDir, "secret.txt"), "secret");
await symlink(join(outsideDir, "secret.txt"), join(root, "source-link.txt"));
await expect(moveWorkspaceFile(root, "source-link.txt", "moved.txt")).rejects.toThrow("Path escapes workspace");
await expect(readWorkspaceFile(root, "moved.txt")).rejects.toThrow("Path does not exist");
await expect(readFile(join(outsideDir, "secret.txt"), "utf8")).resolves.toBe("secret");
});
});
@@ -0,0 +1,107 @@
import { mkdir, truncate, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js";
import { readWorkspaceFile } from "./fileContentService.js";
import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js";
import { readWorkspaceImagePreview } from "./imagePreviewService.js";
afterEach(async () => {
await cleanupTempWorkspaces();
});
describe("readWorkspaceFile", () => {
it("reads text files with normalized paths and language metadata", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "src"));
await writeFile(join(root, "src", "main.ts"), "const answer = 42;\n");
const file = await readWorkspaceFile(root, "./src//main.ts");
expect(file).toMatchObject({
path: "src/main.ts",
language: "typescript",
encoding: "utf8",
content: "const answer = 42;\n",
truncated: false,
binary: false,
});
expect(file.size).toBe(19);
expect(Date.parse(file.modifiedAt)).not.toBeNaN();
});
it("rejects missing paths, directories, traversal, and absolute paths", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "dir"));
await expect(readWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
await expect(readWorkspaceFile(root, "dir")).rejects.toThrow("Path is not a file");
await expect(readWorkspaceFile(root, "missing.txt")).rejects.toThrow("Path does not exist");
await expect(readWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
});
it("reads allowed absolute files outside the workspace", async () => {
const root = await createTempWorkspace();
const external = await createTempWorkspace();
await writeFile(join(external, "README.md"), "external docs\n");
const file = await readWorkspaceFile(root, join(external, "README.md"), { allowedPaths: [external] });
expect(file).toMatchObject({
path: join(external, "README.md"),
language: "markdown",
content: "external docs\n",
truncated: false,
binary: false,
});
await expect(readWorkspaceFile(root, join(external, "README.md"))).rejects.toThrow("Absolute paths are not allowed");
});
it("detects binary files and omits binary content", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "image.bin"), Buffer.from([0x66, 0x6f, 0x00, 0x6f]));
const file = await readWorkspaceFile(root, "image.bin");
expect(file).toMatchObject({ content: "", binary: true, truncated: false });
expect(file.size).toBe(4);
});
it("marks supported images as previewable", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "logo.PNG"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]));
const file = await readWorkspaceFile(root, "logo.PNG");
expect(file).toMatchObject({ mediaType: "image", mimeType: "image/png", content: "", binary: true, truncated: false });
expect(file.size).toBe(9);
});
it("opens image preview streams only for supported images within the preview size limit", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "diagram.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>");
await writeFile(join(root, "note.txt"), "hello");
await writeFile(join(root, "huge.png"), "");
await truncate(join(root, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1);
const preview = await readWorkspaceImagePreview(root, "diagram.svg");
preview.stream.destroy();
expect(preview).toMatchObject({ path: "diagram.svg", mimeType: "image/svg+xml", size: 46 });
await expect(readWorkspaceImagePreview(root, "note.txt")).rejects.toThrow("Image preview is not supported");
await expect(readWorkspaceImagePreview(root, "huge.png")).rejects.toThrow("Image is too large to preview");
});
it("truncates large text files", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "large.md"), "a".repeat(512 * 1024 + 7));
const file = await readWorkspaceFile(root, "large.md");
expect(file.language).toBe("markdown");
expect(file.content).toHaveLength(512 * 1024);
expect(file.truncated).toBe(true);
expect(file.binary).toBe(false);
});
});
@@ -1,375 +0,0 @@
import { mkdtemp, mkdir, readFile, rm, symlink, truncate, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js";
import { readWorkspaceFile, writeWorkspaceFile } from "./fileContentService.js";
import { deleteWorkspaceFile, moveWorkspaceFile } from "./fileContentService.js";
import { readWorkspaceImagePreview } from "./imagePreviewService.js";
const roots: string[] = [];
async function tempWorkspace(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "pi-web-file-content-"));
roots.push(root);
return root;
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("readWorkspaceFile", () => {
it("reads text files with normalized paths and language metadata", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "src"));
await writeFile(join(root, "src", "main.ts"), "const answer = 42;\n");
const file = await readWorkspaceFile(root, "./src//main.ts");
expect(file).toMatchObject({
path: "src/main.ts",
language: "typescript",
encoding: "utf8",
content: "const answer = 42;\n",
truncated: false,
binary: false,
});
expect(file.size).toBe(19);
expect(Date.parse(file.modifiedAt)).not.toBeNaN();
});
it("rejects missing paths, directories, traversal, and absolute paths", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "dir"));
await expect(readWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
await expect(readWorkspaceFile(root, "dir")).rejects.toThrow("Path is not a file");
await expect(readWorkspaceFile(root, "missing.txt")).rejects.toThrow("Path does not exist");
await expect(readWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
});
it("reads allowed absolute files outside the workspace", async () => {
const root = await tempWorkspace();
const external = await tempWorkspace();
await writeFile(join(external, "README.md"), "external docs\n");
const file = await readWorkspaceFile(root, join(external, "README.md"), { allowedPaths: [external] });
expect(file).toMatchObject({
path: join(external, "README.md"),
language: "markdown",
content: "external docs\n",
truncated: false,
binary: false,
});
await expect(readWorkspaceFile(root, join(external, "README.md"))).rejects.toThrow("Absolute paths are not allowed");
});
it("detects binary files and omits binary content", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "image.bin"), Buffer.from([0x66, 0x6f, 0x00, 0x6f]));
const file = await readWorkspaceFile(root, "image.bin");
expect(file).toMatchObject({ content: "", binary: true, truncated: false });
expect(file.size).toBe(4);
});
it("marks supported images as previewable", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "logo.PNG"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]));
const file = await readWorkspaceFile(root, "logo.PNG");
expect(file).toMatchObject({ mediaType: "image", mimeType: "image/png", content: "", binary: true, truncated: false });
expect(file.size).toBe(9);
});
it("opens image preview streams only for supported images within the preview size limit", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "diagram.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>");
await writeFile(join(root, "note.txt"), "hello");
await writeFile(join(root, "huge.png"), "");
await truncate(join(root, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1);
const preview = await readWorkspaceImagePreview(root, "diagram.svg");
preview.stream.destroy();
expect(preview).toMatchObject({ path: "diagram.svg", mimeType: "image/svg+xml", size: 46 });
await expect(readWorkspaceImagePreview(root, "note.txt")).rejects.toThrow("Image preview is not supported");
await expect(readWorkspaceImagePreview(root, "huge.png")).rejects.toThrow("Image is too large to preview");
});
it("truncates large text files", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "large.md"), "a".repeat(512 * 1024 + 7));
const file = await readWorkspaceFile(root, "large.md");
expect(file.language).toBe("markdown");
expect(file.content).toHaveLength(512 * 1024);
expect(file.truncated).toBe(true);
expect(file.binary).toBe(false);
});
});
describe("writeWorkspaceFile", () => {
it("writes text content to a new file with normalized paths", async () => {
const root = await tempWorkspace();
const result = await writeWorkspaceFile(root, "./src//hello.ts", Buffer.from("const greeting = 'hello';\n"));
expect(result).toMatchObject({ path: "src/hello.ts", created: true });
expect(result.size).toBe(26);
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
// Verify the file was actually written
const content = await readFile(join(root, "src", "hello.ts"), "utf8");
expect(content).toBe("const greeting = 'hello';\n");
});
it("writes binary content", async () => {
const root = await tempWorkspace();
const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
const result = await writeWorkspaceFile(root, "image.png", binaryData);
expect(result).toMatchObject({ path: "image.png", created: true, size: 6 });
});
it("overwrites existing files by default", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "notes.txt"), "old content");
const result = await writeWorkspaceFile(root, "notes.txt", Buffer.from("new content"));
expect(result).toMatchObject({ path: "notes.txt", created: false, size: 11 });
const content = await readFile(join(root, "notes.txt"), "utf8");
expect(content).toBe("new content");
});
it("throws when overwrite is false and file exists", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "existing.txt"), "data");
await expect(writeWorkspaceFile(root, "existing.txt", Buffer.from("new"), { overwrite: false })).rejects.toThrow("File already exists");
});
it("creates intermediate directories by default", async () => {
const root = await tempWorkspace();
await writeWorkspaceFile(root, "deep/nested/dir/file.txt", Buffer.from("deep content"));
const content = await readFile(join(root, "deep", "nested", "dir", "file.txt"), "utf8");
expect(content).toBe("deep content");
});
it("fails when createDirs is false and parent directory does not exist", async () => {
const root = await tempWorkspace();
await expect(writeWorkspaceFile(root, "missing/dir/file.txt", Buffer.from("x"), { createDirs: false })).rejects.toThrow();
});
it("rejects missing paths, traversal, and absolute paths", async () => {
const root = await tempWorkspace();
await expect(writeWorkspaceFile(root, undefined, Buffer.from("x"))).rejects.toThrow("path query parameter is required");
await expect(writeWorkspaceFile(root, "../secret.txt", Buffer.from("x"))).rejects.toThrow("Path traversal is not allowed");
await expect(writeWorkspaceFile(root, "/etc/passwd", Buffer.from("x"))).rejects.toThrow("Absolute paths are not allowed");
});
it("rejects writing to a directory path", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(writeWorkspaceFile(root, "mydir", Buffer.from("data"))).rejects.toThrow("Path is not a file");
});
it("prevents writing through symlinks that escape the workspace", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
// Create a symlink inside the workspace that points outside
const { symlink } = await import("node:fs/promises");
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-"));
roots.push(outsideDir);
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
// Attempting to write through the symlink should be blocked
await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow();
});
});
describe("deleteWorkspaceFile", () => {
it("deletes an existing file and returns existed: true", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "notes.txt"), "hello");
const result = await deleteWorkspaceFile(root, "notes.txt");
expect(result).toMatchObject({ path: "notes.txt", existed: true });
await expect(readWorkspaceFile(root, "notes.txt")).rejects.toThrow("Path does not exist");
});
it("returns existed: false when deleting a non-existent file", async () => {
const root = await tempWorkspace();
const result = await deleteWorkspaceFile(root, "missing.txt");
expect(result).toMatchObject({ path: "missing.txt", existed: false });
});
it("rejects deleting a directory", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory");
});
it("rejects path traversal", async () => {
const root = await tempWorkspace();
await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
await expect(deleteWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
});
it("rejects missing path", async () => {
const root = await tempWorkspace();
await expect(deleteWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
await expect(deleteWorkspaceFile(root, "")).rejects.toThrow("path query parameter is required");
});
it("deletes a symlink itself, not its target", async () => {
const root = await tempWorkspace();
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-"));
roots.push(outsideDir);
await writeFile(join(outsideDir, "real.txt"), "real content");
// Create a symlink inside the workspace pointing outside
await symlink(join(outsideDir, "real.txt"), join(root, "link.txt"));
const result = await deleteWorkspaceFile(root, "link.txt");
expect(result).toMatchObject({ path: "link.txt", existed: true });
// The symlink should be gone, but the target file should still exist
await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow();
const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
expect(realContent).toBe("real content");
});
it("prevents deleting through a symlinked parent directory that escapes the workspace", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
// A real file living outside the workspace that must not be deletable.
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-parent-"));
roots.push(outsideDir);
await writeFile(join(outsideDir, "victim.txt"), "important");
// A symlinked parent directory inside the workspace pointing outside.
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
await expect(deleteWorkspaceFile(root, "subdir/escape/victim.txt")).rejects.toThrow("Path escapes workspace");
// The outside file must survive.
const realContent = await readFile(join(outsideDir, "victim.txt"), "utf8");
expect(realContent).toBe("important");
});
});
describe("moveWorkspaceFile", () => {
it("moves a file to a new path", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "original.txt"), "content");
const result = await moveWorkspaceFile(root, "original.txt", "moved.txt");
expect(result).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
expect(result.size).toBe(7);
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
// Source should no longer exist
await expect(readWorkspaceFile(root, "original.txt")).rejects.toThrow("Path does not exist");
// Target should exist
const target = await readWorkspaceFile(root, "moved.txt");
expect(target.content).toBe("content");
});
it("creates intermediate directories by default", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "file.txt"), "data");
await moveWorkspaceFile(root, "file.txt", "deep/nested/dir/file.txt");
const target = await readWorkspaceFile(root, "deep/nested/dir/file.txt");
expect(target.content).toBe("data");
});
it("fails when createDirs is false and parent directory does not exist", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "file.txt"), "data");
await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow();
});
it("overwrites target when overwrite is true", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "source.txt"), "source content");
await writeFile(join(root, "target.txt"), "target content");
const result = await moveWorkspaceFile(root, "source.txt", "target.txt", { overwrite: true });
expect(result.toPath).toBe("target.txt");
const target = await readWorkspaceFile(root, "target.txt");
expect(target.content).toBe("source content");
});
it("throws when target exists and overwrite is false (default)", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "source.txt"), "source");
await writeFile(join(root, "target.txt"), "target");
await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists");
// Source should still exist
const source = await readWorkspaceFile(root, "source.txt");
expect(source.content).toBe("source");
});
it("rejects source path traversal", async () => {
const root = await tempWorkspace();
await expect(moveWorkspaceFile(root, "../secret.txt", "target.txt")).rejects.toThrow("Path traversal is not allowed");
});
it("rejects target path traversal", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "source.txt"), "data");
await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow();
});
it("rejects moving a directory", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(moveWorkspaceFile(root, "mydir", "newdir")).rejects.toThrow("Source path is not a file");
});
it("rejects missing fromPath or toPath", async () => {
const root = await tempWorkspace();
await expect(moveWorkspaceFile(root, undefined, "target.txt")).rejects.toThrow("fromPath query parameter is required");
await expect(moveWorkspaceFile(root, "source.txt", undefined)).rejects.toThrow("toPath query parameter is required");
await expect(moveWorkspaceFile(root, "", "target.txt")).rejects.toThrow("fromPath query parameter is required");
await expect(moveWorkspaceFile(root, "source.txt", "")).rejects.toThrow("toPath query parameter is required");
});
it("prevents moving through symlinks that escape the workspace", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
await writeFile(join(root, "subdir", "file.txt"), "data");
// Create a symlink inside the workspace that points outside
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-move-outside-"));
roots.push(outsideDir);
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow();
});
});
@@ -0,0 +1,15 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const tempRoots: string[] = [];
export async function createTempWorkspace(prefix = "pi-web-file-content-"): Promise<string> {
const root = await mkdtemp(join(tmpdir(), prefix));
tempRoots.push(root);
return root;
}
export async function cleanupTempWorkspaces(): Promise<void> {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
}
@@ -0,0 +1,93 @@
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { writeWorkspaceFile } from "./fileContentService.js";
import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js";
afterEach(async () => {
await cleanupTempWorkspaces();
});
describe("writeWorkspaceFile", () => {
it("writes text content to a new file with normalized paths", async () => {
const root = await createTempWorkspace();
const result = await writeWorkspaceFile(root, "./src//hello.ts", Buffer.from("const greeting = 'hello';\n"));
expect(result).toMatchObject({ path: "src/hello.ts", created: true });
expect(result.size).toBe(26);
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
// Verify the file was actually written
const content = await readFile(join(root, "src", "hello.ts"), "utf8");
expect(content).toBe("const greeting = 'hello';\n");
});
it("writes binary content without text re-encoding", async () => {
const root = await createTempWorkspace();
const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
const result = await writeWorkspaceFile(root, "image.png", binaryData);
expect(result).toMatchObject({ path: "image.png", created: true, size: 6 });
await expect(readFile(join(root, "image.png"))).resolves.toEqual(binaryData);
});
it("overwrites existing files by default", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "notes.txt"), "old content");
const result = await writeWorkspaceFile(root, "notes.txt", Buffer.from("new content"));
expect(result).toMatchObject({ path: "notes.txt", created: false, size: 11 });
const content = await readFile(join(root, "notes.txt"), "utf8");
expect(content).toBe("new content");
});
it("throws when overwrite is false and file exists", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "existing.txt"), "data");
await expect(writeWorkspaceFile(root, "existing.txt", Buffer.from("new"), { overwrite: false })).rejects.toThrow("File already exists");
});
it("creates intermediate directories by default", async () => {
const root = await createTempWorkspace();
await writeWorkspaceFile(root, "deep/nested/dir/file.txt", Buffer.from("deep content"));
const content = await readFile(join(root, "deep", "nested", "dir", "file.txt"), "utf8");
expect(content).toBe("deep content");
});
it("fails when createDirs is false and parent directory does not exist", async () => {
const root = await createTempWorkspace();
await expect(writeWorkspaceFile(root, "missing/dir/file.txt", Buffer.from("x"), { createDirs: false })).rejects.toThrow();
});
it("rejects missing paths, traversal, and absolute paths", async () => {
const root = await createTempWorkspace();
await expect(writeWorkspaceFile(root, undefined, Buffer.from("x"))).rejects.toThrow("path query parameter is required");
await expect(writeWorkspaceFile(root, "../secret.txt", Buffer.from("x"))).rejects.toThrow("Path traversal is not allowed");
await expect(writeWorkspaceFile(root, "/etc/passwd", Buffer.from("x"))).rejects.toThrow("Absolute paths are not allowed");
});
it("rejects writing to a directory path", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(writeWorkspaceFile(root, "mydir", Buffer.from("data"))).rejects.toThrow("Path is not a file");
});
it("prevents writing through symlinks that escape the workspace", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
const outsideDir = await createTempWorkspace("pi-web-outside-");
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow("Path escapes workspace");
await expect(readFile(join(outsideDir, "evil.txt"))).rejects.toMatchObject({ code: "ENOENT" });
});
});
@@ -76,6 +76,37 @@ describe("file suggestions", () => {
]);
});
it("waits for both git probes before falling back in all-file scope", async () => {
let releaseUntracked: (() => void) | undefined;
let resolved = false;
const deps: FileSuggestionDependencies = {
execFile: (file, args) => {
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.reject(new Error("not a git repository"));
if (file === "git" && args.join(" ") === "ls-files --others --exclude-standard -z") {
return new Promise<{ stdout: string }>((resolve) => {
releaseUntracked = () => {
resolve({ stdout: "" });
};
});
}
if (file === "rg") return Promise.resolve({ stdout: "sdk.md\n" });
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
},
};
const suggestions = listFileSuggestions("/repo", "sdk", { scope: "all" }, deps).then((value) => {
resolved = true;
return value;
});
await Promise.resolve();
expect(resolved).toBe(false);
expect(releaseUntracked).toBeDefined();
releaseUntracked?.();
await expect(suggestions).resolves.toEqual([{ path: "sdk.md", kind: "other" }]);
});
it("keeps git untracked files in all-file scope when the broad scan misses them", async () => {
const deps: FileSuggestionDependencies = {
execFile: (file, args) => {
+7 -4
View File
@@ -253,13 +253,16 @@ async function listTrackedFiles(cwd: string, exec: CommandRunner): Promise<Clien
}
async function listGitFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
const [tracked, untracked] = await Promise.all([
const [trackedResult, untrackedResult] = await Promise.allSettled([
git(cwd, ["ls-files", "-z"], exec),
git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], exec),
]);
] as const);
if (trackedResult.status === "rejected") throw trackedResult.reason;
if (untrackedResult.status === "rejected") throw untrackedResult.reason;
return [
...withDirectories(nulRecords(tracked), "tracked"),
...withDirectories(nulRecords(untracked), "untracked"),
...withDirectories(nulRecords(trackedResult.value), "tracked"),
...withDirectories(nulRecords(untrackedResult.value), "untracked"),
];
}
@@ -53,7 +53,7 @@ afterEach(async () => {
});
describe("workspace deletion routes", () => {
it("closes target workspace terminals before starting the deletion terminal command", async () => {
it("closes target workspace terminals before starting deletion from the main workspace", async () => {
const response = await app.inject({ method: "DELETE", url: "/api/projects/p1/workspaces/feature" });
expect(response.statusCode).toBe(200);