Archived
feat: apply agent profile settings atomically
This commit is contained in:
@@ -61,18 +61,39 @@ describe("buildApp machine routes", () => {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
components: {
|
||||
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
|
||||
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"] },
|
||||
sessiond: {
|
||||
component: "sessiond",
|
||||
label: "Remote Sessiond",
|
||||
runtimeVersion: "1.0.0",
|
||||
available: true,
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
|
||||
activeAgentProfile: {
|
||||
schemaVersion: 1,
|
||||
revision: `sha256:${"a".repeat(64)}`,
|
||||
command: "remote-agent",
|
||||
dir: "/srv/remote-agent",
|
||||
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||
},
|
||||
},
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"],
|
||||
},
|
||||
}));
|
||||
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const runtime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` });
|
||||
const refreshedRuntime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime?refresh=1` });
|
||||
|
||||
expect(runtime.statusCode).toBe(200);
|
||||
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
|
||||
expect(refreshedRuntime.statusCode).toBe(200);
|
||||
expect(runtime.json()).toMatchObject({
|
||||
machineId: remote.id,
|
||||
ok: true,
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig],
|
||||
components: { sessiond: { activeAgentProfile: { command: "remote-agent", dir: "/srv/remote-agent" } } },
|
||||
});
|
||||
expect(requestJson).toHaveBeenCalledTimes(2);
|
||||
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
|
||||
});
|
||||
|
||||
@@ -101,9 +122,11 @@ describe("buildApp machine routes", () => {
|
||||
it("merges remote selected-machine config updates into the target machine config", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
let persistedConfig = fullPiWebConfig();
|
||||
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)) });
|
||||
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(persistedConfig) });
|
||||
persistedConfig = configFromMachineConfigWriteBody(body);
|
||||
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(persistedConfig) });
|
||||
});
|
||||
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
@@ -136,6 +159,102 @@ describe("buildApp machine routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a false-success agent profile write from an older remote machine", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const legacyConfig = fullPiWebConfig();
|
||||
delete legacyConfig.agent;
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: piWebConfigResponse(legacyConfig),
|
||||
}));
|
||||
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await appTestContext.app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/config`,
|
||||
payload: { config: { agent: { command: "remote-agent", dir: "/srv/remote-agent" } } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json()).toMatchObject({
|
||||
error: "Remote machine did not persist the requested agent profile",
|
||||
machineId: remote.id,
|
||||
});
|
||||
expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config");
|
||||
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
|
||||
config: { ...legacyConfig, agent: { command: "remote-agent", dir: "/srv/remote-agent" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies an explicit remote profile reset instead of treating an empty profile as no patch", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>((method) => {
|
||||
const config = fullPiWebConfig();
|
||||
if (method === "PUT") delete config.agent;
|
||||
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
|
||||
});
|
||||
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await appTestContext.app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/config`,
|
||||
payload: { config: { agent: {} } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json()).toMatchObject({ error: "Remote machine did not persist the requested agent profile" });
|
||||
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
|
||||
config: { ...fullPiWebConfig(), agent: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-profile selected-machine saves compatible with older remote machines", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const legacyConfig = fullPiWebConfig();
|
||||
delete legacyConfig.agent;
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
|
||||
const config = method === "PUT" ? configFromMachineConfigWriteBody(body) : legacyConfig;
|
||||
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
|
||||
});
|
||||
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await appTestContext.app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/config`,
|
||||
payload: { config: { spawnSessions: true } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json<PiWebConfigResponse>().config.spawnSessions).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves foreign-platform agent paths while the target verifies persistence", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const windowsAgent = { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\work" };
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
|
||||
const config = method === "PUT" ? configFromMachineConfigWriteBody(body) : fullPiWebConfig();
|
||||
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
|
||||
});
|
||||
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await appTestContext.app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/config`,
|
||||
payload: { config: { agent: windowsAgent } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json<PiWebConfigResponse>().config.agent).toEqual(windowsAgent);
|
||||
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
|
||||
config: { ...fullPiWebConfig(), agent: windowsAgent },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsafe remote selected-machine config keys before proxying", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
|
||||
@@ -202,6 +202,23 @@ describe("config routes", () => {
|
||||
expect(parsed.envOverrides).toMatchObject({ agentCommand: false, agentDir: false, agentSessionDir: false });
|
||||
});
|
||||
|
||||
it("retains the agent directory environment source across federation responses", () => {
|
||||
const parsed = parsePiWebConfigResponseBody({
|
||||
...responseFor({}, false),
|
||||
envOverrides: {
|
||||
...responseFor({}, false).envOverrides,
|
||||
agentDir: true,
|
||||
agentDirSource: "pi-compatibility",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.envOverrides).toMatchObject({ agentDir: true, agentDirSource: "pi-compatibility" });
|
||||
expect(() => parsePiWebConfigResponseBody({
|
||||
...responseFor({}, false),
|
||||
envOverrides: { ...responseFor({}, false).envOverrides, agentDirSource: "future-source" },
|
||||
})).toThrow("valid agent directory source");
|
||||
});
|
||||
|
||||
it("rejects unsafe local selected-machine config keys before writing", async () => {
|
||||
savedConfig = fullConfig();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseAgentConfig, parseUploadsConfig, resolveEffectivePiWebConfig, savePiWebConfig, type AgentPathHost, type LoadOptions, type PiWebConfig } from "../config.js";
|
||||
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import { agentDirEnvSource, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseAgentConfig, parseUploadsConfig, resolveEffectivePiWebConfig, savePiWebConfig, type AgentPathHost, type LoadOptions, type PiWebConfig } from "../config.js";
|
||||
import type { PiWebAgentDirEnvSource, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import { isPiWebPluginId } from "../shared/pluginIds.js";
|
||||
|
||||
export interface PiWebConfigService {
|
||||
@@ -243,6 +243,7 @@ function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): P
|
||||
subsessions: requireResponseBoolean(record, "subsessions", source),
|
||||
agentCommand: optionalResponseBoolean(record, "agentCommand", source) ?? false,
|
||||
agentDir: optionalResponseBoolean(record, "agentDir", source) ?? false,
|
||||
...optionalAgentDirSource(record, source),
|
||||
agentSessionDir: optionalResponseBoolean(record, "agentSessionDir", source) ?? false,
|
||||
};
|
||||
}
|
||||
@@ -271,8 +272,16 @@ function optionalResponseBoolean(record: Record<string, unknown>, key: string, s
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalAgentDirSource(record: Record<string, unknown>, source: string): { agentDirSource?: PiWebAgentDirEnvSource } {
|
||||
const value = record["agentDirSource"];
|
||||
if (value === undefined) return {};
|
||||
if (value !== "pi-web" && value !== "pi-compatibility") throw new Error(`${source} field must be a valid agent directory source: agentDirSource`);
|
||||
return { agentDirSource: value };
|
||||
}
|
||||
|
||||
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {}): PiWebConfigEnvOverrides {
|
||||
const command = config.agent?.command;
|
||||
const dirEnvSource = agentDirEnvSource(env);
|
||||
return {
|
||||
host: isEnvSet(env["PI_WEB_HOST"]),
|
||||
port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]),
|
||||
@@ -281,6 +290,7 @@ function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {
|
||||
subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]),
|
||||
agentCommand: isEnvSet(env["PI_WEB_AGENT_COMMAND"]),
|
||||
agentDir: hasAgentDirEnvOverride(env, command),
|
||||
...(dirEnvSource === undefined ? {} : { agentDirSource: dirEnvSource }),
|
||||
agentSessionDir: hasAgentSessionDirEnvOverride(env, command),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { WebSocket } from "ws";
|
||||
import type { PiWebAgentConfig } from "../../shared/apiTypes.js";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js";
|
||||
import { mergeSelectedMachineConfig, parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, selectedMachineConfigResponse } from "../configRoutes.js";
|
||||
import { bridgeSockets } from "../webSocketBridge.js";
|
||||
@@ -75,7 +76,8 @@ async function proxySelectedMachineConfigRequest(client: MachineClient, machineI
|
||||
|
||||
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);
|
||||
const updateResponse = await client.requestJson("PUT", remotePath, { config: merged });
|
||||
return sendSelectedMachineConfigResponse(reply, updateResponse, machineId, patch.agent);
|
||||
}
|
||||
|
||||
return reply.code(405).send({ error: "Method not allowed" });
|
||||
@@ -85,11 +87,23 @@ function configPayload(body: unknown): unknown {
|
||||
return isRecord(body) ? body["config"] : undefined;
|
||||
}
|
||||
|
||||
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
|
||||
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string, expectedAgentProfile?: PiWebAgentConfig): FastifyReply {
|
||||
if (!isSuccessfulStatus(upstream.statusCode)) return sendUpstreamJsonResponse(reply, upstream, machineId);
|
||||
const response = parsePiWebConfigResponseBody(upstream.body, "Remote machine config response");
|
||||
if (expectedAgentProfile !== undefined && !sameAgentProfile(response.config.agent, expectedAgentProfile)) {
|
||||
return reply.code(409).send({
|
||||
error: "Remote machine did not persist the requested agent profile",
|
||||
machineId,
|
||||
detail: "Update and restart PI WEB on the remote machine before changing its agent profile.",
|
||||
});
|
||||
}
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
return reply.send(selectedMachineConfigResponse(parsePiWebConfigResponseBody(upstream.body, "Remote machine config response")));
|
||||
return reply.send(selectedMachineConfigResponse(response));
|
||||
}
|
||||
|
||||
function sameAgentProfile(actual: PiWebAgentConfig | undefined, expected: PiWebAgentConfig): boolean {
|
||||
return actual !== undefined && actual.command === expected.command && actual.dir === expected.dir;
|
||||
}
|
||||
|
||||
function sendUpstreamJsonResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
|
||||
|
||||
@@ -18,8 +18,8 @@ export function registerMachineRoutes(app: FastifyInstance, machines = new Machi
|
||||
return health;
|
||||
});
|
||||
|
||||
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
|
||||
const runtime = await machines.runtime(request.params.machineId);
|
||||
app.get<{ Params: { machineId: string }; Querystring: { refresh?: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
|
||||
const runtime = await machines.runtime(request.params.machineId, request.query.refresh === "1");
|
||||
if (runtime === undefined) return reply.code(404).send({ error: "Machine not found" });
|
||||
return runtime;
|
||||
});
|
||||
|
||||
@@ -171,6 +171,7 @@ describe("MachineService", () => {
|
||||
|
||||
const first = await remoteService.runtime(machine.id);
|
||||
const second = await remoteService.runtime(machine.id);
|
||||
const forced = await remoteService.runtime(machine.id, true);
|
||||
|
||||
expect(first).toEqual({
|
||||
machineId: machine.id,
|
||||
@@ -182,7 +183,8 @@ describe("MachineService", () => {
|
||||
capabilities: body.capabilities,
|
||||
});
|
||||
expect(second).toEqual(first);
|
||||
expect(requestJson).toHaveBeenCalledTimes(1);
|
||||
expect(forced).toEqual(first);
|
||||
expect(requestJson).toHaveBeenCalledTimes(2);
|
||||
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
|
||||
expect(factoryMachines).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -192,6 +194,7 @@ describe("MachineService", () => {
|
||||
token: "secret",
|
||||
headers: { "X-Pi-Web-Test": "yes" },
|
||||
}),
|
||||
expect.objectContaining({ id: machine.id }),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -93,10 +93,10 @@ export class MachineService {
|
||||
return health;
|
||||
}
|
||||
|
||||
async runtime(id: string): Promise<MachineRuntime | undefined> {
|
||||
async runtime(id: string, refresh = false): Promise<MachineRuntime | undefined> {
|
||||
const cached = this.runtimeCache.get(id);
|
||||
const now = this.now().getTime();
|
||||
if (cached !== undefined && cached.expiresAt > now) return cached.runtime;
|
||||
if (!refresh && cached !== undefined && cached.expiresAt > now) return cached.runtime;
|
||||
|
||||
const runtime = id === "local" ? await this.localRuntime() : await this.remoteRuntime(id);
|
||||
if (runtime === undefined) return undefined;
|
||||
|
||||
@@ -109,10 +109,11 @@ describe("PI WEB status", () => {
|
||||
|
||||
const runtime = await getPiWebRuntime(daemon);
|
||||
|
||||
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
||||
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]));
|
||||
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
||||
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
||||
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
||||
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.agentProfileConfig);
|
||||
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]));
|
||||
});
|
||||
|
||||
it("carries the daemon-owned active agent profile through the web runtime response", async () => {
|
||||
|
||||
Reference in New Issue
Block a user