feat: target settings to selected machine

This commit is contained in:
Federico Jaramillo Martinez
2026-07-02 13:28:03 +02:00
parent 5ecb32ae62
commit 64b2b32705
38 changed files with 2633 additions and 214 deletions
+141
View File
@@ -160,6 +160,81 @@ describe("buildApp", () => {
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
});
it("filters remote selected-machine config reads to machine-safe keys", 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", "set-cookie": "secret=1" },
body: piWebConfigResponse(fullPiWebConfig()),
}));
remoteClient = fakeRemoteClient({ requestJson });
const response = await 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 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)) });
});
remoteClient = fakeRemoteClient({ requestJson });
const response = await 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 } },
});
const expectedMerged: PiWebConfigValues = {
...fullPiWebConfig(),
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/srv/remote"] },
uploads: { defaultFolder: "remote/uploads" },
maxUploadBytes: 4096,
spawnSessions: true,
};
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,
});
});
it("rejects unsafe remote selected-machine config keys 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 requestJson = vi.fn<MachineClient["requestJson"]>();
remoteClient = fakeRemoteClient({ requestJson });
const response = await 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();
});
it("proxies remote Pi package routes and gives package mutations a longer timeout", 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 }>();
@@ -444,6 +519,10 @@ describe("buildApp", () => {
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 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 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");
@@ -453,6 +532,24 @@ describe("buildApp", () => {
expect(missingResponse.statusCode).toBe(404);
});
it("proxies remote machine plugin lists for settings", 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", "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 }] })]),
}));
remoteClient = fakeRemoteClient({ request });
const response = await 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 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 }>();
@@ -947,6 +1044,32 @@ function fakeConfigService() {
};
}
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,
};
}
function selectedMachinePiWebConfig(): PiWebConfigValues {
return {
plugins: { info: { enabled: true, settings: { note: "remote" } } },
pathAccess: { allowedPaths: ["/srv/repos"] },
uploads: { defaultFolder: "uploads" },
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
};
}
function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: join(tempDir, "config.json"),
@@ -957,6 +1080,24 @@ function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
};
}
interface MachineConfigWriteBody {
config: PiWebConfigValues;
}
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 {
+3 -1
View File
@@ -18,7 +18,7 @@ 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";
@@ -149,9 +149,11 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
app.get("/api/plugins", async () => piWebPlugins.plugins());
app.get("/api/machines/local/plugins", async () => piWebPlugins.plugins());
registerPiPackageRoutes(app, piPackages);
registerPiPackageRoutes(app, piPackages, "/api/machines/local");
registerConfigRoutes(app, configService);
registerLocalMachineConfigRoutes(app, configService);
registerMachineRoutes(app, machines);
registerMachinePluginProxyRoutes(app, machines);
+96 -1
View File
@@ -1,6 +1,6 @@
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { 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();
});
@@ -92,8 +93,102 @@ 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 response = await app.inject({
method: "PUT",
url: "/api/machines/local/config",
payload: { config: { plugins: { info: { enabled: false } }, uploads: { defaultFolder: "uploads\\manual" }, spawnSessions: true } },
});
const expectedConfig: PiWebConfigValues = {
...fullConfig(),
plugins: { info: { enabled: false } },
uploads: { defaultFolder: "uploads/manual" },
spawnSessions: true,
};
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,
});
});
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,
};
}
function selectedMachineConfig(): PiWebConfigValues {
return {
plugins: { info: { enabled: true, settings: { note: "visible" } } },
pathAccess: { allowedPaths: ["/srv/repos"] },
uploads: { defaultFolder: "uploads" },
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
};
}
function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
+113 -1
View File
@@ -8,6 +8,17 @@ export interface PiWebConfigService {
write: (config: PiWebConfigValues) => PiWebConfigResponse | Promise<PiWebConfigResponse>;
}
export const SELECTED_MACHINE_CONFIG_KEYS = [
"plugins",
"pathAccess",
"uploads",
"maxUploadBytes",
"spawnSessions",
"subsessions",
] 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 +61,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 = {};
@@ -88,6 +155,23 @@ 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 } : {}),
};
}
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")) {
@@ -141,6 +225,34 @@ 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),
};
}
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 piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides {
return {
host: isEnvSet(env["PI_WEB_HOST"]),
@@ -156,7 +268,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 {
+60 -4
View File
@@ -1,8 +1,9 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { WebSocket } from "ws";
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;
@@ -45,19 +46,62 @@ async function proxyHttpRequest(machines: MachineService, spec: FederatedHttpRou
}
try {
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");
@@ -110,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";
@@ -117,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),
});
}
+4 -3
View File
@@ -41,7 +41,7 @@ describe("PI WEB status", () => {
expect(status).not.toHaveProperty("release");
});
it("reports Pi package management as a web runtime capability", async () => {
it("reports web-only capabilities from the web runtime", async () => {
const daemon = daemonWithComponent({
component: "sessiond",
label: "Session daemon",
@@ -53,9 +53,10 @@ describe("PI WEB status", () => {
const runtime = await getPiWebRuntime(daemon);
expect(runtime.components.web.capabilities).toContain(PI_WEB_CAPABILITIES.piPackagesManage);
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.capabilities).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("reports stale session daemon versions as messages", async () => {