Archived
feat: apply agent profile settings atomically
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
@@ -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<string, unknown>): { 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")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1947,7 +1947,7 @@ export class PiWebApp extends LitElement {
|
||||
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
|
||||
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-dialog>` : null}
|
||||
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }} .onRefreshMachineRuntime=${(machineId: string) => this.machines.refreshMachineRuntime(machineId)}></settings-dialog>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
|
||||
|
||||
@@ -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<void>;
|
||||
@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)}
|
||||
></settings-sessiond-panel>
|
||||
`;
|
||||
@@ -264,6 +267,13 @@ export class SettingsDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async reloadSessiondState(target = this.settingsTarget()): Promise<void> {
|
||||
await Promise.all([
|
||||
this.loadSessiondConfigForTarget(target),
|
||||
this.onRefreshMachineRuntime?.(target.id),
|
||||
]);
|
||||
}
|
||||
|
||||
private async loadSessiondConfigForTarget(target = this.settingsTarget()): Promise<void> {
|
||||
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);
|
||||
|
||||
@@ -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 <code>pi-web restart</code> on that machine",
|
||||
"Agent profile restart required on Lab Mac (remote machine)",
|
||||
"Run <code>pi-web restart</code> 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<void> {
|
||||
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);
|
||||
|
||||
@@ -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<void>;
|
||||
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
|
||||
@state() private agentDraft: AgentProfileConfigDraft = emptyAgentProfileConfigDraft();
|
||||
@state() private agentDraftDirty = false;
|
||||
@state() private agentLocalError = "";
|
||||
|
||||
protected override willUpdate(changed: PropertyValues<this>): 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`
|
||||
<settings-panel-frame
|
||||
heading="Session daemon"
|
||||
@@ -42,9 +68,11 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
<span>Config file</span>
|
||||
<code>${config.path}</code>
|
||||
</div>
|
||||
<form class="profile-form" aria-label="Pi-compatible agent profile" @submit=${(event: Event) => { void this.saveAgentProfile(event); }}>
|
||||
${profileEditingSupported ? null : html`<div class="profile-support-message">${this.agentProfileSupport.message ?? "Agent profile editing is unavailable for this machine."}</div>`}
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Agent command for diagnostics</span>
|
||||
<span>Companion CLI command</span>
|
||||
${agentCommandOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<input
|
||||
@@ -52,30 +80,34 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
.value=${config.config.agent?.command ?? ""}
|
||||
.value=${this.agentDraft.command}
|
||||
placeholder="pi"
|
||||
?disabled=${this.loading || this.saving || agentCommandOverridden}
|
||||
@change=${(event: Event) => { void this.saveAgentField("command", event); }}
|
||||
?disabled=${this.loading || this.saving || !profileEditingSupported || agentCommandOverridden}
|
||||
@input=${(event: Event) => { this.updateAgentDraft({ command: inputValue(event) }); }}
|
||||
>
|
||||
<small>Set an alternate Pi-compatible CLI when doctor/update checks should target a different command. The embedded session runtime remains PI WEB's SDK path, so this does not dynamically load a different agent implementation.</small>
|
||||
<small>Set the Pi-compatible companion CLI used for doctor and update checks. The embedded session runtime remains PI WEB's bundled Pi SDK.</small>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Agent state directory</span>
|
||||
${agentDirOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
${effectiveAgentDirOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<input
|
||||
class="text-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
.value=${config.config.agent?.dir ?? ""}
|
||||
.value=${this.agentDraft.dir}
|
||||
placeholder="~/.pi/agent or ~/agent-profiles/work"
|
||||
?disabled=${this.loading || this.saving || agentDirOverridden}
|
||||
@change=${(event: Event) => { void this.saveAgentField("dir", event); }}
|
||||
?disabled=${this.loading || this.saving || !profileEditingSupported || agentDirLocked}
|
||||
@input=${(event: Event) => { this.updateAgentDraft({ dir: inputValue(event) }); }}
|
||||
>
|
||||
<small>Choose which compatible auth, models, settings, and sessions PI WEB reads. Non-<code>pi</code> commands require an explicit state directory, then a session daemon restart.</small>
|
||||
<small>Choose the compatible auth, models, settings, and sessions PI WEB reads. An alternate command and its required state directory are saved together.</small>
|
||||
</label>
|
||||
<footer class="form-actions">
|
||||
<button class="primary" type="submit" ?disabled=${this.loading || this.saving || !profileEditingSupported || (agentCommandOverridden && agentDirLocked)}>${this.saving ? "Saving…" : "Save agent profile"}</button>
|
||||
</footer>
|
||||
</form>
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allow agents to start sessions</span>
|
||||
@@ -109,11 +141,14 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
</label>
|
||||
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
|
||||
</div>
|
||||
<section class="effective-card" aria-label="Effective configuration summary">
|
||||
<h3>Effective after environment overrides</h3>
|
||||
<section class="effective-card" aria-label="Desired and active session daemon configuration summary">
|
||||
<h3>Desired after environment overrides</h3>
|
||||
<dl>
|
||||
<div><dt>Agent command</dt><dd>${effectiveAgent?.command ?? html`<span class="muted">pi default</span>`}</dd></div>
|
||||
<div><dt>Agent state</dt><dd>${effectiveAgent?.dir ?? html`<span class="muted">~/.pi/agent default</span>`}</dd></div>
|
||||
<div><dt>Desired command</dt><dd>${effectiveAgent?.command ?? html`<span class="muted">Unavailable</span>`}</dd></div>
|
||||
<div><dt>Desired state</dt><dd>${effectiveAgent?.dir ?? html`<span class="muted">Unavailable</span>`}</dd></div>
|
||||
<div><dt>Active command</dt><dd>${this.activeAgentProfile?.command ?? html`<span class="muted">Unavailable</span>`}</dd></div>
|
||||
<div><dt>Active state</dt><dd>${this.activeAgentProfile?.dir ?? html`<span class="muted">Unavailable</span>`}</dd></div>
|
||||
<div><dt>Profile status</dt><dd>${profileActivationLabel(profileActivation)}</dd></div>
|
||||
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
||||
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
||||
</dl>
|
||||
@@ -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 <code>pi-web restart</code> 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 <code>pi-web restart</code> 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`<div class="loading-card">${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}</div>`;
|
||||
}
|
||||
|
||||
private async saveAgentField(field: "command" | "dir", event: Event): Promise<void> {
|
||||
if (!(event.target instanceof HTMLInputElement)) return;
|
||||
await this.onSave?.(agentFieldConfigPatch(this.configResponse?.config ?? {}, field, event.target.value));
|
||||
private async saveAgentProfile(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
this.agentLocalError = "";
|
||||
try {
|
||||
await this.onSave?.(agentProfileConfigPatchFromDraft(this.agentDraft));
|
||||
} catch (error) {
|
||||
this.agentLocalError = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
private updateAgentDraft(patch: Partial<AgentProfileConfigDraft>): void {
|
||||
this.agentDraft = { ...this.agentDraft, ...patch };
|
||||
this.agentDraftDirty = true;
|
||||
this.agentLocalError = "";
|
||||
}
|
||||
|
||||
private async toggleSpawnSessions(event: Event): Promise<void> {
|
||||
@@ -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<typeof agentProfileActivationState>): string | TemplateResult {
|
||||
if (state === "active") return "Active";
|
||||
if (state === "restart-required") return "Restart required";
|
||||
return html`<span class="muted">Unavailable</span>`;
|
||||
}
|
||||
|
||||
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.`;
|
||||
}
|
||||
|
||||
@@ -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 ",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface SelectedMachineSettingsSupport {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type AgentProfileSettingsSupport = SelectedMachineSettingsSupport;
|
||||
|
||||
export function settingsMachineTarget(machine: Pick<Machine, "id" | "name" | "kind"> | undefined): SettingsMachineTarget {
|
||||
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
|
||||
return { id: "local", name: "local", kind: "local" };
|
||||
@@ -30,6 +32,21 @@ export function selectedMachineSettingsSupport(target: SettingsMachineTarget, ru
|
||||
return { state: "unsupported", message: selectedMachineSettingsUnavailableMessage(target) };
|
||||
}
|
||||
|
||||
export function agentProfileSettingsSupport(target: SettingsMachineTarget, runtime: Pick<MachineRuntime, "ok" | "capabilities"> | 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.`;
|
||||
}
|
||||
|
||||
@@ -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(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");
|
||||
});
|
||||
expect(agentFieldConfigPatch(base, "dir", " ")).toEqual({
|
||||
agent: { command: "agent-lab" },
|
||||
|
||||
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);
|
||||
});
|
||||
expect(agentFieldConfigPatch({ agent: { dir: "/srv/agent-lab" } }, "dir", "")).toEqual({ agent: {} });
|
||||
expect(agentFieldConfigPatch(base, "command", "agent-lab")).not.toHaveProperty("host");
|
||||
|
||||
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<PiWebConfigResponse["envOverrides"]> = {},
|
||||
|
||||
@@ -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<PiWebConfigValues["agent"]> = { ...(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 {
|
||||
return {
|
||||
...base,
|
||||
config: { ...base.config, ...selectedMachine.config },
|
||||
effectiveConfig: { ...base.effectiveConfig, ...selectedMachine.effectiveConfig },
|
||||
envOverrides: {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -102,7 +102,7 @@ export class MachineController {
|
||||
|
||||
async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<void> {
|
||||
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) });
|
||||
|
||||
+13
-5
@@ -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)}`);
|
||||
}
|
||||
});
|
||||
|
||||
+15
-14
@@ -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<PiWebConfig, "agent"> = {}): 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;
|
||||
|
||||
@@ -61,18 +61,39 @@ describe("buildApp machine routes", () => {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
components: {
|
||||
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
|
||||
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"] },
|
||||
sessiond: {
|
||||
component: "sessiond",
|
||||
label: "Remote Sessiond",
|
||||
runtimeVersion: "1.0.0",
|
||||
available: true,
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
|
||||
activeAgentProfile: {
|
||||
schemaVersion: 1,
|
||||
revision: `sha256:${"a".repeat(64)}`,
|
||||
command: "remote-agent",
|
||||
dir: "/srv/remote-agent",
|
||||
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
|
||||
},
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"],
|
||||
},
|
||||
}));
|
||||
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const runtime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` });
|
||||
const refreshedRuntime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime?refresh=1` });
|
||||
|
||||
expect(runtime.statusCode).toBe(200);
|
||||
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
|
||||
expect(refreshedRuntime.statusCode).toBe(200);
|
||||
expect(runtime.json()).toMatchObject({
|
||||
machineId: remote.id,
|
||||
ok: true,
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig],
|
||||
components: { sessiond: { activeAgentProfile: { command: "remote-agent", dir: "/srv/remote-agent" } } },
|
||||
});
|
||||
expect(requestJson).toHaveBeenCalledTimes(2);
|
||||
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
|
||||
});
|
||||
|
||||
@@ -101,9 +122,11 @@ describe("buildApp machine routes", () => {
|
||||
it("merges remote selected-machine config updates into the target machine config", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
let persistedConfig = fullPiWebConfig();
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
|
||||
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(fullPiWebConfig()) });
|
||||
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(configFromMachineConfigWriteBody(body)) });
|
||||
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(persistedConfig) });
|
||||
persistedConfig = configFromMachineConfigWriteBody(body);
|
||||
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(persistedConfig) });
|
||||
});
|
||||
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
@@ -136,6 +159,102 @@ describe("buildApp machine routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a false-success agent profile write from an older remote machine", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const legacyConfig = fullPiWebConfig();
|
||||
delete legacyConfig.agent;
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: piWebConfigResponse(legacyConfig),
|
||||
}));
|
||||
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await appTestContext.app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/config`,
|
||||
payload: { config: { agent: { command: "remote-agent", dir: "/srv/remote-agent" } } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json()).toMatchObject({
|
||||
error: "Remote machine did not persist the requested agent profile",
|
||||
machineId: remote.id,
|
||||
});
|
||||
expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config");
|
||||
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
|
||||
config: { ...legacyConfig, agent: { command: "remote-agent", dir: "/srv/remote-agent" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies an explicit remote profile reset instead of treating an empty profile as no patch", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>((method) => {
|
||||
const config = fullPiWebConfig();
|
||||
if (method === "PUT") delete config.agent;
|
||||
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
|
||||
});
|
||||
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await appTestContext.app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/config`,
|
||||
payload: { config: { agent: {} } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json()).toMatchObject({ error: "Remote machine did not persist the requested agent profile" });
|
||||
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
|
||||
config: { ...fullPiWebConfig(), agent: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-profile selected-machine saves compatible with older remote machines", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const legacyConfig = fullPiWebConfig();
|
||||
delete legacyConfig.agent;
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
|
||||
const config = method === "PUT" ? configFromMachineConfigWriteBody(body) : legacyConfig;
|
||||
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
|
||||
});
|
||||
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await appTestContext.app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/config`,
|
||||
payload: { config: { spawnSessions: true } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json<PiWebConfigResponse>().config.spawnSessions).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves foreign-platform agent paths while the target verifies persistence", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const windowsAgent = { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\work" };
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
|
||||
const config = method === "PUT" ? configFromMachineConfigWriteBody(body) : fullPiWebConfig();
|
||||
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
|
||||
});
|
||||
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await appTestContext.app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/config`,
|
||||
payload: { config: { agent: windowsAgent } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json<PiWebConfigResponse>().config.agent).toEqual(windowsAgent);
|
||||
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
|
||||
config: { ...fullPiWebConfig(), agent: windowsAgent },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsafe remote selected-machine config keys before proxying", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
|
||||
@@ -202,6 +202,23 @@ describe("config routes", () => {
|
||||
expect(parsed.envOverrides).toMatchObject({ agentCommand: false, agentDir: false, agentSessionDir: false });
|
||||
});
|
||||
|
||||
it("retains the agent directory environment source across federation responses", () => {
|
||||
const parsed = parsePiWebConfigResponseBody({
|
||||
...responseFor({}, false),
|
||||
envOverrides: {
|
||||
...responseFor({}, false).envOverrides,
|
||||
agentDir: true,
|
||||
agentDirSource: "pi-compatibility",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.envOverrides).toMatchObject({ agentDir: true, agentDirSource: "pi-compatibility" });
|
||||
expect(() => parsePiWebConfigResponseBody({
|
||||
...responseFor({}, false),
|
||||
envOverrides: { ...responseFor({}, false).envOverrides, agentDirSource: "future-source" },
|
||||
})).toThrow("valid agent directory source");
|
||||
});
|
||||
|
||||
it("rejects unsafe local selected-machine config keys before writing", async () => {
|
||||
savedConfig = fullConfig();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseAgentConfig, parseUploadsConfig, resolveEffectivePiWebConfig, savePiWebConfig, type AgentPathHost, type LoadOptions, type PiWebConfig } from "../config.js";
|
||||
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import { agentDirEnvSource, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseAgentConfig, parseUploadsConfig, resolveEffectivePiWebConfig, savePiWebConfig, type AgentPathHost, type LoadOptions, type PiWebConfig } from "../config.js";
|
||||
import type { PiWebAgentDirEnvSource, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import { isPiWebPluginId } from "../shared/pluginIds.js";
|
||||
|
||||
export interface PiWebConfigService {
|
||||
@@ -243,6 +243,7 @@ function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): P
|
||||
subsessions: requireResponseBoolean(record, "subsessions", source),
|
||||
agentCommand: optionalResponseBoolean(record, "agentCommand", source) ?? false,
|
||||
agentDir: optionalResponseBoolean(record, "agentDir", source) ?? false,
|
||||
...optionalAgentDirSource(record, source),
|
||||
agentSessionDir: optionalResponseBoolean(record, "agentSessionDir", source) ?? false,
|
||||
};
|
||||
}
|
||||
@@ -271,8 +272,16 @@ function optionalResponseBoolean(record: Record<string, unknown>, key: string, s
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalAgentDirSource(record: Record<string, unknown>, source: string): { agentDirSource?: PiWebAgentDirEnvSource } {
|
||||
const value = record["agentDirSource"];
|
||||
if (value === undefined) return {};
|
||||
if (value !== "pi-web" && value !== "pi-compatibility") throw new Error(`${source} field must be a valid agent directory source: agentDirSource`);
|
||||
return { agentDirSource: value };
|
||||
}
|
||||
|
||||
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {}): PiWebConfigEnvOverrides {
|
||||
const command = config.agent?.command;
|
||||
const dirEnvSource = agentDirEnvSource(env);
|
||||
return {
|
||||
host: isEnvSet(env["PI_WEB_HOST"]),
|
||||
port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]),
|
||||
@@ -281,6 +290,7 @@ function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {
|
||||
subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]),
|
||||
agentCommand: isEnvSet(env["PI_WEB_AGENT_COMMAND"]),
|
||||
agentDir: hasAgentDirEnvOverride(env, command),
|
||||
...(dirEnvSource === undefined ? {} : { agentDirSource: dirEnvSource }),
|
||||
agentSessionDir: hasAgentSessionDirEnvOverride(env, command),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { WebSocket } from "ws";
|
||||
import type { PiWebAgentConfig } from "../../shared/apiTypes.js";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js";
|
||||
import { mergeSelectedMachineConfig, parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, selectedMachineConfigResponse } from "../configRoutes.js";
|
||||
import { bridgeSockets } from "../webSocketBridge.js";
|
||||
@@ -75,7 +76,8 @@ async function proxySelectedMachineConfigRequest(client: MachineClient, machineI
|
||||
|
||||
const current = parsePiWebConfigResponseBody(currentResponse.body, "Remote machine config response");
|
||||
const merged = mergeSelectedMachineConfig(current.config, patch);
|
||||
return sendSelectedMachineConfigResponse(reply, await client.requestJson("PUT", remotePath, { config: merged }), machineId);
|
||||
const updateResponse = await client.requestJson("PUT", remotePath, { config: merged });
|
||||
return sendSelectedMachineConfigResponse(reply, updateResponse, machineId, patch.agent);
|
||||
}
|
||||
|
||||
return reply.code(405).send({ error: "Method not allowed" });
|
||||
@@ -85,11 +87,23 @@ function configPayload(body: unknown): unknown {
|
||||
return isRecord(body) ? body["config"] : undefined;
|
||||
}
|
||||
|
||||
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
|
||||
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string, expectedAgentProfile?: PiWebAgentConfig): FastifyReply {
|
||||
if (!isSuccessfulStatus(upstream.statusCode)) return sendUpstreamJsonResponse(reply, upstream, machineId);
|
||||
const response = parsePiWebConfigResponseBody(upstream.body, "Remote machine config response");
|
||||
if (expectedAgentProfile !== undefined && !sameAgentProfile(response.config.agent, expectedAgentProfile)) {
|
||||
return reply.code(409).send({
|
||||
error: "Remote machine did not persist the requested agent profile",
|
||||
machineId,
|
||||
detail: "Update and restart PI WEB on the remote machine before changing its agent profile.",
|
||||
});
|
||||
}
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
return reply.send(selectedMachineConfigResponse(parsePiWebConfigResponseBody(upstream.body, "Remote machine config response")));
|
||||
return reply.send(selectedMachineConfigResponse(response));
|
||||
}
|
||||
|
||||
function sameAgentProfile(actual: PiWebAgentConfig | undefined, expected: PiWebAgentConfig): boolean {
|
||||
return actual !== undefined && actual.command === expected.command && actual.dir === expected.dir;
|
||||
}
|
||||
|
||||
function sendUpstreamJsonResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
|
||||
|
||||
@@ -18,8 +18,8 @@ export function registerMachineRoutes(app: FastifyInstance, machines = new Machi
|
||||
return health;
|
||||
});
|
||||
|
||||
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
|
||||
const runtime = await machines.runtime(request.params.machineId);
|
||||
app.get<{ Params: { machineId: string }; Querystring: { refresh?: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
|
||||
const runtime = await machines.runtime(request.params.machineId, request.query.refresh === "1");
|
||||
if (runtime === undefined) return reply.code(404).send({ error: "Machine not found" });
|
||||
return runtime;
|
||||
});
|
||||
|
||||
@@ -171,6 +171,7 @@ describe("MachineService", () => {
|
||||
|
||||
const first = await remoteService.runtime(machine.id);
|
||||
const second = await remoteService.runtime(machine.id);
|
||||
const forced = await remoteService.runtime(machine.id, true);
|
||||
|
||||
expect(first).toEqual({
|
||||
machineId: machine.id,
|
||||
@@ -182,7 +183,8 @@ describe("MachineService", () => {
|
||||
capabilities: body.capabilities,
|
||||
});
|
||||
expect(second).toEqual(first);
|
||||
expect(requestJson).toHaveBeenCalledTimes(1);
|
||||
expect(forced).toEqual(first);
|
||||
expect(requestJson).toHaveBeenCalledTimes(2);
|
||||
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
|
||||
expect(factoryMachines).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -192,6 +194,7 @@ describe("MachineService", () => {
|
||||
token: "secret",
|
||||
headers: { "X-Pi-Web-Test": "yes" },
|
||||
}),
|
||||
expect.objectContaining({ id: machine.id }),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -93,10 +93,10 @@ export class MachineService {
|
||||
return health;
|
||||
}
|
||||
|
||||
async runtime(id: string): Promise<MachineRuntime | undefined> {
|
||||
async runtime(id: string, refresh = false): Promise<MachineRuntime | undefined> {
|
||||
const cached = this.runtimeCache.get(id);
|
||||
const now = this.now().getTime();
|
||||
if (cached !== undefined && cached.expiresAt > now) return cached.runtime;
|
||||
if (!refresh && cached !== undefined && cached.expiresAt > now) return cached.runtime;
|
||||
|
||||
const runtime = id === "local" ? await this.localRuntime() : await this.remoteRuntime(id);
|
||||
if (runtime === undefined) return undefined;
|
||||
|
||||
@@ -109,10 +109,11 @@ describe("PI WEB status", () => {
|
||||
|
||||
const runtime = await getPiWebRuntime(daemon);
|
||||
|
||||
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
||||
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]));
|
||||
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
||||
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
||||
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
||||
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.agentProfileConfig);
|
||||
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]));
|
||||
});
|
||||
|
||||
it("carries the daemon-owned active agent profile through the web runtime response", async () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<PiWebCapability, readonly PiWebServiceComponent[]>;
|
||||
|
||||
export function isPiWebCapability(value: unknown): value is PiWebCapability {
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user