Archived
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 })]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" } },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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("'", "'\\''")}'`;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" }),
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+43
-938
File diff suppressed because it is too large
Load Diff
@@ -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> {
|
||||
|
||||
@@ -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(() => [
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user