feat: apply agent profile settings atomically

This commit is contained in:
Federico Jaramillo Martinez
2026-07-14 00:11:39 +02:00
parent adc2e297a4
commit 8b5ccc2fd9
33 changed files with 794 additions and 163 deletions
+125 -6
View File
@@ -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 }>();
+17
View File
@@ -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();
+12 -2
View File
@@ -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),
};
}
+17 -3
View File
@@ -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 {
+2 -2
View File
@@ -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;
});
+4 -1
View File
@@ -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 }),
]);
});
+2 -2
View File
@@ -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;
+3 -2
View File
@@ -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 () => {