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
+58 -2
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
import { filesApi, machinesApi, piPackagesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
import type { PiWebConfigValues, TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
import { configApi, filesApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
const workspace: Workspace = {
id: "w/1",
@@ -60,6 +60,48 @@ describe("machine-scoped runtime API", () => {
});
});
describe("settings config and plugin APIs", () => {
it("preserves gateway config and plugin routes by default", async () => {
const fetchMock = stubSequenceFetch([
jsonResponse(piWebConfigResponse({ host: "127.0.0.1" })),
jsonResponse(piWebConfigResponse({ spawnSessions: true })),
jsonResponse(piWebPluginsResponse()),
]);
await expect(configApi.config()).resolves.toMatchObject({ config: { host: "127.0.0.1" } });
await expect(configApi.saveConfig({ spawnSessions: true })).resolves.toMatchObject({ config: { spawnSessions: true } });
await expect(pluginsApi.plugins()).resolves.toEqual(piWebPluginsResponse());
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/config",
"/api/config",
"/api/plugins",
]);
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
});
it("uses machine-scoped config and plugin routes when a machine id is provided", async () => {
const fetchMock = stubSequenceFetch([
jsonResponse(piWebConfigResponse({ spawnSessions: false })),
jsonResponse(piWebConfigResponse({ spawnSessions: true })),
jsonResponse(piWebPluginsResponse()),
]);
await expect(configApi.config("remote a")).resolves.toMatchObject({ config: { spawnSessions: false } });
await expect(configApi.saveConfig({ spawnSessions: true }, "remote a")).resolves.toMatchObject({ config: { spawnSessions: true } });
await expect(pluginsApi.plugins("remote a")).resolves.toEqual(piWebPluginsResponse());
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/machines/remote%20a/config",
"/api/machines/remote%20a/config",
"/api/machines/remote%20a/plugins",
]);
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
});
});
describe("Pi package API", () => {
it("preserves the legacy local Pi package-management routes by default", async () => {
const packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
@@ -358,6 +400,20 @@ function requestBody(init: RequestInit | undefined): string {
return init.body;
}
function piWebConfigResponse(config: PiWebConfigValues) {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
function piWebPluginsResponse() {
return { plugins: [{ id: "info", module: "/pi-web-plugins/info/plugin.js", source: "test", scope: "local", machineSpecific: false, enabled: true }] };
}
function jsonResponse(value: unknown): Response {
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
}
+11 -3
View File
@@ -112,13 +112,21 @@ export const machinesApi = {
runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
};
function configUrl(machineId?: string): string {
return machineId === undefined ? "/api/config" : `${machinePrefix(machineId)}/config`;
}
function pluginsUrl(machineId?: string): string {
return machineId === undefined ? "/api/plugins" : `${machinePrefix(machineId)}/plugins`;
}
export const configApi = {
config: () => request("/api/config", parsePiWebConfigResponse),
saveConfig: (config: PiWebConfigValues) => request("/api/config", parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
config: (machineId?: string) => request(configUrl(machineId), parsePiWebConfigResponse),
saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configUrl(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
};
export const pluginsApi = {
plugins: () => request("/api/plugins", parsePiWebPluginsResponse),
plugins: (machineId?: string) => request(pluginsUrl(machineId), parsePiWebPluginsResponse),
};
function piPackageUrl(endpoint = "", machineId?: string): string {
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Workspace } from "../../../shared/apiTypes";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
import { activityApi, filesApi, gitApi, piPackagesApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
import { activityApi, configApi, filesApi, gitApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
import { workspaceImagePreviewUrl } from "./urls";
@@ -28,6 +28,9 @@ describe("federated route contract", () => {
await Promise.all([
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
ignoreParseFailure(configApi.config(machineId)),
ignoreParseFailure(configApi.saveConfig({ spawnSessions: true }, machineId)),
ignoreParseFailure(pluginsApi.plugins(machineId)),
ignoreParseFailure(piPackagesApi.packages(machineId)),
ignoreParseFailure(piPackagesApi.install("npm:@acme/tools", machineId)),
ignoreParseFailure(piPackagesApi.remove("npm:@acme/tools", "user", machineId)),
@@ -0,0 +1,583 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { configApi, pluginsApi, type Machine, type MachineRuntime, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginInfo, type PiWebPluginsResponse } from "../api";
import { SettingsDialog } from "./SettingsDialog";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("settings-dialog session daemon machine targeting", () => {
it("keeps gateway settings loads on the gateway config/plugin endpoints", async () => {
const config = configResponse({ host: "127.0.0.1" });
const plugins: PiWebPluginsResponse = { plugins: [] };
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
const dialog = new SettingsDialog();
await callDialogPromise(dialog, "loadConfig");
expect(configSpy.mock.calls).toEqual([[]]);
expect(pluginsSpy.mock.calls).toEqual([[]]);
expect(getDialogProperty(dialog, "configResponse")).toBe(config);
expect(getDialogProperty(dialog, "pluginsResponse")).toBe(plugins);
expect(getDialogProperty(dialog, "error")).toBe("");
expect(getDialogProperty(dialog, "loading")).toBe(false);
});
it("loads session-daemon config from the selected machine", async () => {
const config = configResponse({ spawnSessions: false, subsessions: true });
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false });
const savedConfig = configResponse({ spawnSessions: true });
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const dialog = new SettingsDialog();
setDialogProperty(dialog, "configResponse", gatewayConfig);
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
expect(saveSpy.mock.calls).toEqual([[{ spawnSessions: true }, "local"]]);
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ config: { host: "127.0.0.1", spawnSessions: true, subsessions: false } });
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("ignores stale session-daemon load responses after the selected machine changes", async () => {
const load = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const loadPromise = callDialogPromise(dialog, "loadSessiondConfigForTarget");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
load.resolve(configResponse({ spawnSessions: false }));
await loadPromise;
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
it("ignores stale session-daemon save responses after the selected machine changes", async () => {
stubWindowTimers();
const save = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const savePromise = callDialogPromise(dialog, "saveSessiondConfig", { subsessions: true });
expect(getDialogProperty(dialog, "saving")).toBe(true);
dialog.machine = secondRemoteMachine;
save.resolve(configResponse({ subsessions: true }));
await savePromise;
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("skips selected-machine settings loads when the remote runtime does not advertise support", async () => {
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(configResponse({ spawnSessions: true }));
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", true)]));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
await callDialogPromise(dialog, "loadAccessConfigForTarget");
await callDialogPromise(dialog, "loadPluginsForTarget");
expect(configSpy).not.toHaveBeenCalled();
expect(pluginsSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
});
it("does not save remote selected-machine settings when runtime support is missing", async () => {
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ spawnSessions: true }));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
await callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] } });
await callDialogPromise(dialog, "togglePlugin", "info", false);
expect(saveSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
});
it("shows selected-machine settings errors with the selected target name", async () => {
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
expect(getDialogProperty(dialog, "sessiondError")).toBe("Failed to load session-daemon config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
});
describe("settings-dialog general settings machine targeting", () => {
it("describes the General tab as gateway server plus selected-machine file/upload settings", () => {
const dialog = new SettingsDialog();
dialog.section = "general";
dialog.machine = remoteMachine;
expect(callDialogMethod(dialog, "settingsScopeMessage")).toBe("Gateway server config and selected machine file/upload config: Lab Mac (remote machine).");
});
it("keeps gateway server config saves on the gateway config endpoint", async () => {
stubWindowTimers();
const savedConfig = configResponse({ host: "0.0.0.0", port: 9000, allowedHosts: true });
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const onConfigSaved = vi.fn();
const dialog = new SettingsDialog();
dialog.onConfigSaved = onConfigSaved;
await callDialogPromise(dialog, "saveConfig", { host: "0.0.0.0", port: 9000, allowedHosts: true });
expect(saveSpy.mock.calls).toEqual([[{ host: "0.0.0.0", port: 9000, allowedHosts: true }]]);
expect(getDialogProperty(dialog, "configResponse")).toBe(savedConfig);
expect(onConfigSaved).toHaveBeenCalledWith({ host: "0.0.0.0", port: 9000, allowedHosts: true });
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("loads file access and upload config from the selected machine", async () => {
const config = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } });
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadAccessConfigForTarget");
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "accessError")).toBe("");
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
});
it("saves selected-machine file access and upload config through the selected-machine endpoint", async () => {
stubWindowTimers();
const patch = { pathAccess: { allowedPaths: ["/mnt/share", "~/SDKs"] }, uploads: { defaultFolder: "manual/uploads" } };
const savedConfig = configResponse(patch);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
expect(saveSpy.mock.calls).toEqual([[patch, "remote-a"]]);
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "configResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("merges local selected-machine access saves into gateway config without dropping gateway-only values", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["/old"] },
uploads: { defaultFolder: "old/uploads" },
maxUploadBytes: 1234,
});
const patch = { pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {} };
const savedConfig = configResponse({ pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {}, maxUploadBytes: 5678 });
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const onConfigSaved = vi.fn();
const dialog = new SettingsDialog();
dialog.onConfigSaved = onConfigSaved;
setDialogProperty(dialog, "configResponse", gatewayConfig);
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
expect(saveSpy.mock.calls).toEqual([[patch, "local"]]);
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
config: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: {},
maxUploadBytes: 5678,
},
effectiveConfig: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: {},
maxUploadBytes: 5678,
},
});
expect(onConfigSaved).toHaveBeenCalledWith({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: {},
maxUploadBytes: 5678,
});
});
it("ignores stale file access load responses after the selected machine changes", async () => {
const load = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const loadPromise = callDialogPromise(dialog, "loadAccessConfigForTarget");
expect(getDialogProperty(dialog, "accessLoading")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
load.resolve(configResponse({ pathAccess: { allowedPaths: ["/stale"] } }));
await loadPromise;
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "accessError")).toBe("");
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
});
it("ignores stale file access save responses after the selected machine changes", async () => {
const save = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const savePromise = callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } });
expect(getDialogProperty(dialog, "saving")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
save.resolve(configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } }));
await savePromise;
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("shows selected-machine file access errors with the selected target name", async () => {
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadAccessConfigForTarget");
expect(getDialogProperty(dialog, "accessError")).toBe("Failed to load file access/upload config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
});
});
describe("settings-dialog plugin settings machine targeting", () => {
it("loads plugin config and plugin list from the selected machine", async () => {
const config = configResponse({ plugins: { info: { enabled: true } } });
const plugins = pluginsResponse([pluginInfo("info", true)]);
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadPluginsForTarget");
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(plugins);
expect(getDialogProperty(dialog, "pluginError")).toBe("");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
});
it("keeps fulfilled plugin config when the selected machine plugin list is unsupported", async () => {
const config = configResponse({ plugins: { info: { enabled: true } } });
vi.spyOn(configApi, "config").mockResolvedValue(config);
vi.spyOn(pluginsApi, "plugins").mockRejectedValue(new Error("route GET:/api/plugins not found"));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadPluginsForTarget");
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "pluginError")).toBe("Failed to load PI WEB plugin settings from Lab Mac (remote machine): PI WEB plugins: Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
});
it("saves selected-machine plugin toggles as plugin-only patches and refreshes the selected machine plugin list", async () => {
stubWindowTimers();
const baseConfig = configResponse({
plugins: {
keep: { enabled: true, settings: { level: 1 } },
info: { settings: { color: "blue" } },
},
});
const savedConfig = configResponse({
plugins: {
keep: { enabled: true, settings: { level: 1 } },
info: { enabled: false, settings: { color: "blue" } },
},
});
const refreshedPlugins = pluginsResponse([pluginInfo("info", false), pluginInfo("keep", true)]);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
setDialogProperty(dialog, "selectedPluginConfigResponse", baseConfig);
await callDialogPromise(dialog, "togglePlugin", "info", false);
expect(saveSpy.mock.calls).toEqual([[
{
plugins: {
keep: { enabled: true, settings: { level: 1 } },
info: { enabled: false, settings: { color: "blue" } },
},
},
"remote-a",
]]);
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("merges local selected-machine plugin saves into gateway config without dropping gateway-only values", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: false }, gateway: { settings: { theme: "dark" } } },
});
const savedConfig = configResponse({ plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } } });
const refreshedPlugins = pluginsResponse([pluginInfo("info", true)]);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
const onConfigSaved = vi.fn();
const dialog = new SettingsDialog();
dialog.onConfigSaved = onConfigSaved;
setDialogProperty(dialog, "configResponse", gatewayConfig);
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: false } } }));
await callDialogPromise(dialog, "togglePlugin", "info", true);
expect(saveSpy.mock.calls).toEqual([[{ plugins: { info: { enabled: true } } }, "local"]]);
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
config: {
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
},
effectiveConfig: {
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
},
});
expect(onConfigSaved).toHaveBeenCalledWith({
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
});
});
it("ignores stale plugin load responses after the selected machine changes", async () => {
const configLoad = deferred<PiWebConfigResponse>();
const pluginsLoad = deferred<PiWebPluginsResponse>();
vi.spyOn(configApi, "config").mockReturnValue(configLoad.promise);
vi.spyOn(pluginsApi, "plugins").mockReturnValue(pluginsLoad.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const loadPromise = callDialogPromise(dialog, "loadPluginsForTarget");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
configLoad.resolve(configResponse({ plugins: { info: { enabled: true } } }));
pluginsLoad.resolve(pluginsResponse([pluginInfo("info", true)]));
await loadPromise;
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "pluginError")).toBe("");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
});
it("ignores stale plugin save responses after the selected machine changes", async () => {
const save = deferred<PiWebConfigResponse>();
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", false)]));
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
const savePromise = callDialogPromise(dialog, "togglePlugin", "info", false);
expect(getDialogProperty(dialog, "saving")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
save.resolve(configResponse({ plugins: { info: { enabled: false } } }));
await savePromise;
expect(pluginsSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
});
const remoteMachine: Machine = {
id: "remote-a",
name: "Lab Mac",
kind: "remote",
baseUrl: "https://lab.example.test",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
};
const secondRemoteMachine: Machine = {
id: "remote-b",
name: "Build Box",
kind: "remote",
baseUrl: "https://build.example.test",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
};
const runtimeWithoutSelectedMachineSettings: MachineRuntime = {
machineId: "remote-a",
ok: true,
checkedAt: "2026-07-01T00:00:00.000Z",
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage],
};
function getDialogProperty(dialog: SettingsDialog, property: string): unknown {
return Reflect.get(dialog, property);
}
function setDialogProperty(dialog: SettingsDialog, property: string, value: unknown): void {
if (!Reflect.set(dialog, property, value)) throw new Error(`Failed to set SettingsDialog property ${property}`);
}
async function callDialogPromise(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): Promise<void> {
const result = callDialogMethod(dialog, methodName, ...args);
if (!(result instanceof Promise)) throw new Error(`SettingsDialog.${methodName} did not return a promise`);
await result;
}
function callDialogUpdated(dialog: SettingsDialog, changed: Map<string, unknown>): void {
const result = callDialogMethod(dialog, "updated", changed);
if (result !== undefined) throw new Error("SettingsDialog.updated returned an unexpected value");
}
function callDialogMethod(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): unknown {
const method: unknown = Reflect.get(dialog, methodName);
if (!isDialogMethod(method)) throw new Error(`SettingsDialog.${methodName} is not callable`);
return method.call(dialog, ...args);
}
function isDialogMethod(value: unknown): value is (this: SettingsDialog, ...args: readonly unknown[]) => unknown {
return typeof value === "function";
}
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
function pluginsResponse(plugins: PiWebPluginInfo[]): PiWebPluginsResponse {
return { plugins };
}
function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo {
return {
id,
module: `/pi-web-plugins/${id}/plugin.js`,
source: "test",
scope: "local",
machineSpecific: false,
enabled,
};
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolveDeferred: ((value: T) => void) | undefined;
let rejectDeferred: ((error: unknown) => void) | undefined;
const promise = new Promise<T>((resolve, reject) => {
resolveDeferred = resolve;
rejectDeferred = reject;
});
if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized");
return { promise, resolve: resolveDeferred, reject: rejectDeferred };
}
function stubWindowTimers(): void {
vi.stubGlobal("window", {
clearTimeout: vi.fn(),
setTimeout: vi.fn(() => 1),
});
}
+296 -35
View File
@@ -8,8 +8,12 @@ import "./settings/SettingsSessiondPanel";
import "./settings/SettingsPackagesPanel";
import "./settings/SettingsPluginsPanel";
import "./settings/SettingsShortcutsPanel";
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetContext, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading";
import { mergeSelectedMachineAccessConfig } from "./settings/settingsMachineAccessConfig";
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget";
import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settings/settingsPluginConfig";
import { mergeSelectedMachineSessiondConfig } from "./settings/settingsSessiondConfig";
@customElement("settings-dialog")
export class SettingsDialog extends LitElement {
@@ -21,24 +25,40 @@ export class SettingsDialog extends LitElement {
@property({ attribute: false }) onClose?: () => void;
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
@state() private configResponse: PiWebConfigResponse | undefined;
@state() private accessConfigResponse: PiWebConfigResponse | undefined;
@state() private sessiondConfigResponse: PiWebConfigResponse | undefined;
@state() private pluginsResponse: PiWebPluginsResponse | undefined;
@state() private selectedPluginConfigResponse: PiWebConfigResponse | undefined;
@state() private selectedPluginsResponse: PiWebPluginsResponse | undefined;
@state() private packagesResponse: PiPackagesResponse | undefined;
@state() private loading = true;
@state() private accessLoading = true;
@state() private sessiondLoading = true;
@state() private pluginLoading = true;
@state() private packageLoading = true;
@state() private saving = false;
@state() private packageOperation: PiPackageOperationState | undefined;
@state() private error = "";
@state() private accessError = "";
@state() private sessiondError = "";
@state() private pluginError = "";
@state() private packageError = "";
@state() private savedMessage = "";
@state() private packageMessage = "";
private savedMessageTimer: number | undefined;
private loadRequestSeq = 0;
private accessLoadRequestSeq = 0;
private sessiondLoadRequestSeq = 0;
private pluginLoadRequestSeq = 0;
private packageLoadRequestSeq = 0;
private packageMutationSeq = 0;
override connectedCallback(): void {
super.connectedCallback();
void this.loadConfig();
void this.loadAccessConfigForTarget();
void this.loadSessiondConfigForTarget();
void this.loadPluginsForTarget();
void this.loadPackagesForTarget();
}
@@ -49,10 +69,16 @@ export class SettingsDialog extends LitElement {
}
protected override updated(changed: PropertyValues<this>): void {
const currentTarget = this.packageTarget();
const currentTarget = this.settingsTarget();
if (changed.has("machine")) {
const previousTarget = piPackageTargetContext(changed.get("machine"));
const previousTarget = settingsMachineTarget(changed.get("machine"));
if (previousTarget.id !== currentTarget.id) {
this.resetAccessStateForTargetChange();
if (this.isConnected) void this.loadAccessConfigForTarget(currentTarget);
this.resetSessiondStateForTargetChange();
if (this.isConnected) void this.loadSessiondConfigForTarget(currentTarget);
this.resetPluginStateForTargetChange();
if (this.isConnected) void this.loadPluginsForTarget(currentTarget);
this.resetPackageStateForTargetChange();
if (this.isConnected) void this.loadPackagesForTarget(currentTarget);
return;
@@ -60,6 +86,14 @@ export class SettingsDialog extends LitElement {
}
if (!changed.has("machineRuntime")) return;
if (this.selectedMachineSettingsSupportNeedsReload(changed.get("machineRuntime"), currentTarget)) {
this.resetAccessStateForTargetChange();
if (this.isConnected) void this.loadAccessConfigForTarget(currentTarget);
this.resetSessiondStateForTargetChange();
if (this.isConnected) void this.loadSessiondConfigForTarget(currentTarget);
this.resetPluginStateForTargetChange();
if (this.isConnected) void this.loadPluginsForTarget(currentTarget);
}
if (!this.packageManagementSupportNeedsReload(changed.get("machineRuntime"), currentTarget)) return;
this.resetPackageStateForTargetChange();
if (this.isConnected) void this.loadPackagesForTarget(currentTarget);
@@ -78,10 +112,10 @@ export class SettingsDialog extends LitElement {
</header>
<div class="settings-body">
<nav class="settings-nav" aria-label="Settings sections">
${this.renderNavButton("general", "General", "Gateway config")}
${this.renderNavButton("sessiond", "Session daemon", "Gateway runtime")}
${this.renderNavButton("general", "General", "Gateway + selected machine")}
${this.renderNavButton("sessiond", "Session daemon", "Selected machine")}
${this.renderNavButton("packages", "Pi packages", "Selected machine")}
${this.renderNavButton("plugins", "PI WEB plugins", "Gateway plugins")}
${this.renderNavButton("plugins", "PI WEB plugins", "Selected machine")}
${this.renderNavButton("shortcuts", "Keyboard", "Gateway shortcuts")}
</nav>
<main class="settings-content">
@@ -98,13 +132,14 @@ export class SettingsDialog extends LitElement {
if (this.section === "sessiond") {
return html`
<settings-sessiond-panel
.configResponse=${this.configResponse}
.loading=${this.loading}
.configResponse=${this.sessiondConfigResponse}
.loading=${this.sessiondLoading}
.saving=${this.saving}
.error=${this.error}
.error=${this.sessiondError}
.savedMessage=${this.savedMessage}
.onReload=${() => this.loadConfig()}
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
.onReload=${() => this.loadSessiondConfigForTarget()}
.onSave=${(config: PiWebConfigValues) => this.saveSessiondConfig(config)}
></settings-sessiond-panel>
`;
}
@@ -142,13 +177,14 @@ export class SettingsDialog extends LitElement {
if (this.section === "plugins") {
return html`
<settings-plugins-panel
.configResponse=${this.configResponse}
.pluginsResponse=${this.pluginsResponse}
.loading=${this.loading}
.configResponse=${this.selectedPluginConfigResponse}
.pluginsResponse=${this.selectedPluginsResponse}
.loading=${this.pluginLoading}
.saving=${this.saving}
.error=${this.error}
.error=${this.pluginError}
.savedMessage=${this.savedMessage}
.onReload=${() => this.loadConfig()}
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
.onReload=${() => this.loadPluginsForTarget()}
.onTogglePlugin=${(pluginId: string, enabled: boolean) => this.togglePlugin(pluginId, enabled)}
></settings-plugins-panel>
`;
@@ -156,12 +192,18 @@ export class SettingsDialog extends LitElement {
return html`
<settings-general-panel
.configResponse=${this.configResponse}
.machineConfigResponse=${this.accessConfigResponse}
.loading=${this.loading}
.machineLoading=${this.accessLoading}
.saving=${this.saving}
.error=${this.error}
.machineError=${this.accessError}
.savedMessage=${this.savedMessage}
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
.onReload=${() => this.loadConfig()}
.onReloadMachine=${() => this.loadAccessConfigForTarget()}
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
.onSaveMachineConfig=${(config: PiWebConfigValues) => this.saveMachineAccessConfig(config)}
></settings-general-panel>
`;
}
@@ -186,10 +228,10 @@ export class SettingsDialog extends LitElement {
private settingsScopeMessage(): string {
if (this.section === "packages") return `Selected machine packages: ${piPackageTargetLabel(this.packageTarget())}.`;
if (this.section === "sessiond") return "Local gateway session-daemon config.";
if (this.section === "plugins") return "Local gateway PI WEB plugin enablement.";
if (this.section === "sessiond") return `Selected machine session-daemon config: ${settingsMachineTargetLabel(this.settingsTarget())}.`;
if (this.section === "plugins") return `Selected machine PI WEB plugin enablement: ${settingsMachineTargetLabel(this.settingsTarget())}.`;
if (this.section === "shortcuts") return "Local gateway keyboard shortcuts.";
return "Local gateway config.";
return `Gateway server config and selected machine file/upload config: ${settingsMachineTargetLabel(this.settingsTarget())}.`;
}
private navigate(section: SettingsSection): void {
@@ -215,6 +257,83 @@ export class SettingsDialog extends LitElement {
}
}
private async loadAccessConfigForTarget(target = this.settingsTarget()): Promise<void> {
const requestSeq = ++this.accessLoadRequestSeq;
const support = this.selectedMachineSettingsSupport(target);
if (isSelectedMachineSettingsUnsupported(support)) {
this.accessConfigResponse = undefined;
this.accessLoading = false;
this.accessError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
this.accessLoading = true;
this.accessError = "";
try {
const response = await configApi.config(target.id);
if (!this.isCurrentAccessLoad(requestSeq, target)) return;
this.accessConfigResponse = response;
} catch (error) {
if (this.isCurrentAccessLoad(requestSeq, target)) {
this.accessError = `Failed to load file access/upload config from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
}
} finally {
if (this.isCurrentAccessLoad(requestSeq, target)) this.accessLoading = false;
}
}
private async loadSessiondConfigForTarget(target = this.settingsTarget()): Promise<void> {
const requestSeq = ++this.sessiondLoadRequestSeq;
const support = this.selectedMachineSettingsSupport(target);
if (isSelectedMachineSettingsUnsupported(support)) {
this.sessiondConfigResponse = undefined;
this.sessiondLoading = false;
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
this.sessiondLoading = true;
this.sessiondError = "";
try {
const response = await configApi.config(target.id);
if (!this.isCurrentSessiondLoad(requestSeq, target)) return;
this.sessiondConfigResponse = response;
} catch (error) {
if (this.isCurrentSessiondLoad(requestSeq, target)) {
this.sessiondError = `Failed to load session-daemon config from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
}
} finally {
if (this.isCurrentSessiondLoad(requestSeq, target)) this.sessiondLoading = false;
}
}
private async loadPluginsForTarget(target = this.settingsTarget()): Promise<void> {
const requestSeq = ++this.pluginLoadRequestSeq;
const support = this.selectedMachineSettingsSupport(target);
if (isSelectedMachineSettingsUnsupported(support)) {
this.selectedPluginConfigResponse = undefined;
this.selectedPluginsResponse = undefined;
this.pluginLoading = false;
this.pluginError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
this.pluginLoading = true;
this.pluginError = "";
try {
const [config, plugins] = await Promise.allSettled([configApi.config(target.id), pluginsApi.plugins(target.id)]);
if (!this.isCurrentPluginLoad(requestSeq, target)) return;
const errors: string[] = [];
if (config.status === "fulfilled") this.selectedPluginConfigResponse = config.value;
else errors.push(`config: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(config.reason), target)}`);
if (plugins.status === "fulfilled") this.selectedPluginsResponse = plugins.value;
else errors.push(`PI WEB plugins: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(plugins.reason), target)}`);
this.pluginError = errors.length === 0 ? "" : `Failed to load PI WEB plugin settings from ${settingsMachineTargetLabel(target)}: ${errors.join("; ")}`;
} finally {
if (this.isCurrentPluginLoad(requestSeq, target)) this.pluginLoading = false;
}
}
private async loadPackagesForTarget(target = this.packageTarget()): Promise<void> {
const requestSeq = ++this.packageLoadRequestSeq;
this.packageLoading = true;
@@ -232,18 +351,40 @@ export class SettingsDialog extends LitElement {
}
private async togglePlugin(pluginId: string, enabled: boolean): Promise<void> {
const baseConfig = this.configResponse?.config ?? {};
const currentPlugins = baseConfig.plugins ?? {};
const currentPluginConfig = currentPlugins[pluginId] ?? {};
await this.saveConfig({
...baseConfig,
plugins: {
...currentPlugins,
[pluginId]: { ...currentPluginConfig, enabled },
},
});
const pluginRefreshError = await this.refreshPlugins();
if (pluginRefreshError !== undefined) this.error = pluginRefreshError;
if (this.saving) return;
const target = this.settingsTarget();
const support = this.selectedMachineSettingsSupport(target);
if (isSelectedMachineSettingsUnsupported(support)) {
this.pluginError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
if (this.selectedPluginConfigResponse === undefined) {
this.pluginError = `Plugin config is not loaded for ${settingsMachineTargetLabel(target)}. Reload before changing plugin enablement.`;
return;
}
const patch = pluginEnabledConfigPatch(this.selectedPluginConfigResponse.config, pluginId, enabled);
this.saving = true;
this.pluginError = "";
this.savedMessage = "";
try {
const response = await configApi.saveConfig(patch, target.id);
if (!this.isCurrentSettingsTarget(target)) return;
this.selectedPluginConfigResponse = response;
if (target.kind === "local" && this.configResponse !== undefined) {
this.configResponse = mergeSelectedMachinePluginConfig(this.configResponse, response);
this.onConfigSaved?.(this.configResponse.effectiveConfig);
}
const pluginRefreshError = await this.refreshPluginsForTarget(target);
if (!this.isCurrentSettingsTarget(target)) return;
if (pluginRefreshError !== undefined) this.pluginError = pluginRefreshError;
this.showSavedMessage();
} catch (error) {
if (this.isCurrentSettingsTarget(target)) {
this.pluginError = `Failed to save PI WEB plugin config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
}
} finally {
this.saving = false;
}
}
private async saveConfig(config: PiWebConfigValues): Promise<void> {
@@ -263,6 +404,61 @@ export class SettingsDialog extends LitElement {
}
}
private async saveMachineAccessConfig(config: PiWebConfigValues): Promise<void> {
if (this.saving) return;
const target = this.settingsTarget();
const support = this.selectedMachineSettingsSupport(target);
if (isSelectedMachineSettingsUnsupported(support)) {
this.accessError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
this.saving = true;
this.accessError = "";
this.savedMessage = "";
try {
const response = await configApi.saveConfig(config, target.id);
if (!this.isCurrentSettingsTarget(target)) return;
this.accessConfigResponse = response;
if (target.kind === "local" && this.configResponse !== undefined) {
this.configResponse = mergeSelectedMachineAccessConfig(this.configResponse, response);
this.onConfigSaved?.(this.configResponse.effectiveConfig);
}
this.showSavedMessage();
} catch (error) {
if (this.isCurrentSettingsTarget(target)) {
this.accessError = `Failed to save file access/upload config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
}
} finally {
this.saving = false;
}
}
private async saveSessiondConfig(config: PiWebConfigValues): Promise<void> {
if (this.saving) return;
const target = this.settingsTarget();
const support = this.selectedMachineSettingsSupport(target);
if (isSelectedMachineSettingsUnsupported(support)) {
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
this.saving = true;
this.sessiondError = "";
this.savedMessage = "";
try {
const response = await configApi.saveConfig(config, target.id);
if (!this.isCurrentSettingsTarget(target)) return;
this.sessiondConfigResponse = response;
if (target.kind === "local" && this.configResponse !== undefined) this.configResponse = mergeSelectedMachineSessiondConfig(this.configResponse, response);
this.showSavedMessage();
} catch (error) {
if (this.isCurrentSettingsTarget(target)) {
this.sessiondError = `Failed to save session-daemon config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
}
} finally {
this.saving = false;
}
}
private async installPiPackage(source: string): Promise<void> {
const target = this.packageTarget();
await this.runPiPackageMutation({ kind: "install", source }, "install Pi package", target, () => piPackagesApi.install(source, target.id));
@@ -296,7 +492,7 @@ export class SettingsDialog extends LitElement {
const response = await mutate();
if (!this.isCurrentPackageMutation(requestSeq, target)) return;
this.packagesResponse = { packages: response.packages };
const pluginRefreshError = shouldRefreshGatewayPluginsAfterPiPackageMutation(target) ? await this.refreshPlugins() : undefined;
const pluginRefreshError = shouldRefreshGatewayPluginsAfterPiPackageMutation(target) ? await this.refreshGatewayPlugins() : undefined;
if (!this.isCurrentPackageMutation(requestSeq, target)) return;
if (pluginRefreshError !== undefined) this.packageError = pluginRefreshError;
this.packageMessage = piPackageMutationFollowUpMessage(response.action, target);
@@ -311,17 +507,41 @@ export class SettingsDialog extends LitElement {
}
}
private async refreshPlugins(): Promise<string | undefined> {
private async refreshGatewayPlugins(): Promise<string | undefined> {
try {
this.pluginsResponse = await pluginsApi.plugins();
return undefined;
} catch (error) {
return `Failed to refresh PI WEB plugins: ${errorMessage(error)}`;
return `Failed to refresh gateway PI WEB plugins: ${errorMessage(error)}`;
}
}
private async refreshPluginsForTarget(target: SettingsMachineTarget): Promise<string | undefined> {
try {
const response = await pluginsApi.plugins(target.id);
if (this.isCurrentSettingsTarget(target)) this.selectedPluginsResponse = response;
return undefined;
} catch (error) {
return `Config saved, but failed to refresh PI WEB plugins from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
}
}
private settingsTarget(): SettingsMachineTarget {
return settingsMachineTarget(this.machine);
}
private packageTarget(): PiPackageTargetContext {
return piPackageTargetContext(this.machine);
return this.settingsTarget();
}
private selectedMachineSettingsSupport(target = this.settingsTarget()): SelectedMachineSettingsSupport {
return selectedMachineSettingsSupport(target, this.machineRuntime);
}
private selectedMachineSettingsSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: SettingsMachineTarget): boolean {
const previousSupport = selectedMachineSettingsSupport(target, previousRuntime);
const currentSupport = this.selectedMachineSettingsSupport(target);
return selectedMachineSettingsSupportKey(previousSupport) !== selectedMachineSettingsSupportKey(currentSupport);
}
private packageManagementSupport(target = this.packageTarget()): PiPackageManagementSupport {
@@ -339,6 +559,18 @@ export class SettingsDialog extends LitElement {
return requestSeq === this.loadRequestSeq;
}
private isCurrentAccessLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
return requestSeq === this.accessLoadRequestSeq && this.isCurrentSettingsTarget(target);
}
private isCurrentSessiondLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
return requestSeq === this.sessiondLoadRequestSeq && this.isCurrentSettingsTarget(target);
}
private isCurrentPluginLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
return requestSeq === this.pluginLoadRequestSeq && this.isCurrentSettingsTarget(target);
}
private isCurrentPackageLoad(requestSeq: number, target: PiPackageTargetContext): boolean {
return requestSeq === this.packageLoadRequestSeq && this.isCurrentPackageTarget(target);
}
@@ -351,6 +583,35 @@ export class SettingsDialog extends LitElement {
return this.packageTarget().id === target.id;
}
private isCurrentSettingsTarget(target: SettingsMachineTarget): boolean {
return this.settingsTarget().id === target.id;
}
private resetAccessStateForTargetChange(): void {
this.accessLoadRequestSeq += 1;
this.accessLoading = false;
this.accessError = "";
this.accessConfigResponse = undefined;
this.savedMessage = "";
}
private resetSessiondStateForTargetChange(): void {
this.sessiondLoadRequestSeq += 1;
this.sessiondLoading = false;
this.sessiondError = "";
this.sessiondConfigResponse = undefined;
this.savedMessage = "";
}
private resetPluginStateForTargetChange(): void {
this.pluginLoadRequestSeq += 1;
this.pluginLoading = false;
this.pluginError = "";
this.selectedPluginConfigResponse = undefined;
this.selectedPluginsResponse = undefined;
this.savedMessage = "";
}
private resetPackageStateForTargetChange(): void {
const hadPackageOperation = this.packageOperation !== undefined;
this.packageLoadRequestSeq += 1;
@@ -0,0 +1,229 @@
import { describe, expect, it, vi } from "vitest";
import type { TemplateResult } from "lit";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { SettingsGeneralPanel } from "./SettingsGeneralPanel";
import type { GatewayServerConfigDraft, MachineAccessConfigDraft } from "./settingsConfigDraft";
describe("settings-general-panel copy", () => {
it("uses factual scope copy for gateway and selected-machine settings", () => {
const panel = new SettingsGeneralPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.configResponse = configResponse({ host: "127.0.0.1" });
panel.machineConfigResponse = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } });
const template = panel.render();
const strings = collectTemplateStrings(template).join("");
const values = collectTemplateValues(template);
expect(strings).toContain("Gateway server fields edit this local gateway. File access and upload defaults edit ");
expect(strings).toContain("Host, port, and allowed hosts are saved in the gateway config.");
expect(strings).toContain("External filesystem roots and upload defaults are saved on ");
expect(values.filter((value) => value === "Lab Mac (remote machine)")).toHaveLength(4);
});
it("shows reload copy when selected-machine access config is unavailable", () => {
const panel = new SettingsGeneralPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.configResponse = configResponse({ host: "127.0.0.1" });
panel.machineError = "Failed to load file access/upload config from Lab Mac (remote machine): unsupported";
const template = panel.render();
const values = collectTemplateValues(template);
expect(values).toContain("Selected-machine file access config is unavailable. Reload before saving file/upload settings.");
expect(values).toContain("Failed to load file access/upload config from Lab Mac (remote machine): unsupported");
});
});
describe("settings-general-panel save payloads", () => {
it("saves gateway server fields through the gateway save callback only", async () => {
const panel = new SettingsGeneralPanel();
const onSave = vi.fn();
const onSaveMachineConfig = vi.fn();
const event = new Event("submit", { cancelable: true });
panel.configResponse = configResponse({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["old.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/gateway"] },
uploads: { defaultFolder: "gateway/uploads" },
spawnSessions: true,
});
panel.onSave = onSave;
panel.onSaveMachineConfig = onSaveMachineConfig;
setPanelProperty(panel, "gatewayDraft", {
host: " 0.0.0.0 ",
port: "9000",
allowedHostsMode: "all",
allowedHostsText: "ignored.local",
} satisfies GatewayServerConfigDraft);
await callPanelPromise(panel, "saveGatewayConfig", event);
expect(event.defaultPrevented).toBe(true);
expect(onSave.mock.calls).toEqual([[
{
host: "0.0.0.0",
port: 9000,
allowedHosts: true,
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/gateway"] },
uploads: { defaultFolder: "gateway/uploads" },
spawnSessions: true,
},
]]);
expect(onSaveMachineConfig).not.toHaveBeenCalled();
expect(getPanelProperty(panel, "gatewayLocalError")).toBe("");
});
it("saves external roots and upload defaults through the selected-machine save callback only", async () => {
const panel = new SettingsGeneralPanel();
const onSave = vi.fn();
const onSaveMachineConfig = vi.fn();
const event = new Event("submit", { cancelable: true });
panel.onSave = onSave;
panel.onSaveMachineConfig = onSaveMachineConfig;
setPanelProperty(panel, "machineDraft", {
allowedPathsText: "/tmp\n~/SDKs\n",
uploadDefaultFolder: " manual\\uploads/. ",
} satisfies MachineAccessConfigDraft);
await callPanelPromise(panel, "saveMachineAccessConfig", event);
expect(event.defaultPrevented).toBe(true);
expect(onSaveMachineConfig.mock.calls).toEqual([[
{
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
},
]]);
expect(onSave).not.toHaveBeenCalled();
expect(getPanelProperty(panel, "machineLocalError")).toBe("");
});
it("clears upload defaults with a selected-machine-safe patch", async () => {
const panel = new SettingsGeneralPanel();
const onSaveMachineConfig = vi.fn();
panel.onSaveMachineConfig = onSaveMachineConfig;
setPanelProperty(panel, "machineDraft", {
allowedPathsText: "",
uploadDefaultFolder: "",
} satisfies MachineAccessConfigDraft);
await callPanelPromise(panel, "saveMachineAccessConfig", new Event("submit", { cancelable: true }));
expect(onSaveMachineConfig.mock.calls).toEqual([[
{
pathAccess: { allowedPaths: [] },
uploads: {},
},
]]);
});
it("keeps invalid upload folders local and does not save selected-machine config", async () => {
const panel = new SettingsGeneralPanel();
const onSaveMachineConfig = vi.fn();
panel.onSaveMachineConfig = onSaveMachineConfig;
setPanelProperty(panel, "machineDraft", {
allowedPathsText: "",
uploadDefaultFolder: "/tmp/uploads",
} satisfies MachineAccessConfigDraft);
await callPanelPromise(panel, "saveMachineAccessConfig", new Event("submit", { cancelable: true }));
expect(onSaveMachineConfig).not.toHaveBeenCalled();
expect(getPanelProperty(panel, "machineLocalError")).toBe("Upload default folder must be workspace-relative.");
});
});
function collectTemplateStrings(template: TemplateResult): string[] {
const strings: string[] = [];
visitTemplate(template);
return strings;
function visitTemplate(current: TemplateResult): void {
strings.push(...templateStrings(current));
for (const value of templateValues(current)) {
if (Array.isArray(value)) {
for (const item of value) if (isTemplateResult(item)) visitTemplate(item);
} else if (isTemplateResult(value)) {
visitTemplate(value);
}
}
}
}
function collectTemplateValues(template: TemplateResult): unknown[] {
const values: unknown[] = [];
visit(template);
return values;
function visit(current: unknown): void {
if (Array.isArray(current)) {
for (const item of current) visit(item);
return;
}
if (!isTemplateResult(current)) return;
for (const value of templateValues(current)) {
values.push(value);
visit(value);
}
}
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function setPanelProperty(panel: SettingsGeneralPanel, property: string, value: unknown): void {
if (!Reflect.set(panel, property, value)) throw new Error(`Failed to set SettingsGeneralPanel property ${property}`);
}
function getPanelProperty(panel: SettingsGeneralPanel, property: string): unknown {
return Reflect.get(panel, property);
}
async function callPanelPromise(panel: SettingsGeneralPanel, methodName: string, ...args: readonly unknown[]): Promise<void> {
const result = callPanelMethod(panel, methodName, ...args);
if (!(result instanceof Promise)) throw new Error(`SettingsGeneralPanel.${methodName} did not return a promise`);
await result;
}
function callPanelMethod(panel: SettingsGeneralPanel, methodName: string, ...args: readonly unknown[]): unknown {
const method: unknown = Reflect.get(panel, methodName);
if (!isPanelMethod(method)) throw new Error(`SettingsGeneralPanel.${methodName} is not callable`);
return method.call(panel, ...args);
}
function isPanelMethod(value: unknown): value is (this: SettingsGeneralPanel, ...args: readonly unknown[]) => unknown {
return typeof value === "function";
}
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
@@ -1,99 +1,181 @@
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { configFromDraft, draftFromConfig, emptyConfigDraft, type ConfigDraft } from "./settingsConfigDraft";
import { DEFAULT_WORKSPACE_UPLOADS_FOLDER, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues } from "../../api";
import {
emptyGatewayServerConfigDraft,
emptyMachineAccessConfigDraft,
gatewayServerConfigFromDraft,
gatewayServerDraftFromConfig,
machineAccessConfigPatchFromDraft,
machineAccessDraftFromConfig,
type GatewayServerConfigDraft,
type MachineAccessConfigDraft,
} from "./settingsConfigDraft";
@customElement("settings-general-panel")
export class SettingsGeneralPanel extends LitElement {
@property({ attribute: false }) configResponse: PiWebConfigResponse | undefined;
@property({ attribute: false }) machineConfigResponse: PiWebConfigResponse | undefined;
@property({ type: Boolean }) loading = false;
@property({ type: Boolean }) machineLoading = false;
@property({ type: Boolean }) saving = false;
@property() error = "";
@property() machineError = "";
@property() savedMessage = "";
@property() targetLabel = "selected machine";
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onReloadMachine?: () => void | Promise<void>;
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
@state() private draft: ConfigDraft = emptyConfigDraft();
@state() private localError = "";
@property({ attribute: false }) onSaveMachineConfig?: (config: PiWebConfigValues) => void | Promise<void>;
@state() private gatewayDraft: GatewayServerConfigDraft = emptyGatewayServerConfigDraft();
@state() private machineDraft: MachineAccessConfigDraft = emptyMachineAccessConfigDraft();
@state() private gatewayLocalError = "";
@state() private machineLocalError = "";
protected override willUpdate(changed: PropertyValues<this>): void {
if (changed.has("configResponse") && this.configResponse !== undefined) {
this.draft = draftFromConfig(this.configResponse.config);
this.localError = "";
this.gatewayDraft = gatewayServerDraftFromConfig(this.configResponse.config);
this.gatewayLocalError = "";
}
if (changed.has("machineConfigResponse") && this.machineConfigResponse !== undefined) {
this.machineDraft = machineAccessDraftFromConfig(this.machineConfigResponse.config);
this.machineLocalError = "";
}
}
override render(): TemplateResult {
const config = this.configResponse;
return html`
<div class="section-heading">
<div>
<h2>General configuration</h2>
<p>Update the JSON config file PI WEB is using. Host and port changes are saved immediately, but require the web service to restart before the running server binds to the new address.</p>
<p>Gateway server fields edit this local gateway. File access and upload defaults edit ${this.targetLabel}.</p>
</div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
<button class="secondary" ?disabled=${this.loading || this.machineLoading} @click=${() => { this.reloadAll(); }}>Reload</button>
</div>
${this.renderSavedMessage()}
<div class="settings-sections">
${this.renderGatewayServerSettings()}
${this.renderSelectedMachineAccessSettings()}
</div>
${this.renderMessages()}
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
<div class="config-path-card">
<span>Config file</span>
<code>${config?.path ?? "Unknown"}</code>
<small>${config?.exists === true ? "Existing file" : "This file will be created on save"}</small>
</div>
<form class="config-form" @submit=${(event: Event) => { void this.saveConfig(event); }}>
<label class="field">
<span class="field-heading">
<span>Host</span>
${this.renderOverrideBadge("host")}
</span>
<input .value=${this.draft.host} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ host: inputValue(event) }); }}>
<small>Address the web server should bind to. Leave empty to use PI WEB's default.</small>
</label>
<label class="field">
<span class="field-heading">
<span>Port</span>
${this.renderOverrideBadge("port")}
</span>
<input .value=${this.draft.port} inputmode="numeric" pattern="[0-9]*" placeholder="8504" autocomplete="off" @input=${(event: Event) => { this.updateDraft({ port: inputValue(event) }); }}>
<small>TCP port from 1 to 65535. Leave empty to use PI WEB's default.</small>
</label>
<div class="field">
<span class="field-heading">
<span>Allowed hosts</span>
${this.renderOverrideBadge("allowedHosts")}
</span>
<select .value=${this.draft.allowedHostsMode} @change=${(event: Event) => { this.updateDraft({ allowedHostsMode: selectValue(event) === "all" ? "all" : "list" }); }}>
<option value="list">Only listed hosts</option>
<option value="all">Allow every host</option>
</select>
<textarea .value=${this.draft.allowedHostsText} ?disabled=${this.draft.allowedHostsMode === "all"} rows="4" placeholder="example.local&#10;192.168.1.20" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedHostsText: textAreaValue(event) }); }}></textarea>
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
</div>
<label class="field">
<span class="field-heading">
<span>External filesystem roots</span>
</span>
<textarea .value=${this.draft.allowedPathsText} rows="4" placeholder="~/SDKs&#10;/opt/reference" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedPathsText: textAreaValue(event) }); }}></textarea>
<small>Global allowlist for absolute <code>@</code> completions and file explorer reads outside a workspace. Enter one absolute path, Windows absolute path, or <code>~</code>-prefixed path per line. Leave empty to deny external paths by default.</small>
</label>
${this.renderEffectiveConfig()}
<footer class="form-actions">
<button class="primary" ?disabled=${this.loading || this.saving}>${this.saving ? "Saving…" : "Save config"}</button>
</footer>
</form>
`}
`;
}
private renderMessages(): TemplateResult | null {
const error = this.localError || this.error;
if (error !== "") return html`<div class="message error-message">${error}</div>`;
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
return null;
private renderGatewayServerSettings(): TemplateResult {
const config = this.configResponse;
return html`
<section class="settings-card" aria-label="Gateway server settings">
<div class="card-heading">
<h3>Gateway server</h3>
<p>Host, port, and allowed hosts are saved in the gateway config. Address changes require the web service to restart before the running server binds to the new address.</p>
</div>
${this.renderGatewayMessages()}
${config === undefined && this.loading ? html`<div class="loading-card">Loading gateway configuration…</div>` : html`
<div class="config-path-card">
<span>Gateway config file</span>
<code>${config?.path ?? "Unknown"}</code>
<small>${config?.exists === true ? "Existing file" : "This file will be created on save"}</small>
</div>
<form class="config-form" @submit=${(event: Event) => { void this.saveGatewayConfig(event); }}>
<label class="field">
<span class="field-heading">
<span>Host</span>
${this.renderOverrideBadge("host")}
</span>
<input .value=${this.gatewayDraft.host} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateGatewayDraft({ host: inputValue(event) }); }}>
<small>Address the web server should bind to. Leave empty to use PI WEB's default.</small>
</label>
<label class="field">
<span class="field-heading">
<span>Port</span>
${this.renderOverrideBadge("port")}
</span>
<input .value=${this.gatewayDraft.port} inputmode="numeric" pattern="[0-9]*" placeholder="8504" autocomplete="off" @input=${(event: Event) => { this.updateGatewayDraft({ port: inputValue(event) }); }}>
<small>TCP port from 1 to 65535. Leave empty to use PI WEB's default.</small>
</label>
<div class="field">
<span class="field-heading">
<span>Allowed hosts</span>
${this.renderOverrideBadge("allowedHosts")}
</span>
<select .value=${this.gatewayDraft.allowedHostsMode} @change=${(event: Event) => { this.updateGatewayDraft({ allowedHostsMode: selectValue(event) === "all" ? "all" : "list" }); }}>
<option value="list">Only listed hosts</option>
<option value="all">Allow every host</option>
</select>
<textarea .value=${this.gatewayDraft.allowedHostsText} ?disabled=${this.gatewayDraft.allowedHostsMode === "all"} rows="4" placeholder="example.local&#10;192.168.1.20" spellcheck="false" @input=${(event: Event) => { this.updateGatewayDraft({ allowedHostsText: textAreaValue(event) }); }}></textarea>
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
</div>
${this.renderGatewayEffectiveConfig()}
<footer class="form-actions">
<button class="primary" ?disabled=${this.loading || this.saving}>${this.saving ? "Saving…" : "Save gateway server config"}</button>
</footer>
</form>
`}
</section>
`;
}
private renderSelectedMachineAccessSettings(): TemplateResult {
const config = this.machineConfigResponse;
return html`
<section class="settings-card" aria-label="Selected machine file access and upload settings">
<div class="card-heading">
<h3>Selected machine file access and uploads</h3>
<p>External filesystem roots and upload defaults are saved on ${this.targetLabel}.</p>
</div>
${this.renderMachineMessages()}
${config === undefined ? html`<div class="loading-card">${this.machineLoading ? "Loading selected-machine file access config…" : "Selected-machine file access config is unavailable. Reload before saving file/upload settings."}</div>` : html`
<div class="config-path-card">
<span>Selected machine config file</span>
<code>${config.path}</code>
<small>${config.exists ? "Existing file" : "This file will be created on save"}</small>
</div>
<form class="config-form" @submit=${(event: Event) => { void this.saveMachineAccessConfig(event); }}>
<label class="field">
<span class="field-heading">
<span>External filesystem roots</span>
</span>
<textarea .value=${this.machineDraft.allowedPathsText} rows="4" placeholder="~/SDKs&#10;/opt/reference" spellcheck="false" @input=${(event: Event) => { this.updateMachineDraft({ allowedPathsText: textAreaValue(event) }); }}></textarea>
<small>Allowlist for absolute <code>@</code> completions and file explorer reads outside a workspace on ${this.targetLabel}. Enter one absolute path, Windows absolute path, or <code>~</code>-prefixed path per line. Leave empty to deny external paths by default.</small>
</label>
<label class="field">
<span class="field-heading">
<span>Default upload folder</span>
</span>
<input .value=${this.machineDraft.uploadDefaultFolder} placeholder=${DEFAULT_WORKSPACE_UPLOADS_FOLDER} autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateMachineDraft({ uploadDefaultFolder: inputValue(event) }); }}>
<small>Workspace-relative folder for manual file uploads on ${this.targetLabel}. Leave empty to use PI WEB's default <code>${DEFAULT_WORKSPACE_UPLOADS_FOLDER}</code>.</small>
</label>
${this.renderMachineEffectiveConfig()}
<footer class="form-actions">
<button class="primary" ?disabled=${this.machineLoading || this.saving}>${this.saving ? "Saving…" : "Save file/upload config"}</button>
</footer>
</form>
`}
</section>
`;
}
private renderSavedMessage(): TemplateResult | null {
if (this.savedMessage === "") return null;
return html`<div class="message success-message">${this.savedMessage}</div>`;
}
private renderGatewayMessages(): TemplateResult | null {
const error = this.gatewayLocalError || this.error;
if (error === "") return null;
return html`<div class="message error-message">${error}</div>`;
}
private renderMachineMessages(): TemplateResult | null {
const error = this.machineLocalError || this.machineError;
if (error === "") return null;
return html`<div class="message error-message">${error}</div>`;
}
private renderOverrideBadge(key: keyof PiWebConfigEnvOverrides): TemplateResult | null {
@@ -101,40 +183,72 @@ export class SettingsGeneralPanel extends LitElement {
return html`<span class="override-badge">environment override</span>`;
}
private renderEffectiveConfig(): TemplateResult {
private renderGatewayEffectiveConfig(): TemplateResult {
const effective = this.configResponse?.effectiveConfig ?? {};
return html`
<section class="effective-card" aria-label="Effective configuration summary">
<h3>Effective after environment overrides</h3>
<section class="effective-card" aria-label="Effective gateway configuration summary">
<h3>Effective gateway settings after environment overrides</h3>
<dl>
<div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div>
<div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div>
<div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div>
<div><dt>External roots</dt><dd>${formatAllowedPaths(effective.pathAccess?.allowedPaths)}</dd></div>
</dl>
</section>
`;
}
private async saveConfig(event: Event): Promise<void> {
private renderMachineEffectiveConfig(): TemplateResult {
const effective = this.machineConfigResponse?.effectiveConfig ?? {};
return html`
<section class="effective-card" aria-label="Effective selected machine file access and upload summary">
<h3>Effective selected-machine settings</h3>
<dl>
<div><dt>External roots</dt><dd>${formatAllowedPaths(effective.pathAccess?.allowedPaths)}</dd></div>
<div><dt>Upload folder</dt><dd>${effective.uploads?.defaultFolder ?? html`<span class="muted">${DEFAULT_WORKSPACE_UPLOADS_FOLDER} default</span>`}</dd></div>
</dl>
</section>
`;
}
private reloadAll(): void {
void this.onReload?.();
void this.onReloadMachine?.();
}
private async saveGatewayConfig(event: Event): Promise<void> {
event.preventDefault();
this.localError = "";
this.gatewayLocalError = "";
try {
await this.onSave?.(configFromDraft(this.draft, this.configResponse?.config ?? {}));
await this.onSave?.(gatewayServerConfigFromDraft(this.gatewayDraft, this.configResponse?.config ?? {}));
} catch (error) {
this.localError = errorMessage(error);
this.gatewayLocalError = errorMessage(error);
}
}
private updateDraft(patch: Partial<ConfigDraft>): void {
this.draft = { ...this.draft, ...patch };
this.localError = "";
private async saveMachineAccessConfig(event: Event): Promise<void> {
event.preventDefault();
this.machineLocalError = "";
try {
await this.onSaveMachineConfig?.(machineAccessConfigPatchFromDraft(this.machineDraft));
} catch (error) {
this.machineLocalError = errorMessage(error);
}
}
private updateGatewayDraft(patch: Partial<GatewayServerConfigDraft>): void {
this.gatewayDraft = { ...this.gatewayDraft, ...patch };
this.gatewayLocalError = "";
}
private updateMachineDraft(patch: Partial<MachineAccessConfigDraft>): void {
this.machineDraft = { ...this.machineDraft, ...patch };
this.machineLocalError = "";
}
static override styles = css`
:host { display: block; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-heading > div { display: grid; gap: 6px; min-width: 0; }
.section-heading > div, .card-heading { display: grid; gap: 6px; min-width: 0; }
h2, h3, p { margin: 0; }
h2 { font-size: 17px; line-height: 1.25; }
h3 { font-size: 13px; line-height: 1.3; }
@@ -143,12 +257,15 @@ export class SettingsGeneralPanel extends LitElement {
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
button:disabled { opacity: .55; cursor: not-allowed; }
.secondary { flex: 0 0 auto; }
.message, .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.settings-sections { display: grid; gap: 14px; }
.settings-card, .message, .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.settings-card { display: grid; gap: 14px; }
.message { margin-bottom: 12px; }
.settings-card .message { margin-bottom: 0; }
.error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
.loading-card { color: var(--pi-muted); }
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
.config-path-card { display: grid; gap: 5px; }
.config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
.config-path-card small, .field small { color: var(--pi-muted); }
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import type { TemplateResult } from "lit";
import type { PiWebConfigResponse, PiWebConfigValues, PiWebPluginInfo } from "../../api";
import { SettingsPluginsPanel } from "./SettingsPluginsPanel";
describe("settings-plugins-panel copy", () => {
it("names the selected machine in plugin scope copy", () => {
const panel = new SettingsPluginsPanel();
panel.targetLabel = "Lab Mac (remote machine)";
const template = panel.render();
const strings = collectTemplateStrings(template).join("");
const values = collectTemplateValues(template);
expect(strings).toContain("Enable or disable discovered PI WEB browser plugins on ");
expect(strings).toContain("Config key on ");
expect(strings).toContain("No PI WEB browser plugins discovered on ");
expect(values.filter((value) => value === "Lab Mac (remote machine)")).toHaveLength(3);
});
});
describe("settings-plugins-panel state", () => {
it("shows disabled remote plugins from the selected machine plugin list", () => {
const panel = new SettingsPluginsPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.configResponse = configResponse({ plugins: { "remote-disabled": { enabled: false } } });
panel.pluginsResponse = { plugins: [pluginInfo("remote-disabled", false)] };
const values = collectTemplateValues(panel.render());
expect(values).toContain("remote-disabled");
expect(values).toContain("Config disabled");
expect(values).toContain("Disabled");
});
it("disables plugin toggles while selected-machine config is unavailable", () => {
const panel = new SettingsPluginsPanel();
panel.pluginsResponse = { plugins: [pluginInfo("remote-disabled", false)] };
expect(collectTemplateStrings(panel.render()).join("")).toContain("Configuration is unavailable. Reload to try again before changing plugin enablement.");
expect(templateValues(renderPluginTemplate(panel, pluginInfo("remote-disabled", false))).filter(isBoolean)).toEqual([false, true]);
});
});
function renderPluginTemplate(panel: SettingsPluginsPanel, plugin: PiWebPluginInfo): TemplateResult {
const renderPlugin: unknown = Reflect.get(panel, "renderPlugin");
if (!isPanelRenderPlugin(renderPlugin)) throw new Error("SettingsPluginsPanel.renderPlugin is not callable");
return renderPlugin.call(panel, plugin);
}
function isPanelRenderPlugin(value: unknown): value is (this: SettingsPluginsPanel, plugin: PiWebPluginInfo) => TemplateResult {
return typeof value === "function";
}
function collectTemplateStrings(template: TemplateResult): string[] {
const strings: string[] = [];
visitTemplate(template);
return strings;
function visitTemplate(current: TemplateResult): void {
strings.push(...templateStrings(current));
for (const value of templateValues(current)) {
if (Array.isArray(value)) {
for (const item of value) if (isTemplateResult(item)) visitTemplate(item);
} else if (isTemplateResult(value)) {
visitTemplate(value);
}
}
}
}
function collectTemplateValues(template: TemplateResult): unknown[] {
const values: unknown[] = [];
visit(template);
return values;
function visit(current: unknown): void {
if (Array.isArray(current)) {
for (const item of current) visit(item);
return;
}
if (!isTemplateResult(current)) return;
for (const value of templateValues(current)) {
values.push(value);
visit(value);
}
}
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function isBoolean(value: unknown): value is boolean {
return typeof value === "boolean";
}
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo {
return {
id,
module: `/pi-web-plugins/${id}/plugin.js`,
source: "test",
scope: "local",
machineSpecific: false,
enabled,
};
}
@@ -10,6 +10,7 @@ export class SettingsPluginsPanel extends LitElement {
@property({ type: Boolean }) saving = false;
@property() error = "";
@property() savedMessage = "";
@property() targetLabel = "local (local gateway)";
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onTogglePlugin?: (pluginId: string, enabled: boolean) => void | Promise<void>;
@@ -19,14 +20,15 @@ export class SettingsPluginsPanel extends LitElement {
<div class="section-heading">
<div>
<h2>PI WEB plugins</h2>
<p>Enable or disable discovered PI WEB browser plugins. This is separate from installing Pi packages. Changes apply after reloading the browser tab; already-loaded plugin code is not unloaded from the current page.</p>
<p>Enable or disable discovered PI WEB browser plugins on <strong>${this.targetLabel}</strong>. This is separate from installing Pi packages. Reload the browser tab to apply plugin runtime changes.</p>
</div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
${this.renderMessages()}
<div class="trust-warning"><strong>Trusted code warning:</strong> PI WEB plugins and Pi packages can run with your user permissions. Enable plugins only from sources you trust.</div>
<div class="plugin-note">Config key: <code>plugins</code>. Plugins are enabled unless their entry sets <code>enabled</code> to <code>false</code>.</div>
${this.loading && plugins.length === 0 ? html`<div class="loading-card">Loading PI WEB plugins…</div>` : plugins.length === 0 ? html`<div class="loading-card">No PI WEB browser plugins discovered.</div>` : html`
<div class="plugin-note">Config key on ${this.targetLabel}: <code>plugins</code>. Plugins are enabled unless their entry sets <code>enabled</code> to <code>false</code>.</div>
${this.configResponse === undefined && !this.loading ? html`<div class="loading-card">Configuration is unavailable. Reload to try again before changing plugin enablement.</div>` : null}
${this.loading && plugins.length === 0 ? html`<div class="loading-card">Loading PI WEB plugins…</div>` : plugins.length === 0 ? html`<div class="loading-card">No PI WEB browser plugins discovered on ${this.targetLabel}.</div>` : html`
<div class="plugin-list">
${plugins.map((plugin) => this.renderPlugin(plugin))}
</div>
@@ -51,7 +53,7 @@ export class SettingsPluginsPanel extends LitElement {
<small>${configuredState}</small>
</div>
<label class="toggle">
<input type="checkbox" .checked=${plugin.enabled} ?disabled=${this.saving} @change=${(event: Event) => { void this.togglePlugin(plugin, event); }}>
<input type="checkbox" .checked=${plugin.enabled} ?disabled=${this.saving || this.configResponse === undefined} @change=${(event: Event) => { void this.togglePlugin(plugin, event); }}>
<span>${plugin.enabled ? "Enabled" : "Disabled"}</span>
</label>
</article>
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import type { TemplateResult } from "lit";
import { SettingsSessiondPanel } from "./SettingsSessiondPanel";
describe("settings-sessiond-panel copy", () => {
it("names the selected machine in the scope and restart copy", () => {
const panel = new SettingsSessiondPanel();
panel.targetLabel = "Lab Mac (remote machine)";
const template = panel.render();
const strings = templateStrings(template);
const values = templateValues(template);
expect(values.filter((value) => value === "Lab Mac (remote machine)")).toHaveLength(2);
expect(strings.join("")).toContain("These settings affect the long-lived session runtime on ");
expect(strings.join("")).toContain("Restart required on ");
expect(strings.join("")).toContain("run <code>pi-web restart</code> on that machine");
});
});
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
@@ -1,6 +1,7 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
@customElement("settings-sessiond-panel")
export class SettingsSessiondPanel extends LitElement {
@@ -9,6 +10,7 @@ export class SettingsSessiondPanel extends LitElement {
@property({ type: Boolean }) saving = false;
@property() error = "";
@property() savedMessage = "";
@property() targetLabel = "local (local gateway)";
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
@@ -25,16 +27,16 @@ export class SettingsSessiondPanel extends LitElement {
<div class="section-heading">
<div>
<h2>Session daemon</h2>
<p>These settings affect the long-lived session runtime. Changes are saved to the config file immediately but only take effect after the session daemon restarts.</p>
<p>These settings affect the long-lived session runtime on ${this.targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.</p>
</div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
${this.renderMessages()}
<div class="restart-note" role="note">Restart required: run <code>pi-web restart</code> (or restart the session daemon service) after changing these settings.</div>
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
<div class="restart-note" role="note">Restart required on ${this.targetLabel}: run <code>pi-web restart</code> on that machine (or restart its session daemon service) after changing these settings.</div>
${config === undefined ? html`<div class="loading-card">${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}</div>` : html`
<div class="config-path-card">
<span>Config file</span>
<code>${config?.path ?? "Unknown"}</code>
<code>${config.path}</code>
</div>
<div class="field">
<span class="field-heading">
@@ -88,14 +90,12 @@ export class SettingsSessiondPanel extends LitElement {
private async toggleSpawnSessions(event: Event): Promise<void> {
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
const baseConfig = this.configResponse?.config ?? {};
await this.onSave?.({ ...baseConfig, spawnSessions: enabled });
await this.onSave?.(spawnSessionsConfigPatch(enabled));
}
private async toggleSubsessions(event: Event): Promise<void> {
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
const baseConfig = this.configResponse?.config ?? {};
await this.onSave?.({ ...baseConfig, subsessions: enabled });
await this.onSave?.(subsessionsConfigPatch(enabled));
}
static override styles = css`
@@ -1,7 +1,82 @@
import { describe, expect, it } from "vitest";
import { configFromDraft, draftFromConfig } from "./settingsConfigDraft";
import {
configFromDraft,
draftFromConfig,
gatewayServerConfigFromDraft,
gatewayServerDraftFromConfig,
machineAccessConfigPatchFromDraft,
machineAccessDraftFromConfig,
} from "./settingsConfigDraft";
describe("settings config drafts", () => {
it("splits gateway server and selected-machine access drafts", () => {
const config = {
host: "0.0.0.0",
port: 8504,
allowedHosts: ["example.local", "192.168.1.20"],
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
};
expect(gatewayServerDraftFromConfig(config)).toEqual({
host: "0.0.0.0",
port: "8504",
allowedHostsMode: "list",
allowedHostsText: "example.local\n192.168.1.20",
});
expect(machineAccessDraftFromConfig(config)).toEqual({
allowedPathsText: "/tmp\n~/SDKs",
uploadDefaultFolder: "manual/uploads",
});
});
it("builds gateway server saves without changing selected-machine-safe config values", () => {
expect(gatewayServerConfigFromDraft({
host: " gateway.local ",
port: "9000",
allowedHostsMode: "all",
allowedHostsText: "ignored.local",
}, {
pathAccess: { allowedPaths: ["/old"] },
uploads: { defaultFolder: "old/uploads" },
maxUploadBytes: 1234,
spawnSessions: true,
})).toEqual({
host: "gateway.local",
port: 9000,
allowedHosts: true,
pathAccess: { allowedPaths: ["/old"] },
uploads: { defaultFolder: "old/uploads" },
maxUploadBytes: 1234,
spawnSessions: true,
});
});
it("builds selected-machine access/upload patches only from selected-machine-safe fields", () => {
const patch = machineAccessConfigPatchFromDraft({
allowedPathsText: "/tmp\n~/SDKs\n",
uploadDefaultFolder: " manual\\uploads/. ",
});
expect(Object.keys(patch)).toEqual(["pathAccess", "uploads"]);
expect(patch).toEqual({
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
});
});
it("clears selected-machine access/upload settings with safe default patches", () => {
expect(machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "" })).toEqual({
pathAccess: { allowedPaths: [] },
uploads: {},
});
});
it("rejects invalid selected-machine upload default folders before saving", () => {
expect(() => machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "/tmp/uploads" })).toThrow("Upload default folder must be workspace-relative.");
expect(() => machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "../secret" })).toThrow("Upload default folder must not contain path traversal.");
});
it("converts PI WEB config values to editable general settings drafts", () => {
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"], pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } })).toEqual({
host: "0.0.0.0",
@@ -1,36 +1,51 @@
import type { PiWebConfigValues } from "../../api";
export interface ConfigDraft {
export interface GatewayServerConfigDraft {
host: string;
port: string;
allowedHostsMode: "list" | "all";
allowedHostsText: string;
}
export interface MachineAccessConfigDraft {
allowedPathsText: string;
uploadDefaultFolder: string;
}
export interface ConfigDraft extends GatewayServerConfigDraft {
allowedPathsText: string;
}
export function emptyConfigDraft(): ConfigDraft {
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "", allowedPathsText: "" };
export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft {
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
}
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
export function emptyMachineAccessConfigDraft(): MachineAccessConfigDraft {
return { allowedPathsText: "", uploadDefaultFolder: "" };
}
export function gatewayServerDraftFromConfig(config: PiWebConfigValues): GatewayServerConfigDraft {
return {
host: config.host ?? "",
port: config.port === undefined ? "" : String(config.port),
allowedHostsMode: config.allowedHosts === true ? "all" : "list",
allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "",
allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
};
}
export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
const config: PiWebConfigValues = {
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }),
...(baseConfig.uploads === undefined ? {} : { uploads: baseConfig.uploads }),
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
export function machineAccessDraftFromConfig(config: PiWebConfigValues): MachineAccessConfigDraft {
return {
allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
uploadDefaultFolder: config.uploads?.defaultFolder ?? "",
};
}
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
return { ...gatewayServerDraftFromConfig(config), allowedPathsText: machineAccessDraftFromConfig(config).allowedPathsText };
}
export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
const config = preservedGatewayConfigRemainder(baseConfig);
const host = draft.host.trim();
const port = draft.port.trim();
if (host !== "") config.host = host;
@@ -40,11 +55,38 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
config.port = parsed;
}
config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText);
return config;
}
export function machineAccessConfigPatchFromDraft(draft: MachineAccessConfigDraft): PiWebConfigValues {
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
const uploadDefaultFolder = normalizeWorkspaceRelativeFolder(draft.uploadDefaultFolder);
return {
pathAccess: { allowedPaths },
uploads: uploadDefaultFolder === "" ? {} : { defaultFolder: uploadDefaultFolder },
};
}
export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
const config = gatewayServerConfigFromDraft(draft, baseConfig);
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
if (allowedPaths.length > 0) config.pathAccess = { allowedPaths };
else delete config.pathAccess;
return config;
}
function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebConfigValues {
return {
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }),
...(baseConfig.pathAccess === undefined ? {} : { pathAccess: baseConfig.pathAccess }),
...(baseConfig.uploads === undefined ? {} : { uploads: baseConfig.uploads }),
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
};
}
function parseAllowedHostsText(value: string): string[] {
return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== "");
}
@@ -56,6 +98,21 @@ function parseAllowedPathsText(value: string): string[] {
return paths;
}
function normalizeWorkspaceRelativeFolder(value: string): string {
const trimmed = value.trim();
if (trimmed === "") return "";
if (isAbsoluteLike(trimmed)) throw new Error("Upload default folder must be workspace-relative.");
const parts = trimmed.split(/[\\/]+/u).filter((part) => part !== "" && part !== ".");
if (parts.length === 0) return "";
if (parts.some((part) => part === "..")) throw new Error("Upload default folder must not contain path traversal.");
return parts.join("/");
}
function isAbsoluteishAllowedPath(path: string): boolean {
return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(path);
}
function isAbsoluteLike(value: string): boolean {
const withForwardSlashes = value.replace(/\\/g, "/");
return withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//u.test(withForwardSlashes);
}
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { mergeSelectedMachineAccessConfig } from "./settingsMachineAccessConfig";
describe("selected-machine access config helpers", () => {
it("merges local selected-machine file/upload config into gateway config without dropping gateway-only values", () => {
const gateway = configResponse({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["/old"] },
uploads: { defaultFolder: "old/uploads" },
maxUploadBytes: 1234,
});
const selectedMachine = configResponse({
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
maxUploadBytes: 5678,
});
expect(mergeSelectedMachineAccessConfig(gateway, selectedMachine)).toEqual({
...gateway,
config: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
maxUploadBytes: 5678,
},
effectiveConfig: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
maxUploadBytes: 5678,
},
});
});
it("merges cleared selected-machine access/upload defaults without clearing gateway-only values", () => {
const gateway = configResponse({
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
pathAccess: { allowedPaths: ["/old"] },
uploads: { defaultFolder: "old/uploads" },
});
const selectedMachine = configResponse({ pathAccess: { allowedPaths: [] }, uploads: {} });
expect(mergeSelectedMachineAccessConfig(gateway, selectedMachine)).toEqual({
...gateway,
config: {
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
pathAccess: { allowedPaths: [] },
uploads: {},
},
effectiveConfig: {
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
pathAccess: { allowedPaths: [] },
uploads: {},
},
});
});
});
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
@@ -0,0 +1,18 @@
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
export function mergeSelectedMachineAccessConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
return {
...base,
config: mergeAccessConfig(base.config, selectedMachine.config),
effectiveConfig: mergeAccessConfig(base.effectiveConfig, selectedMachine.effectiveConfig),
};
}
function mergeAccessConfig(base: PiWebConfigValues, selectedMachine: PiWebConfigValues): PiWebConfigValues {
return {
...base,
...(selectedMachine.pathAccess === undefined ? {} : { pathAccess: selectedMachine.pathAccess }),
...(selectedMachine.uploads === undefined ? {} : { uploads: selectedMachine.uploads }),
...(selectedMachine.maxUploadBytes === undefined ? {} : { maxUploadBytes: selectedMachine.maxUploadBytes }),
};
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import type { Machine, MachineRuntime } from "../../api";
import { PI_WEB_CAPABILITIES } from "../../../../shared/capabilities";
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget";
const remoteMachine: Machine = {
id: "remote-a",
name: "Lab Mac",
kind: "remote",
baseUrl: "https://lab.example.test",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
};
describe("selected-machine settings target helpers", () => {
it("uses the selected machine when present and falls back to the local gateway", () => {
expect(settingsMachineTarget(undefined)).toEqual({ id: "local", name: "local", kind: "local" });
expect(settingsMachineTarget(remoteMachine)).toEqual({ id: "remote-a", name: "Lab Mac", kind: "remote" });
});
it("labels local and remote settings targets factually", () => {
expect(settingsMachineTargetLabel({ id: "local", name: "local", kind: "local" })).toBe("local (local gateway)");
expect(settingsMachineTargetLabel(settingsMachineTarget(remoteMachine))).toBe("Lab Mac (remote machine)");
});
it("gates remote selected-machine settings on advertised runtime support", () => {
const target = settingsMachineTarget(remoteMachine);
const supportedRuntime: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings] };
const unsupportedRuntime: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.piPackagesManage] };
expect(selectedMachineSettingsSupport({ id: "local", name: "local", kind: "local" }, undefined)).toEqual({ state: "supported" });
expect(selectedMachineSettingsSupport(target, undefined)).toEqual({ state: "unknown" });
expect(selectedMachineSettingsSupport(target, { ok: false })).toEqual({ state: "unknown" });
expect(selectedMachineSettingsSupport(target, supportedRuntime)).toEqual({ state: "supported" });
const unsupported = selectedMachineSettingsSupport(target, unsupportedRuntime);
expect(isSelectedMachineSettingsUnsupported(unsupported)).toBe(true);
expect(unsupported.message).toBe(selectedMachineSettingsUnavailableMessage(target));
expect(selectedMachineSettingsSupportKey(unsupported)).toBe(`unsupported:${selectedMachineSettingsUnavailableMessage(target)}`);
});
it("turns older remote config route failures into selected-machine compatibility guidance", () => {
const target = settingsMachineTarget(remoteMachine);
expect(selectedMachineSettingsUnavailableMessage(target)).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(friendlySelectedMachineSettingsErrorMessage("Not Found", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
expect(friendlySelectedMachineSettingsErrorMessage("route GET:/api/config not found", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
expect(friendlySelectedMachineSettingsErrorMessage("Cannot PUT /api/config", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
expect(friendlySelectedMachineSettingsErrorMessage("route GET:/api/plugins not found", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
expect(friendlySelectedMachineSettingsErrorMessage("Cannot GET /api/plugins", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
});
it("scopes remote reachability errors to selected-machine settings", () => {
const target = settingsMachineTarget(remoteMachine);
expect(friendlySelectedMachineSettingsErrorMessage("Remote machine unavailable", target)).toBe("Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
expect(friendlySelectedMachineSettingsErrorMessage("Remote machine timeout", target)).toBe("Timed out while contacting Lab Mac for selected-machine settings. The operation may still be running remotely; reload before retrying.");
expect(friendlySelectedMachineSettingsErrorMessage("Not Found", { id: "local", name: "local", kind: "local" })).toBe("Not Found");
});
});
@@ -0,0 +1,64 @@
import type { Machine, MachineKind, MachineRuntime } from "../../api";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities";
export interface SettingsMachineTarget {
id: string;
name: string;
kind: MachineKind;
}
export type SelectedMachineSettingsSupportState = "supported" | "unsupported" | "unknown";
export interface SelectedMachineSettingsSupport {
state: SelectedMachineSettingsSupportState;
message?: string;
}
export function settingsMachineTarget(machine: Pick<Machine, "id" | "name" | "kind"> | undefined): SettingsMachineTarget {
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
return { id: "local", name: "local", kind: "local" };
}
export function settingsMachineTargetLabel(target: SettingsMachineTarget): string {
return target.kind === "local" ? `${target.name} (local gateway)` : `${target.name} (remote machine)`;
}
export function selectedMachineSettingsSupport(target: SettingsMachineTarget, runtime: Pick<MachineRuntime, "ok" | "capabilities"> | undefined): SelectedMachineSettingsSupport {
if (target.kind === "local") return { state: "supported" };
if (runtime?.ok !== true) return { state: "unknown" };
if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.selectedMachineSettings)) return { state: "supported" };
return { state: "unsupported", message: selectedMachineSettingsUnavailableMessage(target) };
}
export function selectedMachineSettingsSupportKey(support: SelectedMachineSettingsSupport): string {
return `${support.state}:${support.message ?? ""}`;
}
export function isSelectedMachineSettingsUnsupported(support: SelectedMachineSettingsSupport | undefined): support is SelectedMachineSettingsSupport & { state: "unsupported" } {
return support?.state === "unsupported";
}
export function selectedMachineSettingsUnavailableMessage(target: SettingsMachineTarget): string {
return `Selected-machine settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`;
}
export function friendlySelectedMachineSettingsErrorMessage(message: string, target: SettingsMachineTarget): string {
const normalized = message.trim();
if (target.kind !== "remote") return normalized;
if (isUnsupportedRemoteSelectedMachineSettingsRouteMessage(normalized)) {
return selectedMachineSettingsUnavailableMessage(target);
}
if (normalized === "Remote machine timeout") {
return `Timed out while contacting ${target.name} for selected-machine settings. The operation may still be running remotely; reload before retrying.`;
}
if (normalized === "Remote machine unavailable") {
return `Could not reach ${target.name} for selected-machine settings. Check the machine connection and try again.`;
}
return normalized;
}
function isUnsupportedRemoteSelectedMachineSettingsRouteMessage(message: string): boolean {
return message === "Not Found"
|| /route\s+(GET|PUT):?\/api\/(config|plugins)\b.*not found/iu.test(message)
|| /cannot\s+(GET|PUT)\s+.*\/api\/(config|plugins)\b/iu.test(message);
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settingsPluginConfig";
describe("plugin settings config helpers", () => {
it("builds plugin-only save patches while preserving existing plugin config", () => {
const patch = pluginEnabledConfigPatch(
{
host: "127.0.0.1",
plugins: {
info: { enabled: true, settings: { theme: "dark" }, custom: "keep" },
metrics: { enabled: false },
},
},
"info",
false,
);
expect(Object.keys(patch)).toEqual(["plugins"]);
expect(patch).toEqual({
plugins: {
info: { enabled: false, settings: { theme: "dark" }, custom: "keep" },
metrics: { enabled: false },
},
});
});
it("merges local selected-machine plugin config into gateway config without dropping gateway-only values", () => {
const gateway = configResponse({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: false } },
});
const selectedMachine = configResponse({ plugins: { info: { enabled: true }, metrics: { enabled: false } } });
expect(mergeSelectedMachinePluginConfig(gateway, selectedMachine)).toEqual({
...gateway,
config: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, metrics: { enabled: false } },
},
effectiveConfig: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, metrics: { enabled: false } },
},
});
});
});
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
@@ -0,0 +1,25 @@
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
export function pluginEnabledConfigPatch(baseConfig: PiWebConfigValues, pluginId: string, enabled: boolean): PiWebConfigValues {
const currentPlugins = baseConfig.plugins ?? {};
const currentPluginConfig = currentPlugins[pluginId] ?? {};
return {
plugins: {
...currentPlugins,
[pluginId]: { ...currentPluginConfig, enabled },
},
};
}
export function mergeSelectedMachinePluginConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
return {
...base,
config: mergePluginConfig(base.config, selectedMachine.config),
effectiveConfig: mergePluginConfig(base.effectiveConfig, selectedMachine.effectiveConfig),
};
}
function mergePluginConfig(base: PiWebConfigValues, selectedMachine: PiWebConfigValues): PiWebConfigValues {
if (selectedMachine.plugins === undefined) return base;
return { ...base, plugins: selectedMachine.plugins };
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
describe("session daemon settings config helpers", () => {
it("builds daemon-only save patches for the sessiond toggles", () => {
expect(spawnSessionsConfigPatch(false)).toEqual({ spawnSessions: false });
expect(Object.keys(spawnSessionsConfigPatch(false))).toEqual(["spawnSessions"]);
expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true });
expect(Object.keys(subsessionsConfigPatch(true))).toEqual(["subsessions"]);
});
it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => {
const gateway = configResponse({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
subsessions: false,
});
const selectedMachine = configResponse({ spawnSessions: true, subsessions: true }, { spawnSessions: true, subsessions: false });
expect(mergeSelectedMachineSessiondConfig(gateway, selectedMachine)).toEqual({
...gateway,
config: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: true,
subsessions: true,
},
effectiveConfig: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: true,
subsessions: true,
},
envOverrides: {
host: false,
port: false,
allowedHosts: false,
spawnSessions: true,
subsessions: false,
},
});
});
});
function configResponse(config: PiWebConfigValues, overrides: Partial<PiWebConfigResponse["envOverrides"]> = {}): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, ...overrides },
};
}
@@ -0,0 +1,22 @@
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
export function spawnSessionsConfigPatch(enabled: boolean): PiWebConfigValues {
return { spawnSessions: enabled };
}
export function subsessionsConfigPatch(enabled: boolean): PiWebConfigValues {
return { subsessions: enabled };
}
export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
return {
...base,
config: { ...base.config, ...selectedMachine.config },
effectiveConfig: { ...base.effectiveConfig, ...selectedMachine.effectiveConfig },
envOverrides: {
...base.envOverrides,
spawnSessions: selectedMachine.envOverrides.spawnSessions,
subsessions: selectedMachine.envOverrides.subsessions,
},
};
}
+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 () => {
+1
View File
@@ -9,6 +9,7 @@ export const PI_WEB_CAPABILITIES = {
promptAttachments: "prompt.attachments",
workspaceFileSuggestions: "workspace.fileSuggestions",
piPackagesManage: "piPackages.manage",
selectedMachineSettings: "settings.selectedMachine",
} as const;
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
+6 -4
View File
@@ -2,18 +2,20 @@ import { describe, expect, it } from "vitest";
import { effectivePiWebCapabilities, PI_WEB_CAPABILITIES, SESSIOND_RUNTIME_CAPABILITIES, WEB_RUNTIME_CAPABILITIES, parseKnownPiWebCapabilities } from "./capabilities";
describe("PI WEB capabilities", () => {
it("advertises Pi package management from the web runtime only", () => {
it("advertises web-only capabilities without requiring session daemon support", () => {
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.piPackagesManage);
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage] },
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] },
sessiond: { available: false, capabilities: [] },
})).toEqual([PI_WEB_CAPABILITIES.piPackagesManage]);
})).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
});
it("keeps only known string capabilities when parsing runtime data", () => {
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage]);
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
});
});
+2
View File
@@ -14,6 +14,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.promptAttachments,
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
PI_WEB_CAPABILITIES.piPackagesManage,
PI_WEB_CAPABILITIES.selectedMachineSettings,
] as const satisfies readonly PiWebCapability[];
export const SESSIOND_RUNTIME_CAPABILITIES = [
@@ -32,6 +33,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],
[PI_WEB_CAPABILITIES.selectedMachineSettings]: ["web"],
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
export function isPiWebCapability(value: unknown): value is PiWebCapability {
+3
View File
@@ -10,6 +10,9 @@ export interface FederatedHttpRouteSpec {
export const FEDERATED_HTTP_ROUTES = [
{ method: "GET", path: "/pi-web/status" },
{ method: "GET", path: "/config" },
{ method: "PUT", path: "/config" },
{ method: "GET", path: "/plugins" },
{ method: "GET", path: "/pi-packages" },
{ method: "POST", path: "/pi-packages/install", timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS },
{ method: "POST", path: "/pi-packages/remove", timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS },
+5 -5
View File
@@ -3,21 +3,21 @@ import { PI_WEB_CAPABILITIES } from "./capabilities";
import { parsePiWebRuntimeResponse } from "./piWebStatusParsing";
describe("PI WEB status parsing", () => {
it("parses package-management runtime capabilities and ignores unknown string capabilities", () => {
it("parses known runtime capabilities and ignores unknown string capabilities", () => {
expect(parsePiWebRuntimeResponse({
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"] },
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: ["future.sessiondCapability"] },
},
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"],
})).toMatchObject({
components: {
web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage] },
web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] },
sessiond: { capabilities: [] },
},
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage],
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings],
});
});