From 8b5ccc2fd9724007ca513f6fe0b6a3c51d14c80c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 14 Jul 2026 00:11:39 +0200 Subject: [PATCH] feat: apply agent profile settings atomically --- src/client/src/api.ts | 2 +- src/client/src/api/clients.test.ts | 8 +- src/client/src/api/clients.ts | 2 +- src/client/src/api/parsers.test.ts | 71 ++++++- src/client/src/api/parsers.ts | 18 +- src/client/src/components/PiWebApp.ts | 2 +- .../SettingsDialog.sessiond.test.ts | 53 ++++++ src/client/src/components/SettingsDialog.ts | 27 ++- .../settings/SettingsSessiondPanel.test.ts | 95 +++++++++- .../settings/SettingsSessiondPanel.ts | 174 +++++++++++++----- .../settings/settingsConfigDraft.test.ts | 19 ++ .../settings/settingsConfigDraft.ts | 33 ++++ .../settings/settingsMachineTarget.test.ts | 23 ++- .../settings/settingsMachineTarget.ts | 21 +++ .../settings/settingsSessiondConfig.test.ts | 60 ++++-- .../settings/settingsSessiondConfig.ts | 68 ++++--- .../src/controllers/machineController.test.ts | 2 +- .../src/controllers/machineController.ts | 2 +- src/config.test.ts | 18 +- src/config.ts | 29 +-- src/server/app.machines.test.ts | 131 ++++++++++++- src/server/configRoutes.test.ts | 17 ++ src/server/configRoutes.ts | 14 +- src/server/machines/machineProxyRoutes.ts | 20 +- src/server/machines/machineRoutes.ts | 4 +- src/server/machines/machineService.test.ts | 5 +- src/server/machines/machineService.ts | 4 +- src/server/piWebStatus.test.ts | 5 +- src/shared/activeAgentProfile.ts | 9 + src/shared/apiTypes.ts | 5 + src/shared/capabilities.test.ts | 6 +- src/shared/capabilities.ts | 2 + src/shared/piWebStatusParsing.test.ts | 8 +- 33 files changed, 794 insertions(+), 163 deletions(-) diff --git a/src/client/src/api.ts b/src/client/src/api.ts index e8a61f7..3b68515 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads"; export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads"; -export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; +export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 07955de..5107c95 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -79,12 +79,16 @@ describe("machine-scoped runtime API", () => { }); it("reads machine runtime through the gateway route", async () => { - const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }); + const response = { machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }; + const fetchMock = stubSequenceFetch([jsonResponse(response), jsonResponse(response)]); await machinesApi.runtime("remote a"); + await machinesApi.runtime("remote a", true); - expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime"); + expect(fetchCall(fetchMock, 1)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime?refresh=1"); + expect(fetchCall(fetchMock, 1)[1]?.cache).toBe("no-store"); }); }); diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 35b9940..190abb4 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -115,7 +115,7 @@ export const machinesApi = { addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }), deleteMachine: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }), health: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth), - runtime: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime), + runtime: (machineId: string, refresh = false) => request(`api/machines/${encodeURIComponent(machineId)}/runtime${refresh ? "?refresh=1" : ""}`, parseMachineRuntime, refresh ? { cache: "no-store" } : {}), }; function configPath(machineId?: string): string { diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 521db79..5b687a7 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; +import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; describe("API parsers", () => { it("parses PI WEB config responses", () => { @@ -9,26 +9,85 @@ describe("API parsers", () => { exists: true, config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } }, effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } }, - envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false }, + envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false }, })).toEqual({ path: "/tmp/config.json", exists: true, config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } }, effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } }, - envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false }, + envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false }, }); }); - it("parses PI WEB runtime responses", () => { + it("parses PI WEB runtime responses including the daemon-owned active profile", () => { 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.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] }, - sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }, + sessiond: { + component: "sessiond", + label: "Session daemon", + runtimeVersion: "1.0.0", + available: true, + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived], + activeAgentProfile: { + schemaVersion: 1, + revision: `sha256:${"a".repeat(64)}`, + command: "agent-lab", + dir: "/srv/agent-lab", + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }, + }, }, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"], - })).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] }); + })).toMatchObject({ + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage], + components: { sessiond: { activeAgentProfile: { command: "agent-lab", dir: "/srv/agent-lab" } } }, + }); + }); + + it("retains portable active profiles in machine runtime snapshots and rejects invalid ownership", () => { + const profile = { + schemaVersion: 1, + revision: `sha256:${"b".repeat(64)}`, + command: "C:\\tools\\pi.exe", + dir: "C:\\agent-profiles\\work", + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }; + const components = { + web: { component: "web", label: "Web/UI", available: true, capabilities: [] }, + sessiond: { component: "sessiond", label: "Session daemon", available: true, capabilities: [], activeAgentProfile: profile }, + }; + + const parsed = parseMachineRuntime({ machineId: "remote-a", ok: true, checkedAt: "now", components, capabilities: [] }); + + expect(parsed.components?.sessiond.activeAgentProfile).toMatchObject({ command: profile.command, dir: profile.dir }); + expect(Object.isFrozen(parsed.components?.sessiond.activeAgentProfile)).toBe(true); + expect(() => parseMachineRuntime({ + machineId: "remote-a", + ok: true, + checkedAt: "now", + components: { ...components, web: { ...components.web, activeAgentProfile: profile } }, + capabilities: [], + })).toThrow("Invalid active agent profile descriptor"); + expect(() => parseMachineRuntime({ + machineId: "remote-a", + ok: true, + checkedAt: "now", + components: { ...components, sessiond: { ...components.sessiond, activeAgentProfile: { ...profile, token: "secret" } } }, + capabilities: [], + })).toThrow("Invalid active agent profile descriptor"); + }); + + it("rejects malformed agent directory override metadata", () => { + expect(() => parsePiWebConfigResponse({ + path: "/tmp/config.json", + exists: true, + config: {}, + effectiveConfig: {}, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentDirSource: "future" }, + })).toThrow("Invalid PI WEB agentDirSource field"); }); it("parses Pi package list and mutation responses", () => { diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 1ca6267..c2508a6 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,5 +1,6 @@ -import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; +import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes"; +import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile"; import { parseKnownPiWebCapabilities } from "../../../shared/capabilities"; function isRecord(value: unknown): value is Record { @@ -647,10 +648,18 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides { subsessions: requireBoolean(record, "subsessions"), agentCommand: optionalBoolean(record, "agentCommand") ?? false, agentDir: optionalBoolean(record, "agentDir") ?? false, + ...optionalAgentDirSource(record), agentSessionDir: optionalBoolean(record, "agentSessionDir") ?? false, }; } +function optionalAgentDirSource(record: Record): { agentDirSource?: PiWebAgentDirEnvSource } { + const value = record["agentDirSource"]; + if (value === undefined) return {}; + if (value !== "pi-web" && value !== "pi-compatibility") throw new Error("Invalid PI WEB agentDirSource field"); + return { agentDirSource: value }; +} + export function parsePiPackagesResponse(value: unknown): PiPackagesResponse { const record = requireRecord(value); return { packages: arrayOf(parsePiPackageInfo)(record["packages"]) }; @@ -752,12 +761,17 @@ function parsePiWebRuntimeComponents(value: unknown): PiWebRuntimeResponse["comp function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent { const record = requireRecord(value); + const component = parsePiWebServiceComponent(record["component"]); + const activeAgentProfileValue = record["activeAgentProfile"]; + const activeAgentProfile = activeAgentProfileValue === undefined ? undefined : parseActiveAgentProfileDescriptor(activeAgentProfileValue); + if (activeAgentProfileValue !== undefined && (component !== "sessiond" || activeAgentProfile === undefined)) throw new Error("Invalid active agent profile descriptor"); return { - component: parsePiWebServiceComponent(record["component"]), + component, label: requireString(record, "label"), ...optionalField("runtimeVersion", optionalString(record, "runtimeVersion")), available: requireBoolean(record, "available"), capabilities: parsePiWebCapabilities(record["capabilities"]), + ...optionalField("activeAgentProfile", activeAgentProfile), ...optionalField("error", optionalString(record, "error")), }; } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index b6ff8cf..998c413 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1947,7 +1947,7 @@ export class PiWebApp extends LitElement { ${state.machineDialogOpen ? html` this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}>` : null} ${this.sessionCleanupDialog !== undefined ? html` { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}>` : null} ${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} - ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}>` : null} + ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }} .onRefreshMachineRuntime=${(machineId: string) => this.machines.refreshMachineRuntime(machineId)}>` : null} `; } diff --git a/src/client/src/components/SettingsDialog.sessiond.test.ts b/src/client/src/components/SettingsDialog.sessiond.test.ts index cbd7528..2d6126f 100644 --- a/src/client/src/components/SettingsDialog.sessiond.test.ts +++ b/src/client/src/components/SettingsDialog.sessiond.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api"; import { SettingsDialog } from "./SettingsDialog"; import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement as runtimeWithoutSelectedMachineSettings, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport"; @@ -40,6 +41,21 @@ describe("settings-dialog session daemon machine targeting", () => { expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false); }); + it("reloads desired config and the active runtime descriptor together", async () => { + const config = configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } }); + const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config); + const runtimeRefresh = vi.fn(() => Promise.resolve()); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.onRefreshMachineRuntime = runtimeRefresh; + + await callDialogPromise(dialog, "reloadSessiondState"); + + expect(configSpy).toHaveBeenCalledWith(remoteMachine.id); + expect(runtimeRefresh).toHaveBeenCalledWith(remoteMachine.id); + expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config); + }); + 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 }); @@ -57,6 +73,43 @@ describe("settings-dialog session daemon machine targeting", () => { expect(getDialogProperty(dialog, "saving")).toBe(false); }); + it("fails closed for a remote agent-profile save without granular support", async () => { + const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } })); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.machineRuntime = { + machineId: remoteMachine.id, + ok: true, + checkedAt: "now", + capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings], + }; + + await callDialogPromise(dialog, "saveSessiondConfig", { agent: { command: "agent-lab", dir: "/srv/agent-lab" } }); + + expect(saveSpy).not.toHaveBeenCalled(); + expect(getDialogProperty(dialog, "sessiondError")).toBe("Agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); + }); + + it("saves a remote agent profile when granular support is advertised", async () => { + stubWindowTimers(); + const patch = { agent: { command: "agent-lab", dir: "/srv/agent-lab" } }; + const saved = configResponse(patch); + const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(saved); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.machineRuntime = { + machineId: remoteMachine.id, + ok: true, + checkedAt: "now", + capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig], + }; + + await callDialogPromise(dialog, "saveSessiondConfig", patch); + + expect(saveSpy).toHaveBeenCalledWith(patch, remoteMachine.id); + expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(saved); + }); + it("ignores stale session-daemon load responses after the selected machine changes", async () => { const load = deferred(); vi.spyOn(configApi, "config").mockReturnValue(load.promise); diff --git a/src/client/src/components/SettingsDialog.ts b/src/client/src/components/SettingsDialog.ts index 7139b30..bb6c5b4 100644 --- a/src/client/src/components/SettingsDialog.ts +++ b/src/client/src/components/SettingsDialog.ts @@ -11,7 +11,7 @@ import "./settings/SettingsShortcutsPanel"; 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 { agentProfileSettingsSupport, friendlySelectedMachineSettingsErrorMessage, isAgentProfileSettingsSupported, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type AgentProfileSettingsSupport, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget"; import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settings/settingsPluginConfig"; import { mergeSelectedMachineSessiondConfig } from "./settings/settingsSessiondConfig"; @@ -24,6 +24,7 @@ export class SettingsDialog extends LitElement { @property({ attribute: false }) onNavigate?: (section: SettingsSection) => void; @property({ attribute: false }) onClose?: () => void; @property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void; + @property({ attribute: false }) onRefreshMachineRuntime?: (machineId: string) => void | Promise; @state() private configResponse: PiWebConfigResponse | undefined; @state() private accessConfigResponse: PiWebConfigResponse | undefined; @state() private sessiondConfigResponse: PiWebConfigResponse | undefined; @@ -57,7 +58,7 @@ export class SettingsDialog extends LitElement { super.connectedCallback(); void this.loadConfig(); void this.loadAccessConfigForTarget(); - void this.loadSessiondConfigForTarget(); + void this.reloadSessiondState(); void this.loadPluginsForTarget(); void this.loadPackagesForTarget(); } @@ -137,7 +138,9 @@ export class SettingsDialog extends LitElement { .error=${this.sessiondError} .savedMessage=${this.savedMessage} .targetLabel=${settingsMachineTargetLabel(this.settingsTarget())} - .onReload=${() => this.loadSessiondConfigForTarget()} + .activeAgentProfile=${this.machineRuntime?.components?.sessiond.activeAgentProfile} + .agentProfileSupport=${this.agentProfileSettingsSupport()} + .onReload=${() => this.reloadSessiondState()} .onSave=${(config: PiWebConfigValues) => this.saveSessiondConfig(config)} > `; @@ -264,6 +267,13 @@ export class SettingsDialog extends LitElement { } } + private async reloadSessiondState(target = this.settingsTarget()): Promise { + await Promise.all([ + this.loadSessiondConfigForTarget(target), + this.onRefreshMachineRuntime?.(target.id), + ]); + } + private async loadSessiondConfigForTarget(target = this.settingsTarget()): Promise { const requestSeq = ++this.sessiondLoadRequestSeq; const support = this.selectedMachineSettingsSupport(target); @@ -424,6 +434,13 @@ export class SettingsDialog extends LitElement { this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`; return; } + if (config.agent !== undefined) { + const profileSupport = this.agentProfileSettingsSupport(target); + if (!isAgentProfileSettingsSupported(profileSupport)) { + this.sessiondError = profileSupport.message ?? `Agent profile settings are not available on ${settingsMachineTargetLabel(target)}.`; + return; + } + } this.saving = true; this.sessiondError = ""; this.savedMessage = ""; @@ -521,6 +538,10 @@ export class SettingsDialog extends LitElement { return selectedMachineSettingsSupport(target, this.machineRuntime); } + private agentProfileSettingsSupport(target = this.settingsTarget()): AgentProfileSettingsSupport { + return agentProfileSettingsSupport(target, this.machineRuntime); + } + private selectedMachineSettingsSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: SettingsMachineTarget): boolean { const previousSupport = selectedMachineSettingsSupport(target, previousRuntime); const currentSupport = this.selectedMachineSettingsSupport(target); diff --git a/src/client/src/components/settings/SettingsSessiondPanel.test.ts b/src/client/src/components/settings/SettingsSessiondPanel.test.ts index 965c886..56bdebf 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.test.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { TemplateResult } from "lit"; -import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; +import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api"; import { SettingsSessiondPanel } from "./SettingsSessiondPanel"; import type { SettingsNotice } from "./SettingsPanelFrame"; @@ -8,11 +8,12 @@ describe("settings-sessiond-panel layout", () => { it("names the selected machine in the scope and restart notice when config is available", () => { const panel = new SettingsSessiondPanel(); panel.targetLabel = "Lab Mac (remote machine)"; - panel.configResponse = configResponse({ + setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" }, spawnSessions: true, subsessions: false, - }); + })); + panel.activeAgentProfile = activeProfile("pi", "/srv/pi"); const rendered = flattenTemplateContent(panel.render()); @@ -20,10 +21,10 @@ describe("settings-sessiond-panel layout", () => { "Session daemon", "These settings affect the long-lived session runtime on Lab Mac (remote machine).", "Reload", - "Restart required on Lab Mac (remote machine)", - "run pi-web restart on that machine", + "Agent profile restart required on Lab Mac (remote machine)", + "Run pi-web restart on that machine", "Config file", - "Agent command for diagnostics", + "Companion CLI command", "agent-lab", "Agent state directory", "/srv/agent-lab", @@ -33,7 +34,8 @@ describe("settings-sessiond-panel layout", () => { it("orders save/load notices before the restart notice and settings content", () => { const panel = new SettingsSessiondPanel(); - panel.configResponse = configResponse({ spawnSessions: false }); + setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" }, spawnSessions: false })); + panel.activeAgentProfile = activeProfile("pi", "/srv/pi"); panel.error = "Failed to save session-daemon config."; panel.savedMessage = "Session daemon settings saved."; @@ -42,11 +44,55 @@ describe("settings-sessiond-panel layout", () => { expectTextOrder(rendered, [ "Failed to save session-daemon config.", "Session daemon settings saved.", - "Restart required on local (local gateway)", + "Agent profile restart required on local (local gateway)", "Config file", ]); }); + it("shows the profile as active without restart guidance when desired and active match", () => { + const panel = new SettingsSessiondPanel(); + setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } })); + panel.activeAgentProfile = activeProfile("agent-lab", "/srv/agent-lab"); + + const rendered = flattenTemplateContent(panel.render()); + + expect(rendered).toContain("Profile status"); + expect(rendered).toContain("Active"); + expect(rendered).not.toContain("restart required on"); + }); + + it("submits command and directory together as one profile save", async () => { + const panel = new SettingsSessiondPanel(); + const onSave = vi.fn(); + setPanelConfig(panel, configResponse({ agent: { command: "pi", dir: "/srv/pi" } })); + setPanelProperty(panel, "agentDraft", { command: " alternate-agent ", dir: " /srv/alternate " }); + panel.onSave = onSave; + const event = new Event("submit", { cancelable: true }); + + await callPanelPromise(panel, "saveAgentProfile", event); + + expect(event.defaultPrevented).toBe(true); + expect(onSave.mock.calls).toEqual([[{ agent: { command: "alternate-agent", dir: "/srv/alternate" } }]]); + }); + + it("preserves a dirty profile draft when an unrelated daemon setting is saved", () => { + const panel = new SettingsSessiondPanel(); + const initial = configResponse({ agent: { command: "pi", dir: "/srv/pi" }, spawnSessions: false }); + setPanelConfig(panel, initial); + callPanelMethod(panel, "updateAgentDraft", { command: "alternate-agent", dir: "/srv/alternate" }); + + const toggled = configResponse({ agent: { command: "pi", dir: "/srv/pi" }, spawnSessions: true }); + panel.configResponse = toggled; + callPanelMethod(panel, "willUpdate", new Map([["configResponse", initial]])); + + expect(Reflect.get(panel, "agentDraft")).toEqual({ command: "alternate-agent", dir: "/srv/alternate" }); + + const saved = configResponse({ agent: { command: "alternate-agent", dir: "/srv/alternate" }, spawnSessions: true }); + panel.configResponse = saved; + callPanelMethod(panel, "willUpdate", new Map([["configResponse", toggled]])); + expect(Reflect.get(panel, "agentDraftDirty")).toBe(false); + }); + it("shows one blocked content state without restart guidance or toggles when config is unavailable", () => { const panel = new SettingsSessiondPanel(); panel.targetLabel = "Lab Mac (remote machine)"; @@ -65,6 +111,37 @@ describe("settings-sessiond-panel layout", () => { }); }); +function activeProfile(command: string, dir: string): ActiveAgentProfileDescriptor { + return { + schemaVersion: 1, + revision: `sha256:${"a".repeat(64)}`, + command, + dir, + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }; +} + +function setPanelConfig(panel: SettingsSessiondPanel, config: PiWebConfigResponse): void { + panel.configResponse = config; + callPanelMethod(panel, "willUpdate", new Map([["configResponse", undefined]])); +} + +function setPanelProperty(panel: SettingsSessiondPanel, property: string, value: unknown): void { + if (!Reflect.set(panel, property, value)) throw new Error(`Failed to set SettingsSessiondPanel property ${property}`); +} + +async function callPanelPromise(panel: SettingsSessiondPanel, methodName: string, ...args: readonly unknown[]): Promise { + const result = callPanelMethod(panel, methodName, ...args); + if (!(result instanceof Promise)) throw new Error(`SettingsSessiondPanel.${methodName} did not return a promise`); + await result; +} + +function callPanelMethod(panel: SettingsSessiondPanel, methodName: string, ...args: readonly unknown[]): unknown { + const method: unknown = Reflect.get(panel, methodName); + if (typeof method !== "function") throw new Error(`SettingsSessiondPanel.${methodName} is not callable`); + return Reflect.apply(method, panel, args); +} + function flattenTemplateContent(template: TemplateResult): string { const chunks: string[] = []; visitTemplate(template); diff --git a/src/client/src/components/settings/SettingsSessiondPanel.ts b/src/client/src/components/settings/SettingsSessiondPanel.ts index 212a3dd..81568c9 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.ts @@ -1,9 +1,11 @@ -import { css, html, LitElement, type TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; +import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api"; import "./SettingsPanelFrame"; import type { SettingsNotice } from "./SettingsPanelFrame"; -import { agentFieldConfigPatch, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig"; +import { agentProfileConfigPatchFromDraft, agentProfileDraftFromConfig, agentProfileDraftMatchesConfig, emptyAgentProfileConfigDraft, type AgentProfileConfigDraft } from "./settingsConfigDraft"; +import type { AgentProfileSettingsSupport } from "./settingsMachineTarget"; +import { agentDirFieldOverridden, agentProfileActivationState, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig"; @customElement("settings-sessiond-panel") export class SettingsSessiondPanel extends LitElement { @@ -13,8 +15,28 @@ export class SettingsSessiondPanel extends LitElement { @property() error = ""; @property() savedMessage = ""; @property() targetLabel = "local (local gateway)"; + @property({ attribute: false }) activeAgentProfile: ActiveAgentProfileDescriptor | undefined; + @property({ attribute: false }) agentProfileSupport: AgentProfileSettingsSupport = { state: "supported" }; @property({ attribute: false }) onReload?: () => void | Promise; @property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise; + @state() private agentDraft: AgentProfileConfigDraft = emptyAgentProfileConfigDraft(); + @state() private agentDraftDirty = false; + @state() private agentLocalError = ""; + + protected override willUpdate(changed: PropertyValues): void { + if (!changed.has("configResponse")) return; + if (this.configResponse === undefined) { + this.agentDraft = emptyAgentProfileConfigDraft(); + this.agentDraftDirty = false; + this.agentLocalError = ""; + return; + } + if (!this.agentDraftDirty || agentProfileDraftMatchesConfig(this.agentDraft, this.configResponse.config)) { + this.agentDraft = agentProfileDraftFromConfig(this.configResponse.config); + this.agentDraftDirty = false; + this.agentLocalError = ""; + } + } override render(): TemplateResult { const config = this.configResponse; @@ -26,8 +48,12 @@ export class SettingsSessiondPanel extends LitElement { // Beta, off by default; also requires spawn to be enabled. const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn; const agentCommandOverridden = config?.envOverrides.agentCommand === true; - const agentDirOverridden = config?.envOverrides.agentDir === true; + const profileEditingSupported = this.agentProfileSupport.state === "supported"; + const draftCommand = agentCommandOverridden ? (config.effectiveConfig.agent?.command ?? this.agentDraft.command) : this.agentDraft.command; + const agentDirLocked = agentDirFieldOverridden(config?.envOverrides, draftCommand); + const effectiveAgentDirOverridden = config?.envOverrides.agentDir === true; const effectiveAgent = config?.effectiveConfig.agent; + const profileActivation = agentProfileActivationState(config, this.activeAgentProfile); return html` Config file ${config.path} - - +
{ void this.saveAgentProfile(event); }}> + ${profileEditingSupported ? null : html`
${this.agentProfileSupport.message ?? "Agent profile editing is unavailable for this machine."}
`} + + +
+ +
+
Allow agents to start sessions @@ -109,11 +141,14 @@ export class SettingsSessiondPanel extends LitElement { Beta: agents can start child sessions they stay attached to (spawn_subsession, list_subsessions, check_subsession, read_subsession) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.
-
-

Effective after environment overrides

+
+

Desired after environment overrides

-
Agent command
${effectiveAgent?.command ?? html`pi default`}
-
Agent state
${effectiveAgent?.dir ?? html`~/.pi/agent default`}
+
Desired command
${effectiveAgent?.command ?? html`Unavailable`}
+
Desired state
${effectiveAgent?.dir ?? html`Unavailable`}
+
Active command
${this.activeAgentProfile?.command ?? html`Unavailable`}
+
Active state
${this.activeAgentProfile?.dir ?? html`Unavailable`}
+
Profile status
${profileActivationLabel(profileActivation)}
Spawn sessions
${effectiveSpawn ? "Enabled" : html`Disabled`}
Subsessions
${effectiveSubsessions ? "Enabled" : html`Disabled`}
@@ -125,13 +160,21 @@ export class SettingsSessiondPanel extends LitElement { private panelNotices(config: PiWebConfigResponse | undefined): readonly SettingsNotice[] { const notices: SettingsNotice[] = []; - if (this.error !== "") notices.push({ type: "error", content: this.error }); + const error = this.agentLocalError || this.error; + if (error !== "") notices.push({ type: "error", content: error }); if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage }); - if (config !== undefined) { + const activation = agentProfileActivationState(config, this.activeAgentProfile); + if (activation === "restart-required") { notices.push({ type: "warning", - title: `Restart required on ${this.targetLabel}`, - content: html`run pi-web restart on that machine (or restart its session daemon service) after changing these settings.`, + title: `Agent profile restart required on ${this.targetLabel}`, + content: html`The desired profile differs from the active session-daemon profile. Run pi-web restart on that machine (or restart its session daemon service) to apply the command and state directory together.`, + }); + } else if (config !== undefined && activation === "unavailable" && this.agentProfileSupport.state === "supported") { + notices.push({ + type: "info", + title: `Active agent profile unavailable on ${this.targetLabel}`, + content: "PI WEB cannot compare the desired profile with the running session daemon. Reload after the daemon is available.", }); } return notices; @@ -141,9 +184,20 @@ export class SettingsSessiondPanel extends LitElement { return html`
${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}
`; } - private async saveAgentField(field: "command" | "dir", event: Event): Promise { - if (!(event.target instanceof HTMLInputElement)) return; - await this.onSave?.(agentFieldConfigPatch(this.configResponse?.config ?? {}, field, event.target.value)); + private async saveAgentProfile(event: Event): Promise { + event.preventDefault(); + this.agentLocalError = ""; + try { + await this.onSave?.(agentProfileConfigPatchFromDraft(this.agentDraft)); + } catch (error) { + this.agentLocalError = errorMessage(error); + } + } + + private updateAgentDraft(patch: Partial): void { + this.agentDraft = { ...this.agentDraft, ...patch }; + this.agentDraftDirty = true; + this.agentLocalError = ""; } private async toggleSpawnSessions(event: Event): Promise { @@ -162,9 +216,13 @@ export class SettingsSessiondPanel extends LitElement { button, input { font: inherit; } 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; } - .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } + .loading-card, .config-path-card, .effective-card, .profile-support-message { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } .loading-card { color: var(--pi-muted); } .config-path-card { display: grid; gap: 5px; } + .profile-form { display: grid; gap: 14px; } + .profile-support-message { color: var(--pi-muted); line-height: 1.45; } + .form-actions { display: flex; justify-content: flex-end; } + .primary { border-color: var(--pi-accent); background: var(--pi-accent); color: var(--pi-accent-contrast); } .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; } .field { display: grid; gap: 7px; } @@ -201,6 +259,20 @@ export class SettingsSessiondPanel extends LitElement { `; } +function profileActivationLabel(state: ReturnType): string | TemplateResult { + if (state === "active") return "Active"; + if (state === "restart-required") return "Restart required"; + return html`Unavailable`; +} + +function inputValue(event: Event): string { + return event.target instanceof HTMLInputElement ? event.target.value : ""; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function sessiondDescription(targetLabel: string): string { return `These settings affect the long-lived session runtime on ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`; } diff --git a/src/client/src/components/settings/settingsConfigDraft.test.ts b/src/client/src/components/settings/settingsConfigDraft.test.ts index 4fa2947..3990105 100644 --- a/src/client/src/components/settings/settingsConfigDraft.test.ts +++ b/src/client/src/components/settings/settingsConfigDraft.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; import { + agentProfileConfigPatchFromDraft, + agentProfileDraftFromConfig, + agentProfileDraftMatchesConfig, gatewayServerConfigFromDraft, gatewayServerDraftFromConfig, machineAccessConfigPatchFromDraft, @@ -29,6 +32,22 @@ describe("settings config drafts", () => { expect(gatewayServerDraftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all"); }); + it("builds one atomic agent profile patch from both draft fields", () => { + expect(agentProfileDraftFromConfig({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } })).toEqual({ + command: "agent-lab", + dir: "/srv/agent-lab", + }); + expect(agentProfileConfigPatchFromDraft({ command: " alternate-agent ", dir: " /srv/alternate-agent " })).toEqual({ + agent: { command: "alternate-agent", dir: "/srv/alternate-agent" }, + }); + expect(agentProfileConfigPatchFromDraft({ command: " ", dir: " " })).toEqual({ agent: {} }); + expect(agentProfileConfigPatchFromDraft({ command: " C:\\tools\\pi.exe ", dir: " C:\\agent-profiles\\work " })).toEqual({ + agent: { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\work" }, + }); + expect(agentProfileDraftMatchesConfig({ command: " agent-lab ", dir: " /srv/agent-lab " }, { agent: { command: "agent-lab", dir: "/srv/agent-lab" } })).toBe(true); + expect(agentProfileDraftMatchesConfig({ command: "agent-lab", dir: "/draft" }, { agent: { command: "agent-lab", dir: "/saved" } })).toBe(false); + }); + it("builds gateway server saves without dropping preserved config values", () => { expect(gatewayServerConfigFromDraft({ host: " gateway.local ", diff --git a/src/client/src/components/settings/settingsConfigDraft.ts b/src/client/src/components/settings/settingsConfigDraft.ts index 01ffcef..0afca5d 100644 --- a/src/client/src/components/settings/settingsConfigDraft.ts +++ b/src/client/src/components/settings/settingsConfigDraft.ts @@ -12,6 +12,11 @@ export interface MachineAccessConfigDraft { uploadDefaultFolder: string; } +export interface AgentProfileConfigDraft { + command: string; + dir: string; +} + export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft { return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" }; } @@ -20,6 +25,10 @@ export function emptyMachineAccessConfigDraft(): MachineAccessConfigDraft { return { allowedPathsText: "", uploadDefaultFolder: "" }; } +export function emptyAgentProfileConfigDraft(): AgentProfileConfigDraft { + return { command: "", dir: "" }; +} + export function gatewayServerDraftFromConfig(config: PiWebConfigValues): GatewayServerConfigDraft { return { host: config.host ?? "", @@ -36,6 +45,30 @@ export function machineAccessDraftFromConfig(config: PiWebConfigValues): Machine }; } +export function agentProfileDraftFromConfig(config: PiWebConfigValues): AgentProfileConfigDraft { + return { + command: config.agent?.command ?? "", + dir: config.agent?.dir ?? "", + }; +} + +export function agentProfileConfigPatchFromDraft(draft: AgentProfileConfigDraft): PiWebConfigValues { + const command = draft.command.trim(); + const dir = draft.dir.trim(); + return { + agent: { + ...(command === "" ? {} : { command }), + ...(dir === "" ? {} : { dir }), + }, + }; +} + +export function agentProfileDraftMatchesConfig(draft: AgentProfileConfigDraft, config: PiWebConfigValues): boolean { + const normalizedDraft = agentProfileConfigPatchFromDraft(draft).agent ?? {}; + const configured = config.agent ?? {}; + return normalizedDraft.command === configured.command && normalizedDraft.dir === configured.dir; +} + export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues { const config = preservedGatewayConfigRemainder(baseConfig); const host = draft.host.trim(); diff --git a/src/client/src/components/settings/settingsMachineTarget.test.ts b/src/client/src/components/settings/settingsMachineTarget.test.ts index b4703ed..ea47f16 100644 --- a/src/client/src/components/settings/settingsMachineTarget.test.ts +++ b/src/client/src/components/settings/settingsMachineTarget.test.ts @@ -1,7 +1,7 @@ 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"; +import { agentProfileSettingsSupport, friendlySelectedMachineSettingsErrorMessage, isAgentProfileSettingsSupported, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget"; const remoteMachine: Machine = { id: "remote-a", @@ -39,6 +39,27 @@ describe("selected-machine settings target helpers", () => { expect(selectedMachineSettingsSupportKey(unsupported)).toBe(`unsupported:${selectedMachineSettingsUnavailableMessage(target)}`); }); + it("gates remote agent profile edits on their granular capability", () => { + const target = settingsMachineTarget(remoteMachine); + + expect(agentProfileSettingsSupport({ id: "local", name: "local", kind: "local" }, undefined)).toEqual({ state: "supported" }); + expect(agentProfileSettingsSupport(target, undefined)).toEqual({ + state: "unknown", + message: "Agent profile support could not be verified on Lab Mac. Reload machine status before changing the profile.", + }); + expect(agentProfileSettingsSupport(target, { + ok: true, + capabilities: [PI_WEB_CAPABILITIES.agentProfileConfig], + })).toEqual({ state: "supported" }); + + const unsupported = agentProfileSettingsSupport(target, { ok: true, capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings] }); + expect(isAgentProfileSettingsSupported(unsupported)).toBe(false); + expect(unsupported).toEqual({ + state: "unsupported", + message: "Agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.", + }); + }); + it("turns older remote config route failures into selected-machine compatibility guidance", () => { const target = settingsMachineTarget(remoteMachine); diff --git a/src/client/src/components/settings/settingsMachineTarget.ts b/src/client/src/components/settings/settingsMachineTarget.ts index 9974081..f4489e5 100644 --- a/src/client/src/components/settings/settingsMachineTarget.ts +++ b/src/client/src/components/settings/settingsMachineTarget.ts @@ -14,6 +14,8 @@ export interface SelectedMachineSettingsSupport { message?: string; } +export type AgentProfileSettingsSupport = SelectedMachineSettingsSupport; + export function settingsMachineTarget(machine: Pick | undefined): SettingsMachineTarget { if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind }; return { id: "local", name: "local", kind: "local" }; @@ -30,6 +32,21 @@ export function selectedMachineSettingsSupport(target: SettingsMachineTarget, ru return { state: "unsupported", message: selectedMachineSettingsUnavailableMessage(target) }; } +export function agentProfileSettingsSupport(target: SettingsMachineTarget, runtime: Pick | undefined): AgentProfileSettingsSupport { + if (target.kind === "local") return { state: "supported" }; + if (runtime?.ok !== true) { + return { + state: "unknown", + message: `Agent profile support could not be verified on ${target.name}. Reload machine status before changing the profile.`, + }; + } + if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.agentProfileConfig)) return { state: "supported" }; + return { + state: "unsupported", + message: `Agent profile settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`, + }; +} + export function selectedMachineSettingsSupportKey(support: SelectedMachineSettingsSupport): string { return `${support.state}:${support.message ?? ""}`; } @@ -38,6 +55,10 @@ export function isSelectedMachineSettingsUnsupported(support: SelectedMachineSet return support?.state === "unsupported"; } +export function isAgentProfileSettingsSupported(support: AgentProfileSettingsSupport | undefined): boolean { + return support?.state === "supported"; +} + 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.`; } diff --git a/src/client/src/components/settings/settingsSessiondConfig.test.ts b/src/client/src/components/settings/settingsSessiondConfig.test.ts index 45a5a21..69d1752 100644 --- a/src/client/src/components/settings/settingsSessiondConfig.test.ts +++ b/src/client/src/components/settings/settingsSessiondConfig.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; -import { agentFieldConfigPatch, mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig"; +import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api"; +import { agentDirFieldOverridden, agentProfileActivationState, mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig"; describe("session daemon settings config helpers", () => { it("builds daemon-only save patches for the sessiond toggles", () => { @@ -8,20 +8,37 @@ describe("session daemon settings config helpers", () => { expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true }); }); - it("builds agent-only patches while preserving sibling agent fields", () => { - const base = { - host: "127.0.0.1", - agent: { command: "agent-lab", dir: "/srv/agent-lab" }, - }; + it("compares the desired effective profile with the daemon-owned active profile", () => { + const config = configResponse( + { agent: { command: "configured-agent", dir: "/configured" } }, + {}, + { agent: { command: "effective-agent", dir: "/effective" } }, + ); - expect(agentFieldConfigPatch(base, "command", " alternate-agent ")).toEqual({ - agent: { command: "alternate-agent", dir: "/srv/agent-lab" }, - }); - expect(agentFieldConfigPatch(base, "dir", " ")).toEqual({ - agent: { command: "agent-lab" }, - }); - expect(agentFieldConfigPatch({ agent: { dir: "/srv/agent-lab" } }, "dir", "")).toEqual({ agent: {} }); - expect(agentFieldConfigPatch(base, "command", "agent-lab")).not.toHaveProperty("host"); + expect(agentProfileActivationState(config, activeProfile("effective-agent", "/effective"))).toBe("active"); + expect(agentProfileActivationState(config, activeProfile("other-agent", "/effective"))).toBe("restart-required"); + expect(agentProfileActivationState(config, activeProfile("effective-agent", "/other"))).toBe("restart-required"); + expect(agentProfileActivationState(configResponse({}, {}, { agent: { command: "pi", dir: "/effective" } }), activeProfile("pi", "/effective"))).toBe("restart-required"); + expect(agentProfileActivationState(configResponse({}, {}, { agent: { command: "pi", dir: "/effective" } }), activeProfile("pi", "/effective", ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]))).toBe("active"); + expect(agentProfileActivationState(config, undefined)).toBe("unavailable"); + expect(agentProfileActivationState(undefined, activeProfile("effective-agent", "/effective"))).toBe("unavailable"); + }); + + it("releases only Pi's compatibility directory override when the draft selects an alternate command", () => { + const baseOverrides = configResponse({}).envOverrides; + + expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "pi")).toBe(true); + expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "pi.exe")).toBe(true); + expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "alternate-agent")).toBe(false); + expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-web" }, "alternate-agent")).toBe(true); + expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true }, "alternate-agent")).toBe(true); + }); + + it("does not leak the gateway agent directory source into a selected-machine response", () => { + const gateway = configResponse({}, { agentDir: true, agentDirSource: "pi-web" }); + const selectedMachine = configResponse({}, { agentDir: false }); + + expect(mergeSelectedMachineSessiondConfig(gateway, selectedMachine).envOverrides.agentDirSource).toBeUndefined(); }); it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => { @@ -37,7 +54,7 @@ describe("session daemon settings config helpers", () => { }); const selectedMachine = configResponse( { spawnSessions: true, subsessions: true, agent: { command: "machine-agent", dir: "/srv/machine-agent" } }, - { spawnSessions: true, subsessions: false, agentCommand: true, agentDir: false, agentSessionDir: true }, + { spawnSessions: true, subsessions: false, agentCommand: true, agentDir: false, agentDirSource: "pi-compatibility", agentSessionDir: true }, { spawnSessions: true, subsessions: true, agent: { command: "env-agent", dir: "/srv/machine-agent" } }, ); @@ -71,12 +88,23 @@ describe("session daemon settings config helpers", () => { subsessions: false, agentCommand: true, agentDir: false, + agentDirSource: "pi-compatibility", agentSessionDir: true, }, }); }); }); +function activeProfile(command: string, dir: string, sessionDirEnvKeys: readonly string[] = ["PI_WEB_AGENT_SESSION_DIR"]): ActiveAgentProfileDescriptor { + return { + schemaVersion: 1, + revision: `sha256:${"a".repeat(64)}`, + command, + dir, + sessionDirEnvKeys, + }; +} + function configResponse( config: PiWebConfigValues, overrides: Partial = {}, diff --git a/src/client/src/components/settings/settingsSessiondConfig.ts b/src/client/src/components/settings/settingsSessiondConfig.ts index 1fe38d8..f330482 100644 --- a/src/client/src/components/settings/settingsSessiondConfig.ts +++ b/src/client/src/components/settings/settingsSessiondConfig.ts @@ -1,4 +1,7 @@ -import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; +import { usesPiCodingAgentStateCompatibility } from "../../../../shared/activeAgentProfile"; +import type { ActiveAgentProfileDescriptor, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../../api"; + +export type AgentProfileActivationState = "active" | "restart-required" | "unavailable"; export function spawnSessionsConfigPatch(enabled: boolean): PiWebConfigValues { return { spawnSessions: enabled }; @@ -8,36 +11,51 @@ export function subsessionsConfigPatch(enabled: boolean): PiWebConfigValues { return { subsessions: enabled }; } -export function agentFieldConfigPatch( - baseConfig: PiWebConfigValues, - field: "command" | "dir", - rawValue: string, -): PiWebConfigValues { - const value = rawValue.trim(); - const agent: NonNullable = { ...(baseConfig.agent ?? {}) }; - if (field === "command") { - if (value === "") delete agent.command; - else agent.command = value; - } else if (value === "") { - delete agent.dir; - } else { - agent.dir = value; - } - return { agent }; +export function agentProfileActivationState( + config: PiWebConfigResponse | undefined, + activeProfile: ActiveAgentProfileDescriptor | undefined, +): AgentProfileActivationState { + const desiredProfile = config?.effectiveConfig.agent; + if (desiredProfile?.command === undefined || desiredProfile.dir === undefined || activeProfile === undefined) return "unavailable"; + const desiredSessionDirEnvKeys = [ + "PI_WEB_AGENT_SESSION_DIR", + ...(usesPiCodingAgentStateCompatibility(desiredProfile.command) ? ["PI_CODING_AGENT_SESSION_DIR"] : []), + ]; + return desiredProfile.command === activeProfile.command + && desiredProfile.dir === activeProfile.dir + && sameStrings(activeProfile.sessionDirEnvKeys, desiredSessionDirEnvKeys) + ? "active" + : "restart-required"; +} + +export function agentDirFieldOverridden(envOverrides: PiWebConfigEnvOverrides | undefined, draftCommand: string): boolean { + if (envOverrides?.agentDirSource === "pi-web") return true; + if (envOverrides?.agentDirSource === "pi-compatibility") return usesPiCodingAgentStateCompatibility(draftCommand.trim() || "pi"); + // Older remote responses do not identify the source. Keep their override + // read-only rather than incorrectly treating a PI_WEB_AGENT_DIR as conditional. + return envOverrides?.agentDir === true; } export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse { + const envOverrides: PiWebConfigEnvOverrides = { + ...base.envOverrides, + spawnSessions: selectedMachine.envOverrides.spawnSessions, + subsessions: selectedMachine.envOverrides.subsessions, + agentCommand: selectedMachine.envOverrides.agentCommand, + agentDir: selectedMachine.envOverrides.agentDir, + agentSessionDir: selectedMachine.envOverrides.agentSessionDir, + }; + if (selectedMachine.envOverrides.agentDirSource === undefined) delete envOverrides.agentDirSource; + else envOverrides.agentDirSource = selectedMachine.envOverrides.agentDirSource; + return { ...base, config: { ...base.config, ...selectedMachine.config }, effectiveConfig: { ...base.effectiveConfig, ...selectedMachine.effectiveConfig }, - envOverrides: { - ...base.envOverrides, - spawnSessions: selectedMachine.envOverrides.spawnSessions, - subsessions: selectedMachine.envOverrides.subsessions, - agentCommand: selectedMachine.envOverrides.agentCommand, - agentDir: selectedMachine.envOverrides.agentDir, - agentSessionDir: selectedMachine.envOverrides.agentSessionDir, - }, + envOverrides, }; } + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/src/client/src/controllers/machineController.test.ts b/src/client/src/controllers/machineController.test.ts index 775d62d..759df89 100644 --- a/src/client/src/controllers/machineController.test.ts +++ b/src/client/src/controllers/machineController.test.ts @@ -93,7 +93,7 @@ describe("MachineController", () => { expect(projects.loadProjects).toHaveBeenCalledOnce(); expect(updateUrl).toHaveBeenCalledOnce(); expect(health).toHaveBeenCalledWith(addedMachine.id); - expect(runtime).toHaveBeenCalledWith(addedMachine.id); + expect(runtime).toHaveBeenCalledWith(addedMachine.id, true); }); it("preserves the current machine state when adding a machine fails", async () => { diff --git a/src/client/src/controllers/machineController.ts b/src/client/src/controllers/machineController.ts index b508315..0378173 100644 --- a/src/client/src/controllers/machineController.ts +++ b/src/client/src/controllers/machineController.ts @@ -102,7 +102,7 @@ export class MachineController { async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise { try { - const runtime = await api.runtime(machineId); + const runtime = await api.runtime(machineId, true); this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, [runtime.machineId]: runtime } }); } catch (error) { this.setState({ error: String(error) }); diff --git a/src/config.test.ts b/src/config.test.ts index 431ecf1..be5c0d1 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentSessionDirEnvKeys, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; +import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; let tempDir: string; let configPath: string; @@ -135,22 +135,30 @@ describe("PI WEB config persistence", () => { }); it("uses explicit PI WEB agent directory env precedence", () => { - expect(effectiveAgentConfig({ + const env = { PI_WEB_AGENT_COMMAND: "acme-agent", PI_WEB_AGENT_DIR: join(tempDir, "web-env-agent"), PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"), - }, { agent: { command: "pi", dir: join(tempDir, "config-agent") } })).toMatchObject({ + }; + expect(effectiveAgentConfig(env, { agent: { command: "pi", dir: join(tempDir, "config-agent") } })).toMatchObject({ command: "acme-agent", dir: join(tempDir, "web-env-agent"), }); + expect(agentDirEnvSource(env)).toBe("pi-web"); }); it("keeps legacy Pi env directory overrides scoped to the canonical Pi command", () => { const legacyDir = join(tempDir, "pi-env-agent"); - expect(effectiveAgentConfig({ PI_CODING_AGENT_DIR: legacyDir }, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({ dir: legacyDir }); + const alternateDir = join(tempDir, "alternate-agent"); + const env = { PI_CODING_AGENT_DIR: legacyDir }; + expect(effectiveAgentConfig(env, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({ dir: legacyDir }); + expect(effectiveAgentConfig(env, { agent: { command: "acme-agent", dir: alternateDir } })).toMatchObject({ command: "acme-agent", dir: alternateDir }); + expect(agentDirEnvSource(env)).toBe("pi-compatibility"); + expect(hasAgentDirEnvOverride(env, "pi")).toBe(true); + expect(hasAgentDirEnvOverride(env, "acme-agent")).toBe(false); for (const command of ["acme-agent", join(tempDir, "bin", "pi")]) { - expect(() => effectiveAgentConfig({ PI_CODING_AGENT_DIR: legacyDir }, { agent: { command } })) + expect(() => effectiveAgentConfig(env, { agent: { command } })) .toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`); } }); diff --git a/src/config.ts b/src/config.ts index 21286cd..8ad0920 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, normalize, resolve } from "node:path"; -import type { PiWebConfigValues } from "./shared/apiTypes.js"; +import type { PiWebAgentDirEnvSource, PiWebConfigValues } from "./shared/apiTypes.js"; +import { isPiCompanionCommand, usesPiCodingAgentStateCompatibility } from "./shared/activeAgentProfile.js"; import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js"; +export { isPiCompanionCommand }; + export type PiWebConfig = PiWebConfigValues; export interface LoadedPiWebConfig { @@ -61,7 +64,7 @@ export interface EffectivePiWebAgentConfig { export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick = {}): EffectivePiWebAgentConfig { const command = parseAgentCommand(envValue(env, PI_WEB_AGENT_COMMAND_ENV) ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment", "current"); - const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? (usesDefaultPiStatePolicy(command) ? envValue(env, PI_CODING_AGENT_DIR_ENV) : undefined) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env); + const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? (usesPiCodingAgentStateCompatibility(command) ? envValue(env, PI_CODING_AGENT_DIR_ENV) : undefined) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env); return { command, dir: resolveAgentDirPath(configuredDir, env, "agent.dir", "environment"), @@ -72,12 +75,19 @@ export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, confi export function agentSessionDirEnvKeys(command = DEFAULT_AGENT_COMMAND): string[] { return uniqueStrings([ PI_WEB_AGENT_SESSION_DIR_ENV, - ...(usesDefaultPiStatePolicy(command) ? [PI_CODING_AGENT_SESSION_DIR_ENV] : []), + ...(usesPiCodingAgentStateCompatibility(command) ? [PI_CODING_AGENT_SESSION_DIR_ENV] : []), ]); } +export function agentDirEnvSource(env: NodeJS.ProcessEnv): PiWebAgentDirEnvSource | undefined { + if (isEnvSet(env[PI_WEB_AGENT_DIR_ENV])) return "pi-web"; + if (isEnvSet(env[PI_CODING_AGENT_DIR_ENV])) return "pi-compatibility"; + return undefined; +} + export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { - return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || (usesDefaultPiStatePolicy(command) && isEnvSet(env[PI_CODING_AGENT_DIR_ENV])); + const source = agentDirEnvSource(env); + return source === "pi-web" || (source === "pi-compatibility" && usesPiCodingAgentStateCompatibility(command)); } export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { @@ -391,19 +401,10 @@ function expandHomePath(value: string, env: NodeJS.ProcessEnv): string { } function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string { - if (usesDefaultPiStatePolicy(command)) return expandHomePath("~/.pi/agent", env); + if (usesPiCodingAgentStateCompatibility(command)) return expandHomePath("~/.pi/agent", env); throw new Error(`PI WEB config agent.dir or ${PI_WEB_AGENT_DIR_ENV} is required when agent.command is ${JSON.stringify(command)}`); } -function usesDefaultPiStatePolicy(command: string): boolean { - return !command.includes("/") && !command.includes("\\") && isPiCompanionCommand(command); -} - -export function isPiCompanionCommand(command: string): boolean { - const name = command.split(/[\\/]/u).at(-1)?.toLowerCase() ?? command.toLowerCase(); - return name.replace(/(?:\.[cm]?js|\.exe|\.cmd)$/iu, "") === DEFAULT_AGENT_COMMAND; -} - function envValue(env: NodeJS.ProcessEnv, key: string): string | undefined { const value = env[key]; return value !== undefined && value !== "" ? value : undefined; diff --git a/src/server/app.machines.test.ts b/src/server/app.machines.test.ts index 6bdf2d1..d0d2698 100644 --- a/src/server/app.machines.test.ts +++ b/src/server/app.machines.test.ts @@ -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((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(() => 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((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((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().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((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().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 }>(); diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index 6225d12..3067008 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -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(); diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index 5ab96a3..ef3c6b4 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -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, key: string, s return value; } +function optionalAgentDirSource(record: Record, 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), }; } diff --git a/src/server/machines/machineProxyRoutes.ts b/src/server/machines/machineProxyRoutes.ts index e9d85d0..31620dd 100644 --- a/src/server/machines/machineProxyRoutes.ts +++ b/src/server/machines/machineProxyRoutes.ts @@ -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 { diff --git a/src/server/machines/machineRoutes.ts b/src/server/machines/machineRoutes.ts index 28a2802..3e6a861 100644 --- a/src/server/machines/machineRoutes.ts +++ b/src/server/machines/machineRoutes.ts @@ -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; }); diff --git a/src/server/machines/machineService.test.ts b/src/server/machines/machineService.test.ts index 9907a8c..7a0189a 100644 --- a/src/server/machines/machineService.test.ts +++ b/src/server/machines/machineService.test.ts @@ -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 }), ]); }); diff --git a/src/server/machines/machineService.ts b/src/server/machines/machineService.ts index 2e8b6a3..7896637 100644 --- a/src/server/machines/machineService.ts +++ b/src/server/machines/machineService.ts @@ -93,10 +93,10 @@ export class MachineService { return health; } - async runtime(id: string): Promise { + async runtime(id: string, refresh = false): Promise { 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; diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index f9626ce..0bbd969 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -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 () => { diff --git a/src/shared/activeAgentProfile.ts b/src/shared/activeAgentProfile.ts index 7bf07cc..8f7a65c 100644 --- a/src/shared/activeAgentProfile.ts +++ b/src/shared/activeAgentProfile.ts @@ -2,6 +2,15 @@ import type { ActiveAgentProfileDescriptor } from "./apiTypes.js"; export const ACTIVE_AGENT_PROFILE_SCHEMA_VERSION = 1 as const; +export function isPiCompanionCommand(command: string): boolean { + const name = command.split(/[\\/]/u).at(-1)?.toLowerCase() ?? command.toLowerCase(); + return name.replace(/(?:\.[cm]?js|\.exe|\.cmd)$/iu, "") === "pi"; +} + +export function usesPiCodingAgentStateCompatibility(command: string): boolean { + return !command.includes("/") && !command.includes("\\") && isPiCompanionCommand(command); +} + const ACTIVE_AGENT_PROFILE_FIELDS = new Set([ "schemaVersion", "revision", diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 2213b77..f839e1e 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -11,6 +11,7 @@ export const PI_WEB_CAPABILITIES = { workspaceFileSuggestions: "workspace.fileSuggestions", piPackagesManage: "piPackages.manage", selectedMachineSettings: "settings.selectedMachine", + agentProfileConfig: "settings.agentProfile", } as const; export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES]; @@ -149,6 +150,8 @@ export interface PiPackageMutationResponse extends PiPackagesResponse { removed?: boolean; } +export type PiWebAgentDirEnvSource = "pi-web" | "pi-compatibility"; + export interface PiWebConfigEnvOverrides { host: boolean; port: boolean; @@ -157,6 +160,8 @@ export interface PiWebConfigEnvOverrides { subsessions: boolean; agentCommand: boolean; agentDir: boolean; + /** The configured directory environment source, even when Pi compatibility is inactive for the desired command. */ + agentDirSource?: PiWebAgentDirEnvSource; agentSessionDir: boolean; } diff --git a/src/shared/capabilities.test.ts b/src/shared/capabilities.test.ts index aa03c5c..6deecd4 100644 --- a/src/shared/capabilities.test.ts +++ b/src/shared/capabilities.test.ts @@ -5,13 +5,15 @@ describe("PI WEB capabilities", () => { 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(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.agentProfileConfig); expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage); expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings); + expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.agentProfileConfig); expect(effectivePiWebCapabilities({ - web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] }, + web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig] }, sessiond: { available: false, capabilities: [] }, - })).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]); + })).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]); }); it("requires web and session daemon support for authoritative session persistence", () => { diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts index 8d00989..7a91f52 100644 --- a/src/shared/capabilities.ts +++ b/src/shared/capabilities.ts @@ -16,6 +16,7 @@ export const WEB_RUNTIME_CAPABILITIES = [ PI_WEB_CAPABILITIES.workspaceFileSuggestions, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, + PI_WEB_CAPABILITIES.agentProfileConfig, ] as const satisfies readonly PiWebCapability[]; export const SESSIOND_RUNTIME_CAPABILITIES = [ @@ -37,6 +38,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = { [PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"], [PI_WEB_CAPABILITIES.piPackagesManage]: ["web"], [PI_WEB_CAPABILITIES.selectedMachineSettings]: ["web"], + [PI_WEB_CAPABILITIES.agentProfileConfig]: ["web"], } as const satisfies Record; export function isPiWebCapability(value: unknown): value is PiWebCapability { diff --git a/src/shared/piWebStatusParsing.test.ts b/src/shared/piWebStatusParsing.test.ts index 1bbb69d..1683b12 100644 --- a/src/shared/piWebStatusParsing.test.ts +++ b/src/shared/piWebStatusParsing.test.ts @@ -8,16 +8,16 @@ describe("PI WEB status parsing", () => { packageName: "@jmfederico/pi-web", generatedAt: "now", components: { - web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"] }, + web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"] }, sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: ["future.sessiondCapability"] }, }, - capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"], + capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"], })).toMatchObject({ components: { - web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] }, + web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig] }, sessiond: { capabilities: [] }, }, - capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings], + capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig], }); });