From 4605a4f1d847fad62b8afe455a6aff989924b2e6 Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Thu, 25 Jun 2026 13:29:30 -0400 Subject: [PATCH 1/8] refactor: add session route service seam --- src/server/sessions/piSessionService.ts | 10 ++-- src/server/sessions/sessionRoutes.test.ts | 72 ++++++++++++++++------- src/server/sessions/sessionRoutes.ts | 10 ++-- src/server/sessions/sessionService.ts | 54 +++++++++++++++++ 4 files changed, 115 insertions(+), 31 deletions(-) create mode 100644 src/server/sessions/sessionService.ts diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 7a3d8dd..afe919f 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -13,7 +13,7 @@ import { type CreateAgentSessionRuntimeFactory, type EditToolDetails, } from "@earendil-works/pi-coding-agent"; -import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js"; +import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js"; import { pageMessagesAtSafeBoundary } from "./messagePaging.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import { BUILTIN_COMMANDS } from "./builtinCommands.js"; @@ -28,6 +28,7 @@ import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js"; import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js"; import { parsePromptAttachments } from "../../shared/promptAttachments.js"; import type { SavedPromptAttachment } from "../../shared/apiTypes.js"; +import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js"; import { cwdPathsEqual } from "../workingDirectory.js"; import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js"; @@ -115,9 +116,8 @@ function parsePromptStreamingBehavior(value: unknown): QueuedPromptKind | undefi type SessionArchiveRepository = Pick & { deleteArchived?: (sessionId: string) => Promise }; -export type PiSessionRef = ClientSessionRef; - -type PiSessionLookup = string | PiSessionRef; +export type PiSessionRef = SessionRouteRef; +type PiSessionLookup = SessionRouteLookup; export interface PiSessionListEntry { id: string; @@ -303,7 +303,7 @@ export interface PiSessionServiceDependencies { now?: () => Date; } -export class PiSessionService { +export class PiSessionService implements SessionRouteService { private readonly active = new Map>(); private readonly activities = new Map(); private readonly heartbeat: NodeJS.Timeout; diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts index 2f2e730..c1f0a26 100644 --- a/src/server/sessions/sessionRoutes.test.ts +++ b/src/server/sessions/sessionRoutes.test.ts @@ -4,7 +4,8 @@ import fastifyWebsocket from "@fastify/websocket"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js"; -import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js"; +import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js"; +import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js"; import { registerSessionRoutes } from "./sessionRoutes.js"; import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js"; @@ -39,7 +40,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); registerSessionRoutes(routeApp, routeService, eventHub); try { @@ -59,7 +60,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); registerSessionRoutes(routeApp, routeService, eventHub); const attachments = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }]; @@ -81,7 +82,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); registerSessionRoutes(routeApp, routeService, eventHub); try { @@ -104,7 +105,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); registerSessionRoutes(routeApp, routeService, eventHub); try { @@ -124,7 +125,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); routeService.reloadError = new Error("Stop current session activity before reloading"); registerSessionRoutes(routeApp, routeService, eventHub); @@ -143,7 +144,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); registerSessionRoutes(routeApp, routeService, eventHub); try { @@ -164,7 +165,7 @@ describe("session routes", () => { const routeApp = Fastify({ logger: false }); await routeApp.register(fastifyWebsocket); const eventHub = new SessionEventHub(); - const routeService = new CapturingRouteSessionService(eventHub); + const routeService = new CapturingRouteSessionService(); registerSessionRoutes(routeApp, routeService, eventHub); try { @@ -180,34 +181,38 @@ describe("session routes", () => { }); }); -class CapturingRouteSessionService extends PiSessionService { +class CapturingRouteSessionService implements SessionRouteService { readonly calls: unknown[] = []; - readonly reloadCalls: (string | PiSessionRef)[] = []; + readonly reloadCalls: SessionRouteLookup[] = []; readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = []; readonly cleanupCalls: NormalizedSessionCleanupRequest[] = []; reloadError: Error | undefined; - constructor(eventHub: SessionEventHub) { - super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 }); - } - - override cleanupPreview(request: NormalizedSessionCleanupRequest): Promise { + cleanupPreview(request: NormalizedSessionCleanupRequest): Promise { this.cleanupPreviewCalls.push(request); return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } }); } - override cleanup(request: NormalizedSessionCleanupRequest): Promise { + cleanup(request: NormalizedSessionCleanupRequest): Promise { this.cleanupCalls.push(request); return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: [], deletedSessionIds: [] }); } - override reload(lookup: string | PiSessionRef): Promise { + reload(lookup: SessionRouteLookup): Promise { this.reloadCalls.push(lookup); if (this.reloadError !== undefined) return Promise.reject(this.reloadError); return Promise.resolve(); } - override status(lookup: string | PiSessionRef) { + dispose(): Promise { + return Promise.resolve(); + } + + list(): never { throw unusedRouteMethod("list"); } + start(): never { throw unusedRouteMethod("start"); } + messages(): Promise { return Promise.resolve([]); } + + status(lookup: SessionRouteLookup) { this.calls.push(lookup); return Promise.resolve({ sessionId: sessionIdFromLookup(lookup), @@ -221,12 +226,20 @@ class CapturingRouteSessionService extends PiSessionService { }); } - override prompt(lookup: string | PiSessionRef, text: unknown, _streamingBehavior?: unknown, attachments?: unknown): Promise { + availableModels(): Promise<[]> { return Promise.resolve([]); } + setModel(): never { throw unusedRouteMethod("setModel"); } + cycleModel(): never { throw unusedRouteMethod("cycleModel"); } + availableThinkingLevels(): Promise<[]> { return Promise.resolve([]); } + setThinkingLevel(): never { throw unusedRouteMethod("setThinkingLevel"); } + cycleThinkingLevel(): never { throw unusedRouteMethod("cycleThinkingLevel"); } + commands(): Promise<[]> { return Promise.resolve([]); } + + prompt(lookup: SessionRouteLookup, text: unknown, _streamingBehavior?: unknown, attachments?: unknown): Promise { this.calls.push(attachments === undefined ? { lookup, text } : { lookup, text, attachments }); return Promise.resolve(); } - override saveAttachments(_lookup: string | PiSessionRef, attachments: unknown, folder?: string) { + saveAttachments(_lookup: SessionRouteLookup, attachments: unknown, folder?: string) { const list = Array.isArray(attachments) ? attachments : []; return Promise.resolve(list.map((attachment: { mimeType: string; data: string; name?: string }) => ({ path: `${folder ?? ".pi-web/attachments"}/${attachment.name ?? "file.png"}`, @@ -234,6 +247,19 @@ class CapturingRouteSessionService extends PiSessionService { size: Buffer.from(attachment.data, "base64").byteLength, }))); } + + shell(): never { throw unusedRouteMethod("shell"); } + runCommand(): never { throw unusedRouteMethod("runCommand"); } + respondToCommand(): never { throw unusedRouteMethod("respondToCommand"); } + abort(): never { throw unusedRouteMethod("abort"); } + stop(): never { throw unusedRouteMethod("stop"); } + archive(): never { throw unusedRouteMethod("archive"); } + archiveTree(): never { throw unusedRouteMethod("archiveTree"); } + restore(): never { throw unusedRouteMethod("restore"); } + deleteArchived(): never { throw unusedRouteMethod("deleteArchived"); } + + + detachParent(): never { throw unusedRouteMethod("detachParent"); } } class RejectingSessionManager implements PiSessionManagerGateway { @@ -260,6 +286,10 @@ class RejectingSessionManager implements PiSessionManagerGateway { } } -function sessionIdFromLookup(lookup: string | PiSessionRef): string { +function sessionIdFromLookup(lookup: SessionRouteLookup): string { return typeof lookup === "string" ? lookup : lookup.id; } + +function unusedRouteMethod(name: string): Error { + return new Error(`Route test did not expect ${name} to be called`); +} diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 9e0c8a2..1840e5d 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -2,10 +2,10 @@ import type { FastifyInstance } from "fastify"; import type { SessionCleanupRequest } from "../../shared/apiTypes.js"; import { normalizeRequestCwd } from "../workingDirectory.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; -import type { PiSessionRef, PiSessionService } from "./piSessionService.js"; +import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js"; import { normalizeSessionCleanupRequest } from "./sessionCleanup.js"; -type SessionLookup = string | PiSessionRef; +type SessionLookup = SessionRouteLookup; interface SessionQuery { cwd?: string; @@ -29,7 +29,7 @@ interface AttachmentsRequestBody { folder?: unknown; } -export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void { +export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRouteService, eventHub: SessionEventHub, prefix = ""): void { app.get<{ Querystring: SessionQuery }>(`${prefix}/sessions`, async (request, reply) => { if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); try { @@ -204,9 +204,9 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS } }); - app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/stop`, (request, reply) => { + app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/stop`, async (request, reply) => { try { - sessions.stop(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body))); + await sessions.stop(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body))); return { stopped: true }; } catch (error) { return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) }); diff --git a/src/server/sessions/sessionService.ts b/src/server/sessions/sessionService.ts new file mode 100644 index 0000000..2c7b933 --- /dev/null +++ b/src/server/sessions/sessionService.ts @@ -0,0 +1,54 @@ +import type { SavedPromptAttachment } from "../../shared/apiTypes.js"; +import type { + ClientArchiveSessionsResponse, + ClientCommand, + ClientCommandResult, + ClientMessagePage, + ClientSession, + ClientSessionCleanupExecuteResponse, + ClientSessionCleanupPreviewResponse, + ClientSessionModel, + ClientSessionRef, + ClientSessionStatus, + ClientThinkingLevel, +} from "../types.js"; +import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js"; + +export type SessionRouteRef = ClientSessionRef; +export type SessionRouteLookup = string | SessionRouteRef; + +/** + * Route-facing session contract for PI WEB's HTTP/WebSocket API. + * + * Keep this surface neutral: implementations may be backed by the native Pi SDK, + * an out-of-process agent bridge, or another daemon. Pi-specific lifecycle hooks + * such as auth-change handling and daemon shutdown stay on the concrete service. + */ +export interface SessionRouteService { + list(cwd: string): Promise; + start(cwd: string): Promise; + messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise; + status(ref: SessionRouteLookup): Promise; + availableModels(ref: SessionRouteLookup): Promise; + setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise; + cycleModel(ref: SessionRouteLookup, direction: "forward" | "backward"): Promise; + availableThinkingLevels(ref: SessionRouteLookup): Promise; + setThinkingLevel(ref: SessionRouteLookup, level: string): Promise; + cycleThinkingLevel(ref: SessionRouteLookup): Promise; + commands(ref: SessionRouteLookup): Promise; + prompt(ref: SessionRouteLookup, text: unknown, streamingBehavior?: unknown, attachments?: unknown): Promise; + saveAttachments(ref: SessionRouteLookup, attachments: unknown, folder?: string): Promise; + cleanupPreview(request: NormalizedSessionCleanupRequest): Promise; + cleanup(request: NormalizedSessionCleanupRequest): Promise; + shell(ref: SessionRouteLookup, text: string): Promise; + runCommand(ref: SessionRouteLookup, text: string): Promise; + respondToCommand(ref: SessionRouteLookup, requestId: string, value: string): Promise; + abort(ref: SessionRouteLookup): Promise; + stop(ref: SessionRouteLookup): void | Promise; + archive(ref: SessionRouteLookup): Promise; + archiveTree(ref: SessionRouteLookup): Promise; + restore(ref: SessionRouteLookup): Promise; + deleteArchived(ref: SessionRouteLookup): Promise; + reload(ref: SessionRouteLookup): Promise; + detachParent(ref: SessionRouteLookup): Promise; +} From 84a485d62e01502554733140be471d8dae0931f4 Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Fri, 26 Jun 2026 02:35:54 -0400 Subject: [PATCH 2/8] feat: add OMP runtime support --- .changeset/omp-agent-runtime.md | 5 + docs/config.html | 81 +++++++++++-- docs/config.md | 37 +++++- src/cli.test.ts | 41 ++++++- src/cli.ts | 26 ++-- src/client/src/api/parsers.test.ts | 12 +- src/client/src/api/parsers.ts | 21 +++- src/client/src/components/AuthDialog.ts | 4 +- .../settings/SettingsSessiondPanel.ts | 63 ++++++++++ .../settings/settingsConfigDraft.test.ts | 3 +- .../settings/settingsConfigDraft.ts | 1 + src/config.test.ts | 47 +++++++- src/config.ts | 114 +++++++++++++++++- src/server/app.test.ts | 2 +- src/server/app.ts | 8 +- src/server/configRoutes.test.ts | 6 +- src/server/configRoutes.ts | 40 +++++- src/server/piWebStatus.test.ts | 52 +++++++- src/server/piWebStatus.ts | 59 +++++---- src/server/sessiond.ts | 8 +- src/server/sessions/authService.test.ts | 27 ++++- src/server/sessions/authService.ts | 9 +- .../sessions/piSessionManagerGateway.test.ts | 25 ++++ .../sessions/piSessionManagerGateway.ts | 15 ++- src/server/sessions/piSessionService.ts | 4 +- src/shared/apiTypes.ts | 12 ++ 26 files changed, 639 insertions(+), 83 deletions(-) create mode 100644 .changeset/omp-agent-runtime.md diff --git a/.changeset/omp-agent-runtime.md b/.changeset/omp-agent-runtime.md new file mode 100644 index 0000000..c270f40 --- /dev/null +++ b/.changeset/omp-agent-runtime.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": minor +--- + +Add configurable Pi-compatible agent runtime settings so PI WEB can target Oh My Pi (`omp`) state, auth, sessions, diagnostics, and update checks. diff --git a/docs/config.html b/docs/config.html index 0062f9c..82d6f3a 100644 --- a/docs/config.html +++ b/docs/config.html @@ -81,7 +81,7 @@

PI WEB configuration covers the machine-local and project-local settings you usually need: bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, upload - limits, and session-daemon tools. + limits, agent runtime selection, and session-daemon tools.

@@ -96,6 +96,7 @@ Project config Config matrix External path access + Agent runtime Session tools Completion tools @@ -128,13 +129,16 @@

Environment overrides include PI_WEB_HOST, PI_WEB_PORT / PORT, - PI_WEB_ALLOWED_HOSTS, PI_WEB_MAX_UPLOAD_BYTES, PI_WEB_SPAWN_SESSIONS, - and PI_WEB_SUBSESSIONS. + PI_WEB_ALLOWED_HOSTS, PI_WEB_MAX_UPLOAD_BYTES, PI_WEB_AGENT_COMMAND, + PI_WEB_AGENT_DIR, PI_WEB_AGENT_SESSION_DIR, PI_CODING_AGENT_DIR, + PI_CODING_AGENT_SESSION_DIR, OMP_CODING_AGENT_DIR, + OMP_CODING_AGENT_SESSION_DIR, PI_WEB_SPAWN_SESSIONS, and + PI_WEB_SUBSESSIONS.

  • host / port: restart the web/API service or process.
  • maxUploadBytes: restart both the web/API process and the session daemon.
  • -
  • spawnSessions / subsessions: restart the session daemon.
  • +
  • agent.command / agent.dir / spawnSessions / subsessions: restart the session daemon.
  • pathAccess: applies on the next request; existing file views may need a browser refresh.
  • plugins: reload the browser tab after changing plugin enablement.
  • shortcuts: saved settings apply in the browser after config refresh/save.
  • @@ -160,6 +164,10 @@ "allowedPaths": ["~/SDKs", "/opt/reference"] }, "maxUploadBytes": 67108864, + "agent": { + "command": "omp", + "dir": "~/.omp/agent" + }, "spawnSessions": true, "subsessions": false, "plugins": { @@ -262,6 +270,22 @@ Not supported locally Restart web/API and session daemon + + Agent CLI command + agent.command + PI_WEB_AGENT_COMMAND + Global/session daemon + Not supported locally + Restart session daemon; affects doctor/status/update checks + + + Agent state directory + agent.dir + PI_WEB_AGENT_DIR, PI_CODING_AGENT_DIR, OMP_CODING_AGENT_DIR + Global/session daemon + Not supported locally + Restart session daemon; affects auth, models, settings, and sessions + Agent can spawn sessions spawnSessions @@ -368,18 +392,18 @@ Restart web/API; advanced state override - Pi session storage directory + Agent session storage directory — - PI_CODING_AGENT_SESSION_DIR - Pi/session daemon env + PI_WEB_AGENT_SESSION_DIR, PI_CODING_AGENT_SESSION_DIR, OMP_CODING_AGENT_SESSION_DIR + Session daemon env Not supported locally - Restart session daemon; follows Pi session priority + Restart session daemon; env-only session storage override - Pi agent config directory + Agent config directory — - PI_CODING_AGENT_DIR - Pi/Web/API/session daemon env + PI_WEB_AGENT_DIR, PI_CODING_AGENT_DIR, OMP_CODING_AGENT_DIR + Web/API + session daemon env Not supported locally Restart services @@ -420,6 +444,41 @@ +
    +

    Agent runtime

    +

    + agent.command controls which Pi-compatible CLI PI WEB checks in doctor/status/update flows. + It defaults to pi; set it to omp when this machine should use Oh My Pi. +

    +

    + agent.dir controls which compatible agent state directory PI WEB reads for auth providers, + model settings, settings, and session metadata. It defaults to the selected agent's conventional + directory (~/.pi/agent for pi, ~/.omp/agent for omp). +

    +
    +
    {
    +  "agent": {
    +    "command": "omp",
    +    "dir": "~/.omp/agent"
    +  }
    +}
    +
    +

    + Environment variables take precedence over the config file. PI_WEB_AGENT_COMMAND selects the + command, PI_WEB_AGENT_DIR sets the state directory for any command, and command-specific + variables such as OMP_CODING_AGENT_DIR are honored when the selected command is omp. +

    +

    + Session directory overrides are environment-only. Set PI_WEB_AGENT_SESSION_DIR or the selected + command's session variable (for example OMP_CODING_AGENT_SESSION_DIR) when you need to override + session storage separately from agent.dir. +

    +
    + Restart the session daemon after changing agent settings. The web/API process can display the new config + immediately, but active session runtime ownership is intentionally long-lived. +
    +
    +

    Session daemon tools

    spawnSessions

    diff --git a/docs/config.md b/docs/config.md index 2707a9c..fe9ba72 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,6 +1,6 @@ # PI WEB configuration reference -PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, manual upload defaults, upload limits, and session-daemon tools. +PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, manual upload defaults, upload limits, agent runtime selection, and session-daemon tools. This file is the markdown reference for agents and package consumers. The website page is . @@ -25,13 +25,13 @@ defaults → global config file → environment overrides Supported project-local settings are then applied for that project's workspaces. For upload defaults, `/.pi-web/config.json` overrides the global value. -Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`. +Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_AGENT_COMMAND`, `PI_WEB_AGENT_DIR`, `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `OMP_CODING_AGENT_DIR`, `OMP_CODING_AGENT_SESSION_DIR`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`. Process restarts depend on the key: - `host` / `port`: restart the web/API service or process. - `maxUploadBytes`: restart both the web/API process and the session daemon. -- `spawnSessions` / `subsessions`: restart the session daemon. +- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions`: restart the session daemon. - `pathAccess`: applies on the next request; existing file views may need a browser refresh. - `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh. - `plugins`: reload the browser tab after changing plugin enablement. @@ -50,6 +50,10 @@ Process restarts depend on the key: "defaultFolder": ".pi-web/uploads" }, "maxUploadBytes": 67108864, + "agent": { + "command": "omp", + "dir": "~/.omp/agent" + }, "spawnSessions": true, "subsessions": false, "plugins": { @@ -99,6 +103,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file | External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed | | Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh | | Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon | +| Agent CLI command | `agent.command` | `PI_WEB_AGENT_COMMAND` | Global/session daemon | Not supported locally | Restart session daemon; affects doctor/status/update checks | +| Agent state directory | `agent.dir` | `PI_WEB_AGENT_DIR`, `PI_CODING_AGENT_DIR`, `OMP_CODING_AGENT_DIR` | Global/session daemon | Not supported locally | Restart session daemon; affects auth, models, settings, and sessions | | Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon | | Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon | | Plugin enablement/settings | `plugins..enabled`, `plugins..settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab | @@ -113,8 +119,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file | Web-to-daemon URL | — | `PI_WEB_SESSIOND_URL` | Web/API env | Not supported locally | Restart web/API | | Projects storage file | — | `PI_WEB_PROJECTS_FILE` | Web/API + session daemon env | Not supported locally | Restart services; advanced state override | | Remote machines storage file | — | `PI_WEB_MACHINES_FILE` | Web/API env | Not supported locally | Restart web/API; advanced state override | -| Pi session storage directory | — | `PI_CODING_AGENT_SESSION_DIR` | Pi/session daemon env | Not supported locally | Restart session daemon; follows Pi session priority | -| Pi agent config directory | — | `PI_CODING_AGENT_DIR` | Pi/Web/API/session daemon env | Not supported locally | Restart services | +| Agent session storage directory | — | `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `OMP_CODING_AGENT_SESSION_DIR` | Session daemon env | Not supported locally | Restart session daemon; env-only session storage override | +| Agent config directory | — | `PI_WEB_AGENT_DIR`, `PI_CODING_AGENT_DIR`, `OMP_CODING_AGENT_DIR` | Web/API + session daemon env | Not supported locally | Restart services | | Skip update checks | — | `PI_WEB_SKIP_VERSION_CHECK`, `PI_WEB_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_OFFLINE` | Web/API env | Not supported locally | Restart web/API after env changes | ## Key details @@ -160,6 +166,27 @@ For machine federation, current remote PI WEB servers return `workspace.effectiv The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES`. +### Agent runtime selection + +`agent.command` controls which Pi-compatible CLI PI WEB checks in doctor/status/update flows. It defaults to `pi`; set it to `omp` when this machine should use Oh My Pi. + +`agent.dir` controls which compatible agent state directory PI WEB reads for auth providers, model settings, settings, and session metadata. It defaults to the selected agent's conventional directory (`~/.pi/agent` for `pi`, `~/.omp/agent` for `omp`). + +```json +{ + "agent": { + "command": "omp", + "dir": "~/.omp/agent" + } +} +``` + +Environment variables take precedence over the config file. `PI_WEB_AGENT_COMMAND` selects the command, `PI_WEB_AGENT_DIR` sets the state directory for any command, and command-specific variables such as `OMP_CODING_AGENT_DIR` are honored when the selected command is `omp`. + +Session directory overrides are environment-only. Set `PI_WEB_AGENT_SESSION_DIR` or the selected command's session variable (for example `OMP_CODING_AGENT_SESSION_DIR`) when you need to override session storage separately from `agent.dir`. + +Restart the session daemon after changing agent settings. The web/API process can display the new config immediately, but active session runtime ownership is intentionally long-lived. + ### Session daemon tools `spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions. diff --git a/src/cli.test.ts b/src/cli.test.ts index 72da8f8..065e55a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -2,9 +2,11 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { commandWithVersionCheck, isCliEntrypoint } from "./cli.js"; +import { agentCommandForChecks, commandWithVersionCheck, isCliEntrypoint } from "./cli.js"; const originalShell = process.env["SHELL"]; +const originalPiWebConfig = process.env["PI_WEB_CONFIG"]; +const originalPiWebAgentCommand = process.env["PI_WEB_AGENT_COMMAND"]; afterEach(() => { if (originalShell === undefined) { @@ -12,25 +14,56 @@ afterEach(() => { } else { process.env["SHELL"] = originalShell; } + if (originalPiWebConfig === undefined) { + delete process.env["PI_WEB_CONFIG"]; + } else { + process.env["PI_WEB_CONFIG"] = originalPiWebConfig; + } + if (originalPiWebAgentCommand === undefined) { + delete process.env["PI_WEB_AGENT_COMMAND"]; + } else { + process.env["PI_WEB_AGENT_COMMAND"] = originalPiWebAgentCommand; + } }); describe("commandWithVersionCheck", () => { it("emits a POSIX subshell group for bash", () => { process.env["SHELL"] = "/bin/bash"; - expect(commandWithVersionCheck("npm")).toBe("command -v npm && (npm --version 2>&1 || true)"); + expect(commandWithVersionCheck("npm")).toBe("command -v 'npm' && ('npm' --version 2>&1 || true)"); }); it("emits a POSIX subshell group for zsh", () => { process.env["SHELL"] = "/bin/zsh"; - expect(commandWithVersionCheck("pi")).toBe("command -v pi && (pi --version 2>&1 || true)"); + expect(commandWithVersionCheck("pi")).toBe("command -v 'pi' && ('pi' --version 2>&1 || true)"); }); it("uses fish begin/end grouping instead of a POSIX subshell", () => { process.env["SHELL"] = "/usr/local/bin/fish"; const command = commandWithVersionCheck("npm"); - expect(command).toBe("command -v npm && begin; npm --version 2>&1 || true; end"); + expect(command).toBe("command -v 'npm' && begin; 'npm' --version 2>&1 || true; end"); expect(command).not.toContain("("); }); + + it("shell-quotes command words", () => { + process.env["SHELL"] = "/bin/bash"; + expect(commandWithVersionCheck("/tmp/agent's/omp")).toBe("command -v '/tmp/agent'\\''s/omp' && ('/tmp/agent'\\''s/omp' --version 2>&1 || true)"); + }); +}); + +describe("agentCommandForChecks", () => { + it("reads the configured agent command for doctor checks", () => { + const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-")); + try { + const configPath = join(dir, "config.json"); + writeFileSync(configPath, `${JSON.stringify({ agent: { command: "omp" } })}\n`); + process.env["PI_WEB_CONFIG"] = configPath; + delete process.env["PI_WEB_AGENT_COMMAND"]; + + expect(agentCommandForChecks()).toBe("omp"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); describe("isCliEntrypoint", () => { diff --git a/src/cli.ts b/src/cli.ts index 00f1409..90fa419 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { homedir, userInfo } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { defaultPiWebConfigPath, defaultPiWebDataDir, examplePiWebConfig } from "./config.js"; +import { defaultPiWebConfigPath, defaultPiWebDataDir, effectiveAgentConfig, effectivePiWebConfig, examplePiWebConfig } from "./config.js"; import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js"; import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js"; @@ -178,7 +178,7 @@ function runQuiet(command: string, args: string[]): number { } function hasCommand(command: string): boolean { - return capture("/usr/bin/env", ["sh", "-c", `command -v ${command}`]).status === 0; + return capture("/usr/bin/env", ["sh", "-c", `command -v ${shellQuote(command)}`]).status === 0; } function isLingerEnabled(): boolean | undefined { @@ -898,16 +898,21 @@ function systemdUserServiceShellCommand(command: string, cwd?: string): string[] ]; } +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + function commandCheck(command: string): string { - return `command -v ${command}`; + return `command -v ${shellQuote(command)}`; } export function commandWithVersionCheck(command: string): string { const found = commandCheck(command); + const commandWord = shellQuote(command); if (detectServiceShell().name === "fish") { - return `${found} && begin; ${command} --version 2>&1 || true; end`; + return `${found} && begin; ${commandWord} --version 2>&1 || true; end`; } - return `${found} && (${command} --version 2>&1 || true)`; + return `${found} && (${commandWord} --version 2>&1 || true)`; } function nodeVersionCheck(): string { @@ -917,14 +922,19 @@ function nodeVersionCheck(): string { ].join(" && "); } +export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): string { + return effectiveAgentConfig(env, effectivePiWebConfig({ env }).config).command; +} + function doctorChecks(): Check[] { const shell = serviceShellLabel(); const backend = currentServiceBackend(); + const agentCommand = agentCommandForChecks(); if (backend === undefined) { return [ [`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())], [`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))], - [`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))], + [`${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))], ]; } @@ -932,12 +942,12 @@ function doctorChecks(): Check[] { ...backendAvailabilityChecks(backend), ...baseShellChecks(backend), [`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))], - [`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))], + [`${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))], ]; const executables = resolveServiceExecutables(backend); checks.push(...executables.web.checks, ...executables.sessiond.checks); if (backend.kind === "systemd") { - checks.push([`systemd user ${shell} can find pi`, systemdUserServiceShellCommand(commandWithVersionCheck("pi"))]); + checks.push([`systemd user ${shell} can find ${agentCommand}`, systemdUserServiceShellCommand(commandWithVersionCheck(agentCommand))]); } return checks; } diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 3d157e1..038d476 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -7,15 +7,15 @@ describe("API parsers", () => { expect(parsePiWebConfigResponse({ 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 }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } }, - envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + 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: "omp", dir: "~/.omp/agent" } }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "omp", dir: "/Users/dev/.omp/agent" } }, + envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, 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 }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } }, - envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + 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: "omp", dir: "~/.omp/agent" } }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "omp", dir: "/Users/dev/.omp/agent" } }, + envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false }, }); }); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 580db98..c64fc8d 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -531,11 +531,21 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues { ...optionalField("pathAccess", optionalPathAccess(record["pathAccess"])), ...optionalField("uploads", optionalUploads(record["uploads"])), ...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")), + ...optionalField("agent", optionalAgent(record["agent"])), ...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")), ...optionalField("subsessions", optionalBoolean(record, "subsessions")), }; } +function optionalAgent(value: unknown): PiWebConfigValues["agent"] | undefined { + if (value === undefined) return undefined; + if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB agent field"); + return { + ...optionalField("command", optionalString(value, "command")), + ...optionalField("dir", optionalString(value, "dir")), + }; +} + function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined { if (value === undefined) return undefined; if (value === true) return true; @@ -598,7 +608,16 @@ function optionalPlugins(value: unknown): PiWebPluginConfigMap | undefined { function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides { const record = requireRecord(value); - return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") }; + return { + host: requireBoolean(record, "host"), + port: requireBoolean(record, "port"), + allowedHosts: requireBoolean(record, "allowedHosts"), + spawnSessions: requireBoolean(record, "spawnSessions"), + subsessions: requireBoolean(record, "subsessions"), + agentCommand: optionalBoolean(record, "agentCommand") ?? false, + agentDir: optionalBoolean(record, "agentDir") ?? false, + agentSessionDir: optionalBoolean(record, "agentSessionDir") ?? false, + }; } export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse { diff --git a/src/client/src/components/AuthDialog.ts b/src/client/src/components/AuthDialog.ts index 28a5698..6cb0bbf 100644 --- a/src/client/src/components/AuthDialog.ts +++ b/src/client/src/components/AuthDialog.ts @@ -54,13 +54,13 @@ export class AuthDialog extends LitElement { case "method": return html`
    - +
    `; case "providers": return html`
    ${state.providers.length === 0 ? html`
    No providers available.
    ` : state.providers.map((provider) => this.renderProviderButton(provider))}
    `; case "apiKey": return html`
    -

    Enter the API key for ${state.provider.name}. It will be stored by pi in auth.json.

    +

    Enter the API key for ${state.provider.name}. It will be stored in the configured agent auth.json.

    { if (event.target instanceof HTMLInputElement) this.onApiKeyInput?.(event.target.value); }}> ${state.error !== undefined && state.error !== "" ? html`
    ${state.error}
    ` : null}
    diff --git a/src/client/src/components/settings/SettingsSessiondPanel.ts b/src/client/src/components/settings/SettingsSessiondPanel.ts index 9c1c9e1..98824c0 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.ts @@ -21,6 +21,9 @@ export class SettingsSessiondPanel extends LitElement { const subsessionsOverridden = config?.envOverrides.subsessions === true; // 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 effectiveAgent = config?.effectiveConfig.agent; return html`
    @@ -36,6 +39,40 @@ export class SettingsSessiondPanel extends LitElement { Config file ${config?.path ?? "Unknown"}
    +
    + + Agent command for diagnostics + ${agentCommandOverridden ? html`environment override` : null} + + { void this.saveAgentField("command", event); }} + > + Use omp to make doctor/update checks target Oh My Pi. The embedded session runtime remains PI WEB's SDK path, so this does not dynamically load a different agent implementation. +
    +
    + + Agent state directory + ${agentDirOverridden ? html`environment override` : null} + + { void this.saveAgentField("dir", event); }} + > + Choose which compatible auth, models, settings, and sessions PI WEB reads. For OMP, set this to ~/.omp/agent, then restart the session daemon. +
    Allow agents to start sessions @@ -72,6 +109,8 @@ export class SettingsSessiondPanel extends LitElement {

    Effective after environment overrides

    +
    Agent command
    ${effectiveAgent?.command ?? html`pi default`}
    +
    Agent state
    ${effectiveAgent?.dir ?? html`Pi default`}
    Spawn sessions
    ${effectiveSpawn ? "Enabled" : html`Disabled`}
    Subsessions
    ${effectiveSubsessions ? "Enabled" : html`Disabled`}
    @@ -86,6 +125,28 @@ export class SettingsSessiondPanel extends LitElement { return null; } + private async saveAgentField(field: "command" | "dir", event: Event): Promise { + if (!(event.target instanceof HTMLInputElement)) return; + const value = event.target.value.trim(); + const baseConfig = this.configResponse?.config ?? {}; + const nextConfig: PiWebConfigValues = { ...baseConfig }; + const nextAgent: NonNullable = { ...(baseConfig.agent ?? {}) }; + if (field === "command") { + if (value === "") delete nextAgent.command; + else nextAgent.command = value; + } else if (value === "") { + delete nextAgent.dir; + } else { + nextAgent.dir = value; + } + if (nextAgent.command === undefined && nextAgent.dir === undefined) { + delete nextConfig.agent; + } else { + nextConfig.agent = nextAgent; + } + await this.onSave?.(nextConfig); + } + private async toggleSpawnSessions(event: Event): Promise { const enabled = event.target instanceof HTMLInputElement && event.target.checked; const baseConfig = this.configResponse?.config ?? {}; @@ -124,6 +185,8 @@ export class SettingsSessiondPanel extends LitElement { .field-heading { display: flex; align-items: center; gap: 8px; } .toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; } .toggle input { width: 16px; height: 16px; } + .text-input { width: 100%; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + .text-input:disabled { opacity: .65; cursor: not-allowed; } .toggle input:disabled { cursor: not-allowed; } .override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; } .beta-badge { border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); background: var(--pi-bg); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } diff --git a/src/client/src/components/settings/settingsConfigDraft.test.ts b/src/client/src/components/settings/settingsConfigDraft.test.ts index 69ebb53..d177add 100644 --- a/src/client/src/components/settings/settingsConfigDraft.test.ts +++ b/src/client/src/components/settings/settingsConfigDraft.test.ts @@ -20,7 +20,7 @@ describe("settings config drafts", () => { allowedHostsMode: "list", allowedHostsText: "example.local, 192.168.1.20\n", allowedPathsText: "/tmp\n~/SDKs\n", - }, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 })).toEqual({ + }, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "omp", dir: "~/.omp/agent" } })).toEqual({ host: "127.0.0.1", port: 9000, allowedHosts: ["example.local", "192.168.1.20"], @@ -29,6 +29,7 @@ describe("settings config drafts", () => { pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, + agent: { command: "omp", dir: "~/.omp/agent" }, }); }); diff --git a/src/client/src/components/settings/settingsConfigDraft.ts b/src/client/src/components/settings/settingsConfigDraft.ts index 414ef16..4ce638e 100644 --- a/src/client/src/components/settings/settingsConfigDraft.ts +++ b/src/client/src/components/settings/settingsConfigDraft.ts @@ -30,6 +30,7 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue ...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }), ...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }), ...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }), + ...(baseConfig.agent === undefined ? {} : { agent: baseConfig.agent }), }; const host = draft.host.trim(); const port = draft.port.trim(); diff --git a/src/config.test.ts b/src/config.test.ts index c23c0c2..8cf115d 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, effectivePiWebConfig, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; +import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, effectiveAgentConfig, effectivePiWebConfig, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; let tempDir: string; let configPath: string; @@ -49,6 +49,51 @@ describe("PI WEB config persistence", () => { expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234); }); + it("persists and reads custom agent runtime settings", () => { + savePiWebConfig({ agent: { command: "omp", dir: "~/.omp/agent" } }, testOptions()); + + expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "omp", dir: "~/.omp/agent" }); + }); + + it("resolves OMP agent defaults from the configured command", () => { + expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "omp" } })).toMatchObject({ + command: "omp", + dir: join(tempDir, ".home", ".omp", "agent"), + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], + }); + }); + + it("lets PI WEB agent environment overrides take precedence", () => { + expect(effectiveAgentConfig({ + PI_WEB_AGENT_COMMAND: "omp", + PI_WEB_AGENT_DIR: join(tempDir, "env-agent"), + }, { agent: { command: "pi", dir: join(tempDir, "config-agent") } })).toMatchObject({ + command: "omp", + dir: join(tempDir, "env-agent"), + }); + }); + + it("lets command-specific agent environment directories override config", () => { + expect(effectiveAgentConfig({ + HOME: join(tempDir, ".home"), + OMP_CODING_AGENT_DIR: join(tempDir, "omp-env-agent"), + }, { agent: { command: "omp", dir: join(tempDir, "config-agent") } })).toMatchObject({ + command: "omp", + dir: join(tempDir, "omp-env-agent"), + }); + }); + + it("normalizes omp.exe to OMP environment keys", () => { + expect(effectiveAgentConfig({ + HOME: join(tempDir, ".home"), + OMP_CODING_AGENT_DIR: join(tempDir, "omp-exe-env-agent"), + }, { agent: { command: "omp.exe", dir: join(tempDir, "config-agent") } })).toMatchObject({ + command: "omp.exe", + dir: join(tempDir, "omp-exe-env-agent"), + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], + }); + }); + it("exposes the default upload folder in the effective config", () => { expect(effectivePiWebConfig(testOptions()).config.uploads).toEqual({ defaultFolder: DEFAULT_UPLOADS_FOLDER }); }); diff --git a/src/config.ts b/src/config.ts index b770fb1..8754c01 100644 --- a/src/config.ts +++ b/src/config.ts @@ -35,6 +35,42 @@ export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024; export const DEFAULT_UPLOADS_FOLDER = ".pi-web/uploads"; +export const DEFAULT_AGENT_COMMAND = "pi"; +export const PI_WEB_AGENT_COMMAND_ENV = "PI_WEB_AGENT_COMMAND"; +export const PI_WEB_AGENT_DIR_ENV = "PI_WEB_AGENT_DIR"; +export const PI_WEB_AGENT_SESSION_DIR_ENV = "PI_WEB_AGENT_SESSION_DIR"; +export const PI_CODING_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"; +export const PI_CODING_AGENT_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR"; + +export interface EffectivePiWebAgentConfig { + command: string; + dir: string; + sessionDirEnvKeys: string[]; +} + +export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick = {}, cwd = process.cwd()): EffectivePiWebAgentConfig { + const command = parseAgentCommand(env[PI_WEB_AGENT_COMMAND_ENV] ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment"); + const commandDirEnv = commandAgentDirEnv(command); + const configuredDir = env[PI_WEB_AGENT_DIR_ENV] ?? env[commandDirEnv] ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env); + return { + command, + dir: resolveAgentDirPath(configuredDir, env, cwd, "agent.dir", "environment"), + sessionDirEnvKeys: agentSessionDirEnvKeys(command), + }; +} + +export function agentSessionDirEnvKeys(command = DEFAULT_AGENT_COMMAND): string[] { + return uniqueStrings([PI_WEB_AGENT_SESSION_DIR_ENV, commandSessionDirEnv(command), PI_CODING_AGENT_SESSION_DIR_ENV]); +} + +export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { + return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || isEnvSet(env[commandAgentDirEnv(command)]); +} + +export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { + return agentSessionDirEnvKeys(command).some((key) => isEnvSet(env[key])); +} + export function effectiveUploadsConfig(config: Pick = {}): NonNullable { return { defaultFolder: config.uploads?.defaultFolder ?? DEFAULT_UPLOADS_FOLDER }; } @@ -79,7 +115,7 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf const port = env["PI_WEB_PORT"] ?? env["PORT"]; const allowedHosts = env["PI_WEB_ALLOWED_HOSTS"]; const maxUpload = env["PI_WEB_MAX_UPLOAD_BYTES"]; - + const agent = effectiveAgentConfig(env, loaded.config, options.cwd ?? process.cwd()); return { ...loaded, config: { @@ -94,6 +130,7 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf spawnSessions: spawnSessionsEnabled(env, loaded.config), // Beta capability, resolved off by default. subsessions: subsessionsEnabled(env, loaded.config), + agent: { command: agent.command, dir: agent.dir }, }, }; } @@ -113,6 +150,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): delete existing["maxUploadBytes"]; delete existing["spawnSessions"]; delete existing["subsessions"]; + delete existing["agent"]; const merged = { ...existing, ...piWebConfigRecord(normalized) }; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8"); @@ -138,6 +176,7 @@ function piWebConfigRecord(config: PiWebConfig): Record { ...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}), ...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}), ...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}), + ...(config.agent !== undefined ? { agent: config.agent } : {}), }; } @@ -153,6 +192,7 @@ function parsePiWebConfig(value: Record, path: string): PiWebCo ...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}), ...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}), ...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}), + ...(value["agent"] !== undefined ? { agent: parseAgentConfig(value["agent"], path) } : {}), }; } @@ -203,6 +243,35 @@ function parseString(value: unknown, key: string, path: string): string { return value; } +function parseAgentConfig(value: unknown, path: string): NonNullable { + if (!isRecord(value)) throw new Error(`PI WEB config agent must be an object: ${path}`); + const command = value["command"]; + const dir = value["dir"]; + return { + ...(command !== undefined ? { command: parseAgentCommand(command, "agent.command", path) } : {}), + ...(dir !== undefined ? { dir: parseAgentDir(dir, "agent.dir", path) } : {}), + }; +} + +function parseAgentCommand(value: unknown, key: string, path: string): string { + const command = parseString(value, key, path).trim(); + if (command === "") throw new Error(`PI WEB config ${key} must be a non-empty string: ${path}`); + if (/[\s;&|`$<>]/u.test(command)) throw new Error(`PI WEB config ${key} must be a single command name or path without shell metacharacters: ${path}`); + return command; +} + +function parseAgentDir(value: unknown, key: string, path: string): string { + const dir = parseString(value, key, path); + if (!isAbsoluteOrHomePath(dir)) throw new Error(`PI WEB config ${key} must be an absolute path or start with ~: ${path}`); + return dir; +} + +function resolveAgentDirPath(value: string, env: NodeJS.ProcessEnv, cwd: string, key: string, path: string): string { + const parsed = parseAgentDir(value, key, path); + const expanded = expandHomePath(parsed, env); + return isAbsoluteLike(expanded) ? expanded : resolve(cwd, expanded); +} + function parsePort(value: unknown, key: string, path = "environment"): number { const port = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN; if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`PI WEB config ${key} must be an integer from 1 to 65535: ${path}`); @@ -252,6 +321,49 @@ function parseWorkspaceRelativeFolder(value: unknown, key: string, path: string) return parts.join("/"); } + +function isAbsoluteOrHomePath(value: string): boolean { + return value === "~" || value.startsWith("~/") || value.startsWith("~\\") || isAbsoluteLike(value); +} + +function expandHomePath(value: string, env: NodeJS.ProcessEnv): string { + const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir(); + if (value === "~") return home; + if (value.startsWith("~/") || value.startsWith("~\\")) return join(home, value.slice(2)); + return value; +} + +function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string { + return expandHomePath(isOmpCommand(command) ? "~/.omp/agent" : "~/.pi/agent", env); +} + +function commandAgentDirEnv(command: string): string { + const prefix = agentEnvPrefix(command); + return prefix === "PI" ? PI_CODING_AGENT_DIR_ENV : `${prefix}_CODING_AGENT_DIR`; +} + +function commandSessionDirEnv(command: string): string { + return `${agentEnvPrefix(command)}_CODING_AGENT_SESSION_DIR`; +} + +function agentEnvPrefix(command: string): string { + const name = command.split(/[\\/]/u).at(-1) ?? command; + const normalized = name.replace(/(?:\.[cm]?js|\.exe)$/iu, "").replace(/[^A-Za-z0-9]+/gu, "_").replace(/^_+|_+$/gu, "").toUpperCase(); + return normalized === "" ? "PI" : normalized; +} + +function isOmpCommand(command: string): boolean { + const name = command.split(/[\\/]/u).at(-1)?.toLowerCase(); + return name === "omp" || name === "omp.exe"; +} + +function isEnvSet(value: string | undefined): boolean { + return value !== undefined && value !== ""; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} function isAbsoluteLike(value: string): boolean { const withForwardSlashes = value.replace(/\\/g, "/"); return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes); diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 955c5f5..808b6fb 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -901,7 +901,7 @@ function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: false, config, effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, }; } diff --git a/src/server/app.ts b/src/server/app.ts index c0bcf5d..9860f71 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -22,6 +22,7 @@ import { createFilePiWebConfigService, registerConfigRoutes, type PiWebConfigSer import { PiWebPluginService } from "./piWebPluginService.js"; import { createPiWebStatusCache } from "./piWebStatusCache.js"; import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; +import { effectiveAgentConfig, effectivePiWebConfig } from "../config.js"; import { MachineService } from "./machines/machineService.js"; import { registerMachineRoutes } from "./machines/machineRoutes.js"; import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js"; @@ -121,10 +122,11 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebStatus(sessionDaemon), { + const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir }), { onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); }, }); const machines = deps.machines ?? new MachineService(undefined, { @@ -142,7 +144,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise piWebStatusCache.get()); - app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon)); + app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir })); app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon)); app.get("/api/plugins", async () => piWebPlugins.plugins()); registerConfigRoutes(app, configService); diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index da3937f..264805f 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -37,11 +37,11 @@ describe("config routes", () => { const response = await app.inject({ method: "PUT", url: "/api/config", - payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } }, + payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, agent: { command: "omp", dir: "~/.omp/agent" }, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } }, }); expect(response.statusCode).toBe(200); - expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 }); + expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, agent: { command: "omp", dir: "~/.omp/agent" }, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 }); expect(response.json().config).toEqual(savedConfig); }); @@ -100,6 +100,6 @@ function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigRes exists, config, effectiveConfig: config, - envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, }; } diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index 1585efa..89ef3f4 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { effectivePiWebConfig, loadPiWebConfig, parseUploadsConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; +import { effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseUploadsConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; import { isPiWebPluginId } from "../shared/pluginIds.js"; @@ -27,7 +27,7 @@ export function currentPiWebConfigResponse(options: LoadOptions = {}): PiWebConf exists: loaded.exists, config: loaded.config, effectiveConfig: effective.config, - envOverrides: piWebConfigEnvOverrides(env), + envOverrides: piWebConfigEnvOverrides(env, loaded.config), }; } @@ -63,6 +63,7 @@ function parseConfigRequest(value: unknown): PiWebConfig { const maxUploadBytes = value["maxUploadBytes"]; const spawnSessions = value["spawnSessions"]; const subsessions = value["subsessions"]; + const agent = value["agent"]; if (host !== undefined) { if (typeof host !== "string") throw new Error("PI WEB config host must be a string"); config.host = host; @@ -85,6 +86,7 @@ function parseConfigRequest(value: unknown): PiWebConfig { if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean"); config.subsessions = subsessions; } + if (agent !== undefined) config.agent = parseAgentRequest(agent); return config; } @@ -128,6 +130,30 @@ function parseMaxUploadBytesRequest(value: unknown): number { return value; } +function parseAgentRequest(value: unknown): NonNullable { + if (!isRecord(value)) throw new Error("PI WEB config agent must be an object"); + const command = value["command"]; + const dir = value["dir"]; + return { + ...(command === undefined ? {} : { command: parseAgentCommandRequest(command) }), + ...(dir === undefined ? {} : { dir: parseAgentDirRequest(dir) }), + }; +} + +function parseAgentCommandRequest(value: unknown): string { + if (typeof value !== "string" || value.trim() === "") throw new Error("PI WEB config agent.command must be a non-empty string"); + const command = value.trim(); + if (/[\s;&|`$<>]/u.test(command)) throw new Error("PI WEB config agent.command must be a single command name or path without shell metacharacters"); + return command; +} + +function parseAgentDirRequest(value: unknown): string { + if (typeof value !== "string" || value.trim() === "") throw new Error("PI WEB config agent.dir must be a non-empty string"); + const dir = value.trim(); + if (!isAbsoluteOrHomePath(dir)) throw new Error("PI WEB config agent.dir must be an absolute path or start with ~"); + return dir; +} + function parsePluginsRequest(value: unknown): NonNullable { if (!isRecord(value) || Array.isArray(value)) throw new Error("PI WEB config plugins must be an object"); return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => { @@ -141,13 +167,17 @@ function parsePluginsRequest(value: unknown): NonNullable { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index 344395f..94bf1eb 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -2,9 +2,9 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { comparePackageVersions, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; +import { comparePackageVersions, getPiWebStatus, getPiWebVersionStatus, updateCommandFor } from "./piWebStatus.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; -import type { PiWebComponentStatus } from "../shared/apiTypes.js"; +import type { PiWebComponentStatus, PiWebRuntimeComponent } from "../shared/apiTypes.js"; const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"]; const originalHome = process.env["HOME"]; @@ -40,6 +40,26 @@ describe("PI WEB status", () => { expect(status).not.toHaveProperty("release"); }); + it("detects session daemon package installs from the configured agent dir for runtime responses", async () => { + const agentDir = await tempHome(); + try { + await installConfiguredPiWebPackage(agentDir); + const daemon = daemonWithRuntime({ + component: "sessiond", + label: "Session daemon", + runtimeVersion: "1.202605.7", + available: true, + capabilities: [], + }); + + const status = await getPiWebVersionStatus(daemon, { agentCommand: "omp", agentDir }); + + expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: process.cwd(), scope: "user" }); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } + }); + it("reports stale session daemon versions as messages", async () => { process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1"; const daemon = daemonWithComponent({ @@ -60,6 +80,19 @@ describe("PI WEB status", () => { expect(status.messages.map((message) => message.id)).toContain("sessiond-stale"); }); + it("shell-quotes pi-package agent update commands", async () => { + const updateCommand = await updateCommandFor( + { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, + "pi-web restart", + { + agentCommand: "/tmp/agent's/omp", + hasCommand: (command) => Promise.resolve(command === "/tmp/agent's/omp"), + }, + ); + + expect(updateCommand).toBe("'/tmp/agent'\\''s/omp' update 'npm:@jmfederico/pi-web' && pi-web restart"); + }); + it("suggests native systemd commands for local development services", async () => { if (process.platform !== "linux") return; process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1"; @@ -109,6 +142,16 @@ function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClie return daemon; } +function daemonWithRuntime(component: PiWebRuntimeComponent): SessionDaemonClient { + const daemon = new SessionDaemonClient(); + vi.spyOn(daemon, "request").mockResolvedValue({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify(component), + }); + return daemon; +} + function staleLocalSessiond(): PiWebComponentStatus { return { component: "sessiond", @@ -131,6 +174,11 @@ async function installSystemdServiceFiles(home: string, names: string[]): Promis await Promise.all(names.map((name) => writeFile(join(dir, name), ""))); } +async function installConfiguredPiWebPackage(agentDir: string): Promise { + await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8"); +} + + function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) Reflect.deleteProperty(process.env, key); else process.env[key] = value; diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts index a7d4dd4..7aa3187 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -5,11 +5,12 @@ import { promisify } from "node:util"; import { homedir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; -import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; +import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent"; import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js"; import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js"; import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; +import { effectiveAgentConfig } from "../config.js"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`; @@ -73,6 +74,22 @@ interface PiWebStatusDaemon { request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }>; } +interface PiWebStatusOptions { + agentCommand?: string; + agentDir?: string; + hasCommand?: (command: string) => Promise; +} + +function effectiveStatusAgentConfig(options: PiWebStatusOptions): { command: string; dir: string } { + const agent = effectiveAgentConfig(process.env, { + agent: { + ...(options.agentCommand === undefined ? {} : { command: options.agentCommand }), + ...(options.agentDir === undefined ? {} : { dir: options.agentDir }), + }, + }); + return { command: agent.command, dir: agent.dir }; +} + let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined; const runtimePackageInfo = readPackageInfoSync(); @@ -98,10 +115,10 @@ export async function getPiWebRuntime(daemon: PiWebStatusDaemon = new SessionDae }; } -export async function getPiWebComponentStatus(component: PiWebServiceComponent): Promise { +export async function getPiWebComponentStatus(component: PiWebServiceComponent, options: PiWebStatusOptions = {}): Promise { const [installed, installation] = await Promise.all([ readInstalledPackageInfo(), - detectPiWebInstallation(), + detectPiWebInstallation(options.agentDir), ]); const runtimeVersion = runtimePackageInfo?.version ?? DEFAULT_VERSION; const installedVersion = installed?.version; @@ -116,10 +133,10 @@ export async function getPiWebComponentStatus(component: PiWebServiceComponent): }; } -export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise { +export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise { const [web, sessiond] = await Promise.all([ - getPiWebComponentStatus("web"), - getSessiondComponentStatus(daemon), + getPiWebComponentStatus("web", options), + getSessiondComponentStatus(daemon, options), ]); return { packageName: PI_WEB_PACKAGE_NAME, @@ -128,12 +145,13 @@ export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new Sess }; } -export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise { - const versionStatus = await getPiWebVersionStatus(daemon); +export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise { + const agent = effectiveStatusAgentConfig(options); + const versionStatus = await getPiWebVersionStatus(daemon, { ...options, agentDir: agent.dir }); const { web, sessiond } = versionStatus.components; const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION); const components = { web, sessiond }; - const commands = await commandsFor(components); + const commands = await commandsFor(components, { agentCommand: agent.command, hasCommand: options.hasCommand ?? hasCommand }); const messages = buildMessages(components, release, commands); return { ...versionStatus, @@ -187,19 +205,18 @@ function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined return { name, version, path }; } -async function detectPiWebInstallation(): Promise { +async function detectPiWebInstallation(agentDir = effectiveAgentConfig().dir): Promise { const root = packageRootPath(); const realRoot = await realPathOrSelf(root); - const piPackage = await detectPiPackageInstallation(realRoot, root); + const piPackage = await detectPiPackageInstallation(realRoot, root, agentDir); if (piPackage !== undefined) return piPackage; const npmGlobal = await detectNpmGlobalInstallation(realRoot, root); if (npmGlobal !== undefined) return npmGlobal; return { kind: "local", path: root }; } -async function detectPiPackageInstallation(realRoot: string, displayPath: string): Promise { +async function detectPiPackageInstallation(realRoot: string, displayPath: string, agentDir: string): Promise { try { - const agentDir = getAgentDir(); const packageManager = new DefaultPackageManager({ cwd: process.cwd(), agentDir, @@ -267,7 +284,7 @@ async function getSessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise

    { +async function getSessiondComponentStatus(daemon: PiWebStatusDaemon, options: PiWebStatusOptions = {}): Promise { try { const upstream = await daemon.request("GET", "/runtime"); if (upstream.statusCode < 200 || upstream.statusCode >= 300) { @@ -278,7 +295,7 @@ async function getSessiondComponentStatus(daemon: PiWebStatusDaemon): Promise { return version; } -async function commandsFor(components: PiWebStatusResponse["components"]): Promise { +async function commandsFor(components: PiWebStatusResponse["components"], options: { agentCommand: string; hasCommand: (command: string) => Promise }): Promise { const installation = preferredInstallation(components); const [serviceCommands, cliCommands] = await Promise.all([ nativeServiceCommands(), @@ -385,7 +402,7 @@ async function commandsFor(components: PiWebStatusResponse["components"]): Promi const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart; const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart; const status = serviceCommands.status ?? cliCommands.status; - const update = await updateCommandFor(installation, restart); + const update = await updateCommandFor(installation, restart, options); return { ...(update === undefined ? {} : { update }), @@ -413,11 +430,11 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv return cliCommands.restart ?? serviceCommands.restart; } -async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): Promise { +export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { agentCommand: string; hasCommand: (command: string) => Promise }): Promise { if (restartCommand === undefined) return undefined; if (installation?.kind === "pi-package") { - if (!(await hasCommand("pi"))) return undefined; - return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommand}`; + if (!(await options.hasCommand(options.agentCommand))) return undefined; + return `${shellQuote(options.agentCommand)} update ${shellQuote(installation.source ?? PI_WEB_NPM_SOURCE)} && ${restartCommand}`; } if (installation?.kind === "local" && installation.path !== undefined) { if (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined; @@ -497,7 +514,7 @@ async function isGitCheckoutWithUpstream(path: string): Promise { } function hasCommand(command: string): Promise { - return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${command}`]); + return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${shellQuote(command)}`]); } async function commandSucceeds(command: string, args: string[]): Promise { diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index f4cc36e..579eed2 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -9,6 +9,7 @@ import { SessionEventHub } from "./realtime/sessionEventHub.js"; import { AuthService } from "./sessions/authService.js"; import { registerAuthRoutes } from "./sessions/authRoutes.js"; import { PiSessionService } from "./sessions/piSessionService.js"; +import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js"; import { registerSessionRoutes } from "./sessions/sessionRoutes.js"; import { ProjectScopedSpawnTargetResolver } from "./sessions/spawnTargetResolver.js"; import { ProjectService } from "./projects/projectService.js"; @@ -19,24 +20,27 @@ import { TerminalService } from "./terminals/terminalService.js"; import { registerTerminalRoutes } from "./terminals/terminalRoutes.js"; import { getPiWebRuntimeComponent } from "./piWebStatus.js"; import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js"; -import { effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js"; +import { effectiveAgentConfig, effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js"; const { config } = effectivePiWebConfig(); +const agent = effectiveAgentConfig(process.env, config); const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(process.env, config) }); await app.register(fastifyWebsocket); const eventHub = new SessionEventHub(); const workspaceActivity = new WorkspaceActivityService(eventHub); -const auth = new AuthService(); +const auth = new AuthService({ agentDir: agent.dir }); const spawnTargets = spawnSessionsEnabled(process.env, config) ? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() }) : undefined; const sessions = new PiSessionService(eventHub, { modelRegistry: auth.modelRegistry, + agentDir: agent.dir, workspaceActivity, logger: app.log, ...(spawnTargets === undefined ? {} : { spawnTargets }), subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config), + sessionManager: createPiSessionManagerGateway({ agentDir: agent.dir, sessionDirEnvKeys: agent.sessionDirEnvKeys }), }); auth.subscribe((change) => { sessions.applyAuthChange(change); }); const terminals = new TerminalService(eventHub, workspaceActivity); diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index 3efe1b3..04c3e4b 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -1,7 +1,16 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { AuthService, type AuthChange } from "./authService.js"; +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + describe("AuthService", () => { it("saves API keys and emits a global auth change", () => { const { auth, authStorage, changes } = createAuthService(); @@ -30,6 +39,16 @@ describe("AuthService", () => { expect(changes).toEqual([]); auth.dispose(); }); + + it("stores credentials in the configured agent directory", async () => { + const agentDir = await tempAgentDir(); + const auth = new AuthService({ agentDir }); + + auth.saveApiKey("anthropic", "sk-omp"); + + await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-omp"); + auth.dispose(); + }); }); function createAuthService(data: Parameters[0] = {}) { @@ -40,3 +59,9 @@ function createAuthService(data: Parameters[0] = {} auth.subscribe((change) => { changes.push(change); }); return { auth, authStorage, changes }; } + +async function tempAgentDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), "pi-web-auth-agent-")); + tempDirs.push(dir); + return dir; +} diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts index c884af9..3cb1305 100644 --- a/src/server/sessions/authService.ts +++ b/src/server/sessions/authService.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js"; import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js"; @@ -11,17 +12,23 @@ type AuthChangeListener = (change: AuthChange) => void; type ModelRegistryInstance = ReturnType; export interface AuthServiceDependencies { + agentDir?: string; modelRegistry?: ModelRegistryInstance; authFlows?: OAuthLoginFlowService; } +export function createModelRegistryForAgentDir(agentDir: string): ModelRegistryInstance { + const authStorage = AuthStorage.create(join(agentDir, "auth.json")); + return ModelRegistry.create(authStorage, join(agentDir, "models.json")); +} + export class AuthService { readonly modelRegistry: ModelRegistryInstance; private readonly authFlows: OAuthLoginFlowService; private readonly listeners = new Set(); constructor(deps: AuthServiceDependencies = {}) { - this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create()); + this.modelRegistry = deps.modelRegistry ?? (deps.agentDir === undefined ? ModelRegistry.create(AuthStorage.create()) : createModelRegistryForAgentDir(deps.agentDir)); this.authFlows = deps.authFlows ?? new OAuthLoginFlowService(); } diff --git a/src/server/sessions/piSessionManagerGateway.test.ts b/src/server/sessions/piSessionManagerGateway.test.ts index d787ae6..eda4d73 100644 --- a/src/server/sessions/piSessionManagerGateway.test.ts +++ b/src/server/sessions/piSessionManagerGateway.test.ts @@ -59,6 +59,16 @@ describe("SessionDirResolver", () => { expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true }); }); + + it("uses OMP sessionDir environment overrides before settings", async () => { + const envDir = join(tempDir, "omp-env-sessions"); + await mkdir(agentDir, { recursive: true }); + await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8"); + + const resolver = new SessionDirResolver({ agentDir, env: { OMP_CODING_AGENT_SESSION_DIR: envDir }, sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"] }); + + expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true }); + }); }); describe("Pi session manager gateway", () => { @@ -82,6 +92,21 @@ describe("Pi session manager gateway", () => { await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })])); }); + it("includes command-specific env session directories in global listing", async () => { + for (const envKey of ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR"]) { + const envSessionDir = join(tempDir, `${envKey.toLowerCase()}-sessions`); + await writeSessionFile(envSessionDir, `${envKey.toLowerCase()}-session`, cwd); + const gateway = createPiSessionManagerGateway({ + agentDir, + env: { [envKey]: envSessionDir }, + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], + }); + + if (gateway.listAll === undefined) throw new Error("Expected legacy listing support"); + await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: `${envKey.toLowerCase()}-session`, cwd })])); + } + }); + it("lists only sessions for the requested cwd when a custom Pi sessionDir is shared", async () => { const sharedSessionDir = join(tempDir, "shared-sessions"); const otherCwd = join(tempDir, "other-workspace"); diff --git a/src/server/sessions/piSessionManagerGateway.ts b/src/server/sessions/piSessionManagerGateway.ts index 57a2446..7b06eaf 100644 --- a/src/server/sessions/piSessionManagerGateway.ts +++ b/src/server/sessions/piSessionManagerGateway.ts @@ -19,15 +19,18 @@ export interface SessionDirResolution { export interface SessionDirResolverOptions { agentDir?: string; env?: NodeJS.ProcessEnv; + sessionDirEnvKeys?: readonly string[]; } export class SessionDirResolver { private readonly agentDir: string; private readonly env: NodeJS.ProcessEnv; + private readonly sessionDirEnvKeys: readonly string[]; constructor(options: SessionDirResolverOptions = {}) { this.agentDir = options.agentDir ?? getAgentDir(); this.env = options.env ?? process.env; + this.sessionDirEnvKeys = options.sessionDirEnvKeys ?? [PI_SESSION_DIR_ENV]; } defaultSessionsRoot(): string { @@ -35,15 +38,15 @@ export class SessionDirResolver { } globalEnvSessionDir(): string | undefined { - const envSessionDir = this.env[PI_SESSION_DIR_ENV]; - if (envSessionDir === undefined || envSessionDir === "") return undefined; + const envSessionDir = this.envSessionDir(); + if (envSessionDir === undefined) return undefined; const expanded = expandTildePath(envSessionDir); return isAbsolute(expanded) ? expanded : undefined; } resolve(cwd: string): SessionDirResolution { - const envSessionDir = this.env[PI_SESSION_DIR_ENV]; - if (envSessionDir !== undefined && envSessionDir !== "") { + const envSessionDir = this.envSessionDir(); + if (envSessionDir !== undefined) { return { source: "env", sessionDir: resolveConfiguredPath(envSessionDir, cwd), usesConfiguredSessionDir: true }; } @@ -54,6 +57,10 @@ export class SessionDirResolver { return { source: "pi-default", sessionDir: defaultPiSessionDir(cwd, this.agentDir), usesConfiguredSessionDir: false }; } + + private envSessionDir(): string | undefined { + return this.sessionDirEnvKeys.map((key) => this.env[key]).find((value) => value !== undefined && value !== ""); + } } export type PiSessionManagerGatewayOptions = SessionDirResolverOptions; diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index afe919f..bf0d12a 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -21,7 +21,7 @@ import { SessionCommandService } from "./sessionCommandService.js"; import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.js"; import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js"; import type { ActiveSession } from "./sessionRuntimeStore.js"; -import type { AuthChange } from "./authService.js"; +import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js"; import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; import { computeEditPreview, type EditPreviewResult } from "./editPreview.js"; import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js"; @@ -340,7 +340,7 @@ export class PiSessionService implements SessionRouteService { this.archiveStore = deps.archiveStore ?? new SessionArchiveStore(); this.agentDir = deps.agentDir ?? getAgentDir(); this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir }); - this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create()); + this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir); this.spawnTargets = deps.spawnTargets; this.logger = deps.logger ?? noopLogger; this.now = deps.now ?? (() => new Date()); diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index cf028dc..b225908 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -61,6 +61,13 @@ export interface PiWebUploadsConfig { defaultFolder?: string; } +export interface PiWebAgentConfig { + /** Agent CLI command used for diagnostics and package-managed updates. */ + command?: string; + /** Agent config/state directory containing auth.json, models.json, settings.json, and sessions/. */ + dir?: string; +} + export interface PiWebConfigValues { host?: string; port?: number; @@ -82,6 +89,8 @@ export interface PiWebConfigValues { * while the capability stabilizes. Requires spawnSessions to be enabled. */ subsessions?: boolean; + /** Agent runtime state used by the session daemon (Pi by default; OMP compatible). */ + agent?: PiWebAgentConfig; } export type PiWebPluginScope = "bundled" | "local" | "user" | "project"; @@ -105,6 +114,9 @@ export interface PiWebConfigEnvOverrides { allowedHosts: boolean; spawnSessions: boolean; subsessions: boolean; + agentCommand: boolean; + agentDir: boolean; + agentSessionDir: boolean; } export interface PiWebConfigResponse { From 75e2377756c7cb54dfba43633331ca0c326a3c8f Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 28 Jun 2026 16:36:40 +0200 Subject: [PATCH 3/8] feat: generalize agent runtime config --- .changeset/generic-agent-runtime-config.md | 5 ++ .changeset/omp-agent-runtime.md | 5 -- docs/config.html | 44 ++++++------ docs/config.md | 28 ++++---- src/client/src/api/parsers.test.ts | 8 +-- .../settings/SettingsSessiondPanel.ts | 6 +- .../settings/settingsConfigDraft.test.ts | 4 +- src/config.test.ts | 68 ++++++++++++------- src/config.ts | 49 +++++-------- src/server/app.test.ts | 41 ++++++++++- src/server/app.ts | 42 ++++++++++-- src/server/configRoutes.test.ts | 4 +- src/server/configRoutes.ts | 13 ++-- src/server/piWebPluginService.test.ts | 21 ++++++ src/server/piWebPluginService.ts | 60 ++++++++++++---- src/server/piWebStatus.test.ts | 8 +-- src/server/piWebStatusCache.ts | 4 ++ src/server/sessions/authService.test.ts | 4 +- .../sessions/piSessionManagerGateway.test.ts | 11 ++- .../sessions/piSessionManagerGateway.ts | 13 ++-- src/server/sessions/piSessionService.ts | 4 +- src/shared/apiTypes.ts | 2 +- 22 files changed, 289 insertions(+), 155 deletions(-) create mode 100644 .changeset/generic-agent-runtime-config.md delete mode 100644 .changeset/omp-agent-runtime.md diff --git a/.changeset/generic-agent-runtime-config.md b/.changeset/generic-agent-runtime-config.md new file mode 100644 index 0000000..359a42d --- /dev/null +++ b/.changeset/generic-agent-runtime-config.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add configurable agent runtime settings so PI WEB can use alternate Pi-compatible commands and isolated agent state/session directories, with web-side status and plugin views re-reading saved config. diff --git a/.changeset/omp-agent-runtime.md b/.changeset/omp-agent-runtime.md deleted file mode 100644 index c270f40..0000000 --- a/.changeset/omp-agent-runtime.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": minor ---- - -Add configurable Pi-compatible agent runtime settings so PI WEB can target Oh My Pi (`omp`) state, auth, sessions, diagnostics, and update checks. diff --git a/docs/config.html b/docs/config.html index 82d6f3a..5d89c2d 100644 --- a/docs/config.html +++ b/docs/config.html @@ -131,8 +131,7 @@ Environment overrides include PI_WEB_HOST, PI_WEB_PORT / PORT, PI_WEB_ALLOWED_HOSTS, PI_WEB_MAX_UPLOAD_BYTES, PI_WEB_AGENT_COMMAND, PI_WEB_AGENT_DIR, PI_WEB_AGENT_SESSION_DIR, PI_CODING_AGENT_DIR, - PI_CODING_AGENT_SESSION_DIR, OMP_CODING_AGENT_DIR, - OMP_CODING_AGENT_SESSION_DIR, PI_WEB_SPAWN_SESSIONS, and + PI_CODING_AGENT_SESSION_DIR, PI_WEB_SPAWN_SESSIONS, and PI_WEB_SUBSESSIONS.

      @@ -165,8 +164,8 @@ }, "maxUploadBytes": 67108864, "agent": { - "command": "omp", - "dir": "~/.omp/agent" + "command": "pi", + "dir": "~/agent-profiles/research" }, "spawnSessions": true, "subsessions": false, @@ -281,7 +280,7 @@ Agent state directory agent.dir - PI_WEB_AGENT_DIR, PI_CODING_AGENT_DIR, OMP_CODING_AGENT_DIR + PI_WEB_AGENT_DIR, PI_CODING_AGENT_DIR Global/session daemon Not supported locally Restart session daemon; affects auth, models, settings, and sessions @@ -394,7 +393,7 @@ Agent session storage directory — - PI_WEB_AGENT_SESSION_DIR, PI_CODING_AGENT_SESSION_DIR, OMP_CODING_AGENT_SESSION_DIR + PI_WEB_AGENT_SESSION_DIR, PI_CODING_AGENT_SESSION_DIR Session daemon env Not supported locally Restart session daemon; env-only session storage override @@ -402,7 +401,7 @@ Agent config directory — - PI_WEB_AGENT_DIR, PI_CODING_AGENT_DIR, OMP_CODING_AGENT_DIR + PI_WEB_AGENT_DIR, PI_CODING_AGENT_DIR Web/API + session daemon env Not supported locally Restart services @@ -448,34 +447,41 @@

      Agent runtime

      agent.command controls which Pi-compatible CLI PI WEB checks in doctor/status/update flows. - It defaults to pi; set it to omp when this machine should use Oh My Pi. + It defaults to pi. Set it only when diagnostics and package-managed update checks should target + another compatible command; the embedded session runtime still uses PI WEB's SDK integration.

      agent.dir controls which compatible agent state directory PI WEB reads for auth providers, - model settings, settings, and session metadata. It defaults to the selected agent's conventional - directory (~/.pi/agent for pi, ~/.omp/agent for omp). + model settings, settings, and session metadata. It defaults to ~/.pi/agent. Set it to another + Pi-compatible state directory when you want an isolated profile or an alternate compatible agent's data.

      {
         "agent": {
      -    "command": "omp",
      -    "dir": "~/.omp/agent"
      +    "command": "pi",
      +    "dir": "~/agent-profiles/research"
         }
       }

      - Environment variables take precedence over the config file. PI_WEB_AGENT_COMMAND selects the - command, PI_WEB_AGENT_DIR sets the state directory for any command, and command-specific - variables such as OMP_CODING_AGENT_DIR are honored when the selected command is omp. + For example, an Oh My Pi profile can set agent.command to omp and + agent.dir to ~/.omp/agent.

      - Session directory overrides are environment-only. Set PI_WEB_AGENT_SESSION_DIR or the selected - command's session variable (for example OMP_CODING_AGENT_SESSION_DIR) when you need to override - session storage separately from agent.dir. + Environment variables take precedence over the config file. PI_WEB_AGENT_COMMAND selects the + command, PI_WEB_AGENT_DIR sets the state directory for any command, and + PI_WEB_AGENT_SESSION_DIR overrides session storage separately from agent.dir. + Existing Pi Coding Agent env names (PI_CODING_AGENT_DIR and + PI_CODING_AGENT_SESSION_DIR) remain supported for compatibility. +

      +

      + Session directory overrides are environment-only; use PI_WEB_AGENT_SESSION_DIR unless you need + the legacy Pi-compatible PI_CODING_AGENT_SESSION_DIR name.

      Restart the session daemon after changing agent settings. The web/API process can display the new config - immediately, but active session runtime ownership is intentionally long-lived. + immediately, and status/plugin discovery may re-read it on later requests, but active session runtime + ownership is intentionally long-lived.
    diff --git a/docs/config.md b/docs/config.md index fe9ba72..22429fc 100644 --- a/docs/config.md +++ b/docs/config.md @@ -25,7 +25,7 @@ defaults → global config file → environment overrides Supported project-local settings are then applied for that project's workspaces. For upload defaults, `/.pi-web/config.json` overrides the global value. -Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_AGENT_COMMAND`, `PI_WEB_AGENT_DIR`, `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `OMP_CODING_AGENT_DIR`, `OMP_CODING_AGENT_SESSION_DIR`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`. +Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_AGENT_COMMAND`, `PI_WEB_AGENT_DIR`, `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`. Process restarts depend on the key: @@ -51,8 +51,8 @@ Process restarts depend on the key: }, "maxUploadBytes": 67108864, "agent": { - "command": "omp", - "dir": "~/.omp/agent" + "command": "pi", + "dir": "~/agent-profiles/research" }, "spawnSessions": true, "subsessions": false, @@ -104,7 +104,7 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file | Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh | | Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon | | Agent CLI command | `agent.command` | `PI_WEB_AGENT_COMMAND` | Global/session daemon | Not supported locally | Restart session daemon; affects doctor/status/update checks | -| Agent state directory | `agent.dir` | `PI_WEB_AGENT_DIR`, `PI_CODING_AGENT_DIR`, `OMP_CODING_AGENT_DIR` | Global/session daemon | Not supported locally | Restart session daemon; affects auth, models, settings, and sessions | +| Agent state directory | `agent.dir` | `PI_WEB_AGENT_DIR`, `PI_CODING_AGENT_DIR` | Global/session daemon | Not supported locally | Restart session daemon; affects auth, models, settings, and sessions | | Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon | | Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon | | Plugin enablement/settings | `plugins..enabled`, `plugins..settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab | @@ -119,8 +119,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file | Web-to-daemon URL | — | `PI_WEB_SESSIOND_URL` | Web/API env | Not supported locally | Restart web/API | | Projects storage file | — | `PI_WEB_PROJECTS_FILE` | Web/API + session daemon env | Not supported locally | Restart services; advanced state override | | Remote machines storage file | — | `PI_WEB_MACHINES_FILE` | Web/API env | Not supported locally | Restart web/API; advanced state override | -| Agent session storage directory | — | `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `OMP_CODING_AGENT_SESSION_DIR` | Session daemon env | Not supported locally | Restart session daemon; env-only session storage override | -| Agent config directory | — | `PI_WEB_AGENT_DIR`, `PI_CODING_AGENT_DIR`, `OMP_CODING_AGENT_DIR` | Web/API + session daemon env | Not supported locally | Restart services | +| Agent session storage directory | — | `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_SESSION_DIR` | Session daemon env | Not supported locally | Restart session daemon; env-only session storage override | +| Agent config directory | — | `PI_WEB_AGENT_DIR`, `PI_CODING_AGENT_DIR` | Web/API + session daemon env | Not supported locally | Restart services | | Skip update checks | — | `PI_WEB_SKIP_VERSION_CHECK`, `PI_WEB_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_OFFLINE` | Web/API env | Not supported locally | Restart web/API after env changes | ## Key details @@ -168,24 +168,26 @@ The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX ### Agent runtime selection -`agent.command` controls which Pi-compatible CLI PI WEB checks in doctor/status/update flows. It defaults to `pi`; set it to `omp` when this machine should use Oh My Pi. +`agent.command` controls which Pi-compatible CLI PI WEB checks in doctor/status/update flows. It defaults to `pi`. Set it only when diagnostics and package-managed update checks should target another compatible command; the embedded session runtime still uses PI WEB's SDK integration. -`agent.dir` controls which compatible agent state directory PI WEB reads for auth providers, model settings, settings, and session metadata. It defaults to the selected agent's conventional directory (`~/.pi/agent` for `pi`, `~/.omp/agent` for `omp`). +`agent.dir` controls which compatible agent state directory PI WEB reads for auth providers, model settings, settings, and session metadata. It defaults to `~/.pi/agent`. Set it to another Pi-compatible state directory when you want an isolated profile or an alternate compatible agent's data. ```json { "agent": { - "command": "omp", - "dir": "~/.omp/agent" + "command": "pi", + "dir": "~/agent-profiles/research" } } ``` -Environment variables take precedence over the config file. `PI_WEB_AGENT_COMMAND` selects the command, `PI_WEB_AGENT_DIR` sets the state directory for any command, and command-specific variables such as `OMP_CODING_AGENT_DIR` are honored when the selected command is `omp`. +For example, an Oh My Pi profile can set `agent.command` to `omp` and `agent.dir` to `~/.omp/agent`. -Session directory overrides are environment-only. Set `PI_WEB_AGENT_SESSION_DIR` or the selected command's session variable (for example `OMP_CODING_AGENT_SESSION_DIR`) when you need to override session storage separately from `agent.dir`. +Environment variables take precedence over the config file. `PI_WEB_AGENT_COMMAND` selects the command, `PI_WEB_AGENT_DIR` sets the state directory for any command, and `PI_WEB_AGENT_SESSION_DIR` overrides session storage separately from `agent.dir`. Existing Pi Coding Agent env names (`PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR`) remain supported for compatibility. -Restart the session daemon after changing agent settings. The web/API process can display the new config immediately, but active session runtime ownership is intentionally long-lived. +Session directory overrides are environment-only; use `PI_WEB_AGENT_SESSION_DIR` unless you need the legacy Pi-compatible `PI_CODING_AGENT_SESSION_DIR` name. + +Restart the session daemon after changing agent settings. The web/API process can display the new config immediately, and status/plugin discovery may re-read it on later requests, but active session runtime ownership is intentionally long-lived. ### Session daemon tools diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 038d476..64273ff 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -7,14 +7,14 @@ describe("API parsers", () => { expect(parsePiWebConfigResponse({ 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: "omp", dir: "~/.omp/agent" } }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "omp", dir: "/Users/dev/.omp/agent" } }, + 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 }, })).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: "omp", dir: "~/.omp/agent" } }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "omp", dir: "/Users/dev/.omp/agent" } }, + 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 }, }); }); diff --git a/src/client/src/components/settings/SettingsSessiondPanel.ts b/src/client/src/components/settings/SettingsSessiondPanel.ts index 98824c0..4ac2e31 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.ts @@ -54,7 +54,7 @@ export class SettingsSessiondPanel extends LitElement { ?disabled=${this.loading || this.saving || agentCommandOverridden} @change=${(event: Event) => { void this.saveAgentField("command", event); }} > - Use omp to make doctor/update checks target Oh My Pi. The embedded session runtime remains PI WEB's SDK path, so this does not dynamically load a different agent implementation. + 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.
    @@ -67,11 +67,11 @@ export class SettingsSessiondPanel extends LitElement { autocomplete="off" spellcheck="false" .value=${config?.config.agent?.dir ?? ""} - placeholder="~/.pi/agent or ~/.omp/agent" + placeholder="~/.pi/agent or ~/agent-profiles/work" ?disabled=${this.loading || this.saving || agentDirOverridden} @change=${(event: Event) => { void this.saveAgentField("dir", event); }} > - Choose which compatible auth, models, settings, and sessions PI WEB reads. For OMP, set this to ~/.omp/agent, then restart the session daemon. + Choose which compatible auth, models, settings, and sessions PI WEB reads. Set a separate directory for isolated agent profiles, then restart the session daemon.
    diff --git a/src/client/src/components/settings/settingsConfigDraft.test.ts b/src/client/src/components/settings/settingsConfigDraft.test.ts index d177add..0b05679 100644 --- a/src/client/src/components/settings/settingsConfigDraft.test.ts +++ b/src/client/src/components/settings/settingsConfigDraft.test.ts @@ -20,7 +20,7 @@ describe("settings config drafts", () => { allowedHostsMode: "list", allowedHostsText: "example.local, 192.168.1.20\n", allowedPathsText: "/tmp\n~/SDKs\n", - }, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "omp", dir: "~/.omp/agent" } })).toEqual({ + }, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } })).toEqual({ host: "127.0.0.1", port: 9000, allowedHosts: ["example.local", "192.168.1.20"], @@ -29,7 +29,7 @@ describe("settings config drafts", () => { pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, - agent: { command: "omp", dir: "~/.omp/agent" }, + agent: { command: "agent-lab", dir: "~/agent-profiles/lab" }, }); }); diff --git a/src/config.test.ts b/src/config.test.ts index 8cf115d..a1352b7 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, effectiveAgentConfig, effectivePiWebConfig, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; +import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentSessionDirEnvKeys, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; let tempDir: string; let configPath: string; @@ -55,43 +55,61 @@ describe("PI WEB config persistence", () => { expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "omp", dir: "~/.omp/agent" }); }); - it("resolves OMP agent defaults from the configured command", () => { + it("keeps the Pi agent directory default for alternate commands", () => { expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "omp" } })).toMatchObject({ command: "omp", - dir: join(tempDir, ".home", ".omp", "agent"), - sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], + dir: join(tempDir, ".home", ".pi", "agent"), + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], }); }); - it("lets PI WEB agent environment overrides take precedence", () => { + it("resolves explicit alternate agent command and state directory settings", () => { + expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "omp", dir: "~/.omp/agent" } })).toMatchObject({ + command: "omp", + dir: join(tempDir, ".home", ".omp", "agent"), + }); + }); + + it("ignores empty agent environment overrides", () => { + const env = { + HOME: join(tempDir, ".home"), + PI_WEB_AGENT_COMMAND: "", + PI_WEB_AGENT_DIR: "", + PI_WEB_AGENT_SESSION_DIR: "", + PI_CODING_AGENT_DIR: "", + PI_CODING_AGENT_SESSION_DIR: "", + }; + + expect(effectiveAgentConfig(env, { agent: { command: "omp", dir: "~/.omp/agent" } })).toMatchObject({ + command: "omp", + dir: join(tempDir, ".home", ".omp", "agent"), + }); + expect(hasAgentDirEnvOverride(env)).toBe(false); + expect(hasAgentSessionDirEnvOverride(env)).toBe(false); + }); + + it("uses generic agent directory env precedence and Pi compatibility fallback", () => { expect(effectiveAgentConfig({ PI_WEB_AGENT_COMMAND: "omp", - PI_WEB_AGENT_DIR: join(tempDir, "env-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({ command: "omp", - dir: join(tempDir, "env-agent"), + dir: join(tempDir, "web-env-agent"), + }); + + expect(effectiveAgentConfig({ + PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"), + }, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({ + dir: join(tempDir, "pi-env-agent"), }); }); - it("lets command-specific agent environment directories override config", () => { - expect(effectiveAgentConfig({ - HOME: join(tempDir, ".home"), - OMP_CODING_AGENT_DIR: join(tempDir, "omp-env-agent"), - }, { agent: { command: "omp", dir: join(tempDir, "config-agent") } })).toMatchObject({ - command: "omp", - dir: join(tempDir, "omp-env-agent"), - }); - }); + it("does not generate command-specific session directory env keys", () => { + const keys = ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]; - it("normalizes omp.exe to OMP environment keys", () => { - expect(effectiveAgentConfig({ - HOME: join(tempDir, ".home"), - OMP_CODING_AGENT_DIR: join(tempDir, "omp-exe-env-agent"), - }, { agent: { command: "omp.exe", dir: join(tempDir, "config-agent") } })).toMatchObject({ - command: "omp.exe", - dir: join(tempDir, "omp-exe-env-agent"), - sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], - }); + expect(agentSessionDirEnvKeys()).toEqual(keys); + expect(effectiveAgentConfig({ HOME: join(tempDir, ".home"), PI_WEB_AGENT_COMMAND: "omp" }).sessionDirEnvKeys).toEqual(keys); }); it("exposes the default upload folder in the effective config", () => { diff --git a/src/config.ts b/src/config.ts index 8754c01..b775191 100644 --- a/src/config.ts +++ b/src/config.ts @@ -49,26 +49,25 @@ export interface EffectivePiWebAgentConfig { } export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick = {}, cwd = process.cwd()): EffectivePiWebAgentConfig { - const command = parseAgentCommand(env[PI_WEB_AGENT_COMMAND_ENV] ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment"); - const commandDirEnv = commandAgentDirEnv(command); - const configuredDir = env[PI_WEB_AGENT_DIR_ENV] ?? env[commandDirEnv] ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env); + const command = parseAgentCommand(envValue(env, PI_WEB_AGENT_COMMAND_ENV) ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment"); + const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? envValue(env, PI_CODING_AGENT_DIR_ENV) ?? config.agent?.dir ?? defaultAgentDir(env); return { command, dir: resolveAgentDirPath(configuredDir, env, cwd, "agent.dir", "environment"), - sessionDirEnvKeys: agentSessionDirEnvKeys(command), + sessionDirEnvKeys: agentSessionDirEnvKeys(), }; } -export function agentSessionDirEnvKeys(command = DEFAULT_AGENT_COMMAND): string[] { - return uniqueStrings([PI_WEB_AGENT_SESSION_DIR_ENV, commandSessionDirEnv(command), PI_CODING_AGENT_SESSION_DIR_ENV]); +export function agentSessionDirEnvKeys(): string[] { + return uniqueStrings([PI_WEB_AGENT_SESSION_DIR_ENV, PI_CODING_AGENT_SESSION_DIR_ENV]); } -export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { - return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || isEnvSet(env[commandAgentDirEnv(command)]); +export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv): boolean { + return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || isEnvSet(env[PI_CODING_AGENT_DIR_ENV]); } -export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { - return agentSessionDirEnvKeys(command).some((key) => isEnvSet(env[key])); +export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv): boolean { + return agentSessionDirEnvKeys().some((key) => isEnvSet(env[key])); } export function effectiveUploadsConfig(config: Pick = {}): NonNullable { @@ -109,7 +108,10 @@ export function loadPiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig { } export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig { - const loaded = loadPiWebConfig(options); + return resolveEffectivePiWebConfig(loadPiWebConfig(options), options); +} + +export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options: LoadOptions = {}): LoadedPiWebConfig { const env = options.env ?? process.env; const host = env["PI_WEB_HOST"]; const port = env["PI_WEB_PORT"] ?? env["PORT"]; @@ -333,28 +335,13 @@ function expandHomePath(value: string, env: NodeJS.ProcessEnv): string { return value; } -function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string { - return expandHomePath(isOmpCommand(command) ? "~/.omp/agent" : "~/.pi/agent", env); +function defaultAgentDir(env: NodeJS.ProcessEnv): string { + return expandHomePath("~/.pi/agent", env); } -function commandAgentDirEnv(command: string): string { - const prefix = agentEnvPrefix(command); - return prefix === "PI" ? PI_CODING_AGENT_DIR_ENV : `${prefix}_CODING_AGENT_DIR`; -} - -function commandSessionDirEnv(command: string): string { - return `${agentEnvPrefix(command)}_CODING_AGENT_SESSION_DIR`; -} - -function agentEnvPrefix(command: string): string { - const name = command.split(/[\\/]/u).at(-1) ?? command; - const normalized = name.replace(/(?:\.[cm]?js|\.exe)$/iu, "").replace(/[^A-Za-z0-9]+/gu, "_").replace(/^_+|_+$/gu, "").toUpperCase(); - return normalized === "" ? "PI" : normalized; -} - -function isOmpCommand(command: string): boolean { - const name = command.split(/[\\/]/u).at(-1)?.toLowerCase(); - return name === "omp" || name === "omp.exe"; +function envValue(env: NodeJS.ProcessEnv, key: string): string | undefined { + const value = env[key]; + return value !== undefined && value !== "" ? value : undefined; } function isEnvSet(value: string | undefined): boolean { diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 808b6fb..abbb728 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -15,7 +15,7 @@ import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js"; import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js"; import { machineScopedPluginId } from "../shared/machinePluginIds.js"; import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js"; -import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; +import type { PiWebConfigResponse, PiWebConfigValues, PiWebStatusResponse } from "../shared/apiTypes.js"; import type { Project, Workspace } from "./types.js"; let app: FastifyInstance; @@ -556,6 +556,35 @@ describe("buildApp", () => { ]); }); + it("uses the latest configured agent dir for PI WEB status after config writes", async () => { + const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"]; + process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1"; + try { + const initialAgentDir = join(tempDir, "initial-agent"); + const updatedAgentDir = join(tempDir, "updated-agent"); + piWebConfig = { agent: { command: "pi", dir: initialAgentDir } }; + await mkdir(initialAgentDir, { recursive: true }); + await installConfiguredPiWebPackage(updatedAgentDir); + + const initialStatus = await app.inject({ method: "GET", url: "/api/pi-web/status" }); + expect(initialStatus.statusCode).toBe(200); + + const updateResponse = await app.inject({ + method: "PUT", + url: "/api/config", + payload: { config: { agent: { command: "pi", dir: updatedAgentDir } } }, + }); + expect(updateResponse.statusCode).toBe(200); + + const refreshedStatus = await app.inject({ method: "GET", url: "/api/pi-web/status" }); + + expect(refreshedStatus.statusCode).toBe(200); + expect(refreshedStatus.json().components.web.installation).toMatchObject({ kind: "pi-package", source: process.cwd(), scope: "user" }); + } finally { + restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck); + } + }); + it("serves supported workspace images as previews", async () => { const addResponse = await app.inject({ method: "POST", @@ -920,6 +949,16 @@ function fakeSessionDaemon(): SessionProxyDaemon { }; } +async function installConfiguredPiWebPackage(agentDir: string): Promise { + await mkdir(agentDir, { recursive: true }); + await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8"); +} + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) Reflect.deleteProperty(process.env, key); + else process.env[key] = value; +} + function fakeRemoteClient(overrides: Partial): MachineClient { return { request: () => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }), diff --git a/src/server/app.ts b/src/server/app.ts index 9860f71..1b81d2b 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -20,9 +20,9 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js"; import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js"; import { createFilePiWebConfigService, registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; import { PiWebPluginService } from "./piWebPluginService.js"; -import { createPiWebStatusCache } from "./piWebStatusCache.js"; +import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js"; import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; -import { effectiveAgentConfig, effectivePiWebConfig } from "../config.js"; +import { effectiveAgentConfig, type EffectivePiWebAgentConfig } from "../config.js"; import { MachineService } from "./machines/machineService.js"; import { registerMachineRoutes } from "./machines/machineRoutes.js"; import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js"; @@ -116,17 +116,42 @@ function registerLocalFileSuggestionRoutes(app: FastifyInstance, projects: Proje }); } +async function readEffectiveConfig(config: Pick) { + return (await config.read()).effectiveConfig; +} + +async function readEffectiveAgentConfig(config: Pick): Promise { + return effectiveAgentConfig(process.env, await readEffectiveConfig(config)); +} + +function invalidatePiWebStatusOnWrite(config: PiWebConfigService, statusCache: Pick): PiWebConfigService { + return { + read: () => config.read(), + write: async (nextConfig) => { + const response = await config.write(nextConfig); + statusCache.invalidate(); + return response; + }, + }; +} + export async function buildApp(deps: AppDependencies = {}): Promise { const app = Fastify({ logger: deps.logger ?? true, ...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }) }); await app.register(fastifyWebsocket); const projects = deps.projects ?? new ProjectService(new ProjectStore()); const workspaces = deps.workspaces ?? new WorkspaceService(); - const agent = effectiveAgentConfig(process.env, effectivePiWebConfig().config); - const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService({ agentDir: agent.dir }); const configService = deps.config ?? createFilePiWebConfigService(); + const readConfig = () => readEffectiveConfig(configService); + const readAgentConfig = () => readEffectiveAgentConfig(configService); + const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService({ + configProvider: readConfig, + }); const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient(); - const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir }), { + const piWebStatusCache = createPiWebStatusCache(async () => { + const agent = await readAgentConfig(); + return getPiWebStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir }); + }, { onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); }, }); const machines = deps.machines ?? new MachineService(undefined, { @@ -144,10 +169,13 @@ export async function buildApp(deps: AppDependencies = {}): Promise piWebStatusCache.get()); - app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir })); + app.get("/api/pi-web/version", async () => { + const agent = await readAgentConfig(); + return getPiWebVersionStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir }); + }); app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon)); app.get("/api/plugins", async () => piWebPlugins.plugins()); - registerConfigRoutes(app, configService); + registerConfigRoutes(app, invalidatePiWebStatusOnWrite(configService, piWebStatusCache)); registerMachineRoutes(app, machines); registerMachinePluginProxyRoutes(app, machines); diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index 264805f..5b729c0 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -37,11 +37,11 @@ describe("config routes", () => { const response = await app.inject({ method: "PUT", url: "/api/config", - payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, agent: { command: "omp", dir: "~/.omp/agent" }, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } }, + payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" }, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } }, }); expect(response.statusCode).toBe(200); - expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, agent: { command: "omp", dir: "~/.omp/agent" }, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 }); + expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" }, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 }); expect(response.json().config).toEqual(savedConfig); }); diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index 89ef3f4..838e6ee 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseUploadsConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; +import { hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseUploadsConfig, resolveEffectivePiWebConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; import { isPiWebPluginId } from "../shared/pluginIds.js"; @@ -20,14 +20,14 @@ export function createFilePiWebConfigService(options: LoadOptions = {}): PiWebCo export function currentPiWebConfigResponse(options: LoadOptions = {}): PiWebConfigResponse { const loaded = loadPiWebConfig(options); - const effective = effectivePiWebConfig(options); + const effective = resolveEffectivePiWebConfig(loaded, options); const env = options.env ?? process.env; return { path: loaded.path, exists: loaded.exists, config: loaded.config, effectiveConfig: effective.config, - envOverrides: piWebConfigEnvOverrides(env, loaded.config), + envOverrides: piWebConfigEnvOverrides(env), }; } @@ -167,8 +167,7 @@ function parsePluginsRequest(value: unknown): NonNullable { expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u); }); + it("uses the current config provider for Pi package plugin discovery", async () => { + const packageDir = join(tempDir, "pkg"); + const initialAgentDir = join(tempDir, "initial-agent"); + const updatedAgentDir = join(tempDir, "updated-agent"); + let currentConfig = { agent: { dir: initialAgentDir } }; + await writePlugin(packageDir, { + packageJson: { piWeb: { plugins: [{ id: "agent-package", module: "dist/plugin.js" }] } }, + files: { "dist/plugin.js": "export default {};" }, + }); + await mkdir(initialAgentDir, { recursive: true }); + await mkdir(updatedAgentDir, { recursive: true }); + await writeFile(join(updatedAgentDir, "settings.json"), `${JSON.stringify({ packages: [packageDir] }, null, 2)}\n`, "utf8"); + const service = new PiWebPluginService({ roots: [], cwd: tempDir, configProvider: () => currentConfig }); + + await expect(service.manifest()).resolves.toEqual({ plugins: [] }); + + currentConfig = { agent: { dir: updatedAgentDir } }; + + await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "agent-package", source: packageDir, scope: "user" }] }); + }); + it("discovers source checkout plugin packages without symlinks", async () => { await mkdir(join(tempDir, "src", "server"), { recursive: true }); await writeFile(join(tempDir, "src", "server", "index.ts"), "export {};\n"); diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index b952d53..17a8c6e 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -2,8 +2,8 @@ import { existsSync } from "node:fs"; import { readdir, readFile, realpath, stat } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; -import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; -import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js"; +import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent"; +import { effectiveAgentConfig, loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js"; import type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js"; import { isPiWebPluginId } from "../shared/pluginIds.js"; @@ -46,8 +46,9 @@ interface PiWebPluginServiceOptions { roots?: LocalPluginRoot[]; cwd?: string; agentDir?: string; + agentDirProvider?: () => string | Promise; packageProvider?: PiPackageProvider | false; - configProvider?: () => PiWebConfig; + configProvider?: () => PiWebConfig | Promise; } interface LocalPluginRoot { @@ -71,11 +72,12 @@ type ArraylessPluginRecord = Omit; export class DefaultPiPackageProvider implements PiPackageProvider { private readonly packageManager: DefaultPackageManager; - constructor(cwd = process.cwd(), agentDir = getAgentDir()) { + constructor(cwd = process.cwd(), agentDir?: string) { + const resolvedAgentDir = agentDir ?? defaultAgentDirForCwd(cwd); this.packageManager = new DefaultPackageManager({ cwd, - agentDir, - settingsManager: SettingsManager.create(cwd, agentDir), + agentDir: resolvedAgentDir, + settingsManager: SettingsManager.create(cwd, resolvedAgentDir), }); } @@ -88,16 +90,32 @@ export class DefaultPiPackageProvider implements PiPackageProvider { } } +function defaultAgentDirForCwd(cwd: string): string { + return effectiveAgentConfig(process.env, loadPiWebConfig({ cwd }).config, cwd).dir; +} + export class PiWebPluginService { + private readonly cwd: string; private readonly roots: LocalPluginRoot[]; - private readonly packageProvider: PiPackageProvider | undefined; - private readonly configProvider: () => PiWebConfig; + private readonly agentDir: string | undefined; + private readonly agentDirProvider: (() => string | Promise) | undefined; + private readonly packageProviderForAgentDir: ((agentDir: string) => PiPackageProvider) | undefined; + private readonly configProvider: () => PiWebConfig | Promise; constructor(options: PiWebPluginServiceOptions = {}) { const cwd = options.cwd ?? process.cwd(); - const agentDir = options.agentDir ?? getAgentDir(); + this.cwd = cwd; this.roots = options.roots ?? defaultPluginRoots(cwd); - this.packageProvider = options.packageProvider === false ? undefined : options.packageProvider ?? new DefaultPiPackageProvider(cwd, agentDir); + this.agentDir = options.agentDir; + this.agentDirProvider = options.agentDirProvider; + const packageProvider = options.packageProvider; + if (packageProvider === false) { + this.packageProviderForAgentDir = undefined; + } else if (packageProvider !== undefined) { + this.packageProviderForAgentDir = () => packageProvider; + } else { + this.packageProviderForAgentDir = (agentDir) => new DefaultPiPackageProvider(cwd, agentDir); + } this.configProvider = options.configProvider ?? (() => loadPiWebConfig({ cwd }).config); } @@ -110,7 +128,8 @@ export class PiWebPluginService { } async plugins(): Promise { - const [plugins, config] = await Promise.all([this.discoverPlugins(), Promise.resolve(this.configProvider())]); + const config = await this.configProvider(); + const plugins = await this.discoverPlugins(config); return { plugins: plugins.map((plugin) => this.pluginInfo(plugin, config)) }; } @@ -143,15 +162,28 @@ export class PiWebPluginService { }; } - private async discoverPlugins(): Promise { + private async discoverPlugins(config?: PiWebConfig): Promise { const records = new Map(); for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin); - if (this.packageProvider !== undefined) { - for (const plugin of await this.discoverPiPackagePlugins(this.packageProvider)) addUnique(records, plugin); + const packageProvider = await this.packageProvider(config); + if (packageProvider !== undefined) { + for (const plugin of await this.discoverPiPackagePlugins(packageProvider)) addUnique(records, plugin); } return [...records.values()].sort((left, right) => left.id.localeCompare(right.id)); } + private async packageProvider(config?: PiWebConfig): Promise { + if (this.packageProviderForAgentDir === undefined) return undefined; + return this.packageProviderForAgentDir(await this.currentAgentDir(config)); + } + + private async currentAgentDir(config?: PiWebConfig): Promise { + if (this.agentDirProvider !== undefined) return await this.agentDirProvider(); + if (this.agentDir !== undefined) return this.agentDir; + const currentConfig = config ?? await this.configProvider(); + return effectiveAgentConfig(process.env, currentConfig, this.cwd).dir; + } + private async discoverLocalPlugins(): Promise { const plugins: PluginRecord[] = []; for (const root of this.roots) plugins.push(...await discoverLocalRoot(root)); diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index 94bf1eb..3e546fc 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -52,7 +52,7 @@ describe("PI WEB status", () => { capabilities: [], }); - const status = await getPiWebVersionStatus(daemon, { agentCommand: "omp", agentDir }); + const status = await getPiWebVersionStatus(daemon, { agentCommand: "alt-agent", agentDir }); expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: process.cwd(), scope: "user" }); } finally { @@ -85,12 +85,12 @@ describe("PI WEB status", () => { { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, "pi-web restart", { - agentCommand: "/tmp/agent's/omp", - hasCommand: (command) => Promise.resolve(command === "/tmp/agent's/omp"), + agentCommand: "/tmp/agent's/alt-agent", + hasCommand: (command) => Promise.resolve(command === "/tmp/agent's/alt-agent"), }, ); - expect(updateCommand).toBe("'/tmp/agent'\\''s/omp' update 'npm:@jmfederico/pi-web' && pi-web restart"); + expect(updateCommand).toBe("'/tmp/agent'\\''s/alt-agent' update 'npm:@jmfederico/pi-web' && pi-web restart"); }); it("suggests native systemd commands for local development services", async () => { diff --git a/src/server/piWebStatusCache.ts b/src/server/piWebStatusCache.ts index ec8813b..3f56c09 100644 --- a/src/server/piWebStatusCache.ts +++ b/src/server/piWebStatusCache.ts @@ -11,6 +11,7 @@ export interface PiWebStatusCacheOptions { export interface PiWebStatusCache { get(): Promise; refresh(): Promise; + invalidate(): void; } export function createPiWebStatusCache(load: () => Promise, options: PiWebStatusCacheOptions = {}): PiWebStatusCache { @@ -42,5 +43,8 @@ export function createPiWebStatusCache(load: () => Promise, return refresh(); }, refresh, + invalidate(): void { + cached = undefined; + }, }; } diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index 04c3e4b..15dbe38 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -44,9 +44,9 @@ describe("AuthService", () => { const agentDir = await tempAgentDir(); const auth = new AuthService({ agentDir }); - auth.saveApiKey("anthropic", "sk-omp"); + auth.saveApiKey("anthropic", "sk-test"); - await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-omp"); + await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-test"); auth.dispose(); }); }); diff --git a/src/server/sessions/piSessionManagerGateway.test.ts b/src/server/sessions/piSessionManagerGateway.test.ts index eda4d73..144df97 100644 --- a/src/server/sessions/piSessionManagerGateway.test.ts +++ b/src/server/sessions/piSessionManagerGateway.test.ts @@ -60,12 +60,12 @@ describe("SessionDirResolver", () => { expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true }); }); - it("uses OMP sessionDir environment overrides before settings", async () => { - const envDir = join(tempDir, "omp-env-sessions"); + it("uses PI WEB sessionDir environment overrides before settings", async () => { + const envDir = join(tempDir, "pi-web-env-sessions"); await mkdir(agentDir, { recursive: true }); await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8"); - const resolver = new SessionDirResolver({ agentDir, env: { OMP_CODING_AGENT_SESSION_DIR: envDir }, sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"] }); + const resolver = new SessionDirResolver({ agentDir, env: { PI_WEB_AGENT_SESSION_DIR: envDir } }); expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true }); }); @@ -92,14 +92,13 @@ describe("Pi session manager gateway", () => { await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })])); }); - it("includes command-specific env session directories in global listing", async () => { - for (const envKey of ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR"]) { + it("includes generic env session directories in global listing", async () => { + for (const envKey of ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]) { const envSessionDir = join(tempDir, `${envKey.toLowerCase()}-sessions`); await writeSessionFile(envSessionDir, `${envKey.toLowerCase()}-session`, cwd); const gateway = createPiSessionManagerGateway({ agentDir, env: { [envKey]: envSessionDir }, - sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], }); if (gateway.listAll === undefined) throw new Error("Expected legacy listing support"); diff --git a/src/server/sessions/piSessionManagerGateway.ts b/src/server/sessions/piSessionManagerGateway.ts index 7b06eaf..d13ea10 100644 --- a/src/server/sessions/piSessionManagerGateway.ts +++ b/src/server/sessions/piSessionManagerGateway.ts @@ -2,12 +2,11 @@ import type { Dirent } from "node:fs"; import { readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; -import { getAgentDir, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent"; +import { SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent"; +import { agentSessionDirEnvKeys, effectiveAgentConfig } from "../../config.js"; import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js"; import type { PiSessionListEntry, PiSessionManager, PiSessionManagerGateway } from "./piSessionService.js"; -export const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR"; - type SessionDirSource = "env" | "settings" | "pi-default"; export interface SessionDirResolution { @@ -28,9 +27,9 @@ export class SessionDirResolver { private readonly sessionDirEnvKeys: readonly string[]; constructor(options: SessionDirResolverOptions = {}) { - this.agentDir = options.agentDir ?? getAgentDir(); + this.agentDir = options.agentDir ?? effectiveAgentConfig().dir; this.env = options.env ?? process.env; - this.sessionDirEnvKeys = options.sessionDirEnvKeys ?? [PI_SESSION_DIR_ENV]; + this.sessionDirEnvKeys = options.sessionDirEnvKeys ?? agentSessionDirEnvKeys(); } defaultSessionsRoot(): string { @@ -131,11 +130,11 @@ function uniqueSessionsByPath(sessions: readonly PiSessionListEntry[]): PiSessio return [...byPath.values()].sort((a, b) => b.modified.getTime() - a.modified.getTime()); } -export function defaultPiSessionsRoot(agentDir = getAgentDir()): string { +export function defaultPiSessionsRoot(agentDir = effectiveAgentConfig().dir): string { return join(agentDir, "sessions"); } -export function defaultPiSessionDir(cwd: string, agentDir = getAgentDir()): string { +export function defaultPiSessionDir(cwd: string, agentDir = effectiveAgentConfig().dir): string { return sessionDirInDefaultPiStore(defaultPiSessionsRoot(agentDir), cwd); } diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index bf0d12a..95cc5d3 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -7,7 +7,6 @@ import { createAgentSessionServices, createEditToolDefinition, defineTool, - getAgentDir, ModelRegistry, SessionManager, type CreateAgentSessionRuntimeFactory, @@ -25,6 +24,7 @@ import { createModelRegistryForAgentDir, type AuthChange } from "./authService.j import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; import { computeEditPreview, type EditPreviewResult } from "./editPreview.js"; import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js"; +import { effectiveAgentConfig } from "../../config.js"; import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js"; import { parsePromptAttachments } from "../../shared/promptAttachments.js"; import type { SavedPromptAttachment } from "../../shared/apiTypes.js"; @@ -338,7 +338,7 @@ export class PiSessionService implements SessionRouteService { constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) { this.archiveStore = deps.archiveStore ?? new SessionArchiveStore(); - this.agentDir = deps.agentDir ?? getAgentDir(); + this.agentDir = deps.agentDir ?? effectiveAgentConfig().dir; this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir }); this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir); this.spawnTargets = deps.spawnTargets; diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index b225908..41292d8 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -89,7 +89,7 @@ export interface PiWebConfigValues { * while the capability stabilizes. Requires spawnSessions to be enabled. */ subsessions?: boolean; - /** Agent runtime state used by the session daemon (Pi by default; OMP compatible). */ + /** Agent runtime command/state used by PI WEB and the session daemon (Pi by default). */ agent?: PiWebAgentConfig; } From 1edebb1b048f278512ea8391fd7cb290e8fa1987 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 29 Jun 2026 07:56:01 +0200 Subject: [PATCH 4/8] fix: require explicit alternate agent state --- docs/config.html | 35 ++++++------- docs/config.md | 20 ++++---- src/cli.test.ts | 6 +-- .../settings/SettingsSessiondPanel.ts | 4 +- src/config.test.ts | 51 +++++++++++-------- src/config.ts | 30 +++++++---- src/server/configRoutes.ts | 9 ++-- 7 files changed, 88 insertions(+), 67 deletions(-) diff --git a/docs/config.html b/docs/config.html index 5d89c2d..5a4f7c6 100644 --- a/docs/config.html +++ b/docs/config.html @@ -130,8 +130,8 @@

    Environment overrides include PI_WEB_HOST, PI_WEB_PORT / PORT, PI_WEB_ALLOWED_HOSTS, PI_WEB_MAX_UPLOAD_BYTES, PI_WEB_AGENT_COMMAND, - PI_WEB_AGENT_DIR, PI_WEB_AGENT_SESSION_DIR, PI_CODING_AGENT_DIR, - PI_CODING_AGENT_SESSION_DIR, PI_WEB_SPAWN_SESSIONS, and + PI_WEB_AGENT_DIR, PI_WEB_AGENT_SESSION_DIR, PI_CODING_AGENT_DIR / + PI_CODING_AGENT_SESSION_DIR for Pi compatibility, PI_WEB_SPAWN_SESSIONS, and PI_WEB_SUBSESSIONS.

      @@ -280,7 +280,7 @@ Agent state directory agent.dir - PI_WEB_AGENT_DIR, PI_CODING_AGENT_DIR + PI_WEB_AGENT_DIR (PI_CODING_AGENT_DIR for Pi compatibility) Global/session daemon Not supported locally Restart session daemon; affects auth, models, settings, and sessions @@ -393,7 +393,7 @@ Agent session storage directory — - PI_WEB_AGENT_SESSION_DIR, PI_CODING_AGENT_SESSION_DIR + PI_WEB_AGENT_SESSION_DIR (PI_CODING_AGENT_SESSION_DIR for Pi compatibility) Session daemon env Not supported locally Restart session daemon; env-only session storage override @@ -401,7 +401,7 @@ Agent config directory — - PI_WEB_AGENT_DIR, PI_CODING_AGENT_DIR + PI_WEB_AGENT_DIR (PI_CODING_AGENT_DIR for Pi compatibility) Web/API + session daemon env Not supported locally Restart services @@ -452,31 +452,32 @@

      agent.dir controls which compatible agent state directory PI WEB reads for auth providers, - model settings, settings, and session metadata. It defaults to ~/.pi/agent. Set it to another - Pi-compatible state directory when you want an isolated profile or an alternate compatible agent's data. + model settings, settings, and session metadata. It defaults to ~/.pi/agent only for the default + pi command. Set it explicitly when you want an isolated Pi profile or when + agent.command points at another compatible CLI.

      {
         "agent": {
      -    "command": "pi",
      -    "dir": "~/agent-profiles/research"
      +    "command": "my-pi-fork",
      +    "dir": "/opt/my-pi-fork/agent"
         }
       }

      - For example, an Oh My Pi profile can set agent.command to omp and - agent.dir to ~/.omp/agent. + For example, a fork profile can set agent.command to my-pi-fork and + agent.dir to /opt/my-pi-fork/agent.

      Environment variables take precedence over the config file. PI_WEB_AGENT_COMMAND selects the - command, PI_WEB_AGENT_DIR sets the state directory for any command, and - PI_WEB_AGENT_SESSION_DIR overrides session storage separately from agent.dir. - Existing Pi Coding Agent env names (PI_CODING_AGENT_DIR and - PI_CODING_AGENT_SESSION_DIR) remain supported for compatibility. + command, PI_WEB_AGENT_DIR sets the state directory, and PI_WEB_AGENT_SESSION_DIR + overrides session storage separately from agent.dir. Existing Pi Coding Agent env names + (PI_CODING_AGENT_DIR and PI_CODING_AGENT_SESSION_DIR) remain supported only for + Pi compatibility; alternate commands should use the explicit PI WEB variables or config keys.

      - Session directory overrides are environment-only; use PI_WEB_AGENT_SESSION_DIR unless you need - the legacy Pi-compatible PI_CODING_AGENT_SESSION_DIR name. + Session directory overrides are environment-only; use PI_WEB_AGENT_SESSION_DIR unless you are + intentionally using the legacy Pi-compatible PI_CODING_AGENT_SESSION_DIR name.

      Restart the session daemon after changing agent settings. The web/API process can display the new config diff --git a/docs/config.md b/docs/config.md index 22429fc..8baf495 100644 --- a/docs/config.md +++ b/docs/config.md @@ -25,7 +25,7 @@ defaults → global config file → environment overrides Supported project-local settings are then applied for that project's workspaces. For upload defaults, `/.pi-web/config.json` overrides the global value. -Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_AGENT_COMMAND`, `PI_WEB_AGENT_DIR`, `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`. +Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_AGENT_COMMAND`, `PI_WEB_AGENT_DIR`, `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR` / `PI_CODING_AGENT_SESSION_DIR` for Pi compatibility, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`. Process restarts depend on the key: @@ -104,7 +104,7 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file | Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh | | Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon | | Agent CLI command | `agent.command` | `PI_WEB_AGENT_COMMAND` | Global/session daemon | Not supported locally | Restart session daemon; affects doctor/status/update checks | -| Agent state directory | `agent.dir` | `PI_WEB_AGENT_DIR`, `PI_CODING_AGENT_DIR` | Global/session daemon | Not supported locally | Restart session daemon; affects auth, models, settings, and sessions | +| Agent state directory | `agent.dir` | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Global/session daemon | Not supported locally | Restart session daemon; affects auth, models, settings, and sessions | | Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon | | Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon | | Plugin enablement/settings | `plugins..enabled`, `plugins..settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab | @@ -119,8 +119,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file | Web-to-daemon URL | — | `PI_WEB_SESSIOND_URL` | Web/API env | Not supported locally | Restart web/API | | Projects storage file | — | `PI_WEB_PROJECTS_FILE` | Web/API + session daemon env | Not supported locally | Restart services; advanced state override | | Remote machines storage file | — | `PI_WEB_MACHINES_FILE` | Web/API env | Not supported locally | Restart web/API; advanced state override | -| Agent session storage directory | — | `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_SESSION_DIR` | Session daemon env | Not supported locally | Restart session daemon; env-only session storage override | -| Agent config directory | — | `PI_WEB_AGENT_DIR`, `PI_CODING_AGENT_DIR` | Web/API + session daemon env | Not supported locally | Restart services | +| Agent session storage directory | — | `PI_WEB_AGENT_SESSION_DIR` (`PI_CODING_AGENT_SESSION_DIR` for Pi compatibility) | Session daemon env | Not supported locally | Restart session daemon; env-only session storage override | +| Agent config directory | — | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Web/API + session daemon env | Not supported locally | Restart services | | Skip update checks | — | `PI_WEB_SKIP_VERSION_CHECK`, `PI_WEB_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_OFFLINE` | Web/API env | Not supported locally | Restart web/API after env changes | ## Key details @@ -170,22 +170,22 @@ The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX `agent.command` controls which Pi-compatible CLI PI WEB checks in doctor/status/update flows. It defaults to `pi`. Set it only when diagnostics and package-managed update checks should target another compatible command; the embedded session runtime still uses PI WEB's SDK integration. -`agent.dir` controls which compatible agent state directory PI WEB reads for auth providers, model settings, settings, and session metadata. It defaults to `~/.pi/agent`. Set it to another Pi-compatible state directory when you want an isolated profile or an alternate compatible agent's data. +`agent.dir` controls which compatible agent state directory PI WEB reads for auth providers, model settings, settings, and session metadata. It defaults to `~/.pi/agent` only for the default `pi` command. Set it explicitly when you want an isolated Pi profile or when `agent.command` points at another compatible CLI. ```json { "agent": { - "command": "pi", - "dir": "~/agent-profiles/research" + "command": "my-pi-fork", + "dir": "/opt/my-pi-fork/agent" } } ``` -For example, an Oh My Pi profile can set `agent.command` to `omp` and `agent.dir` to `~/.omp/agent`. +For example, a fork profile can set `agent.command` to `my-pi-fork` and `agent.dir` to `/opt/my-pi-fork/agent`. -Environment variables take precedence over the config file. `PI_WEB_AGENT_COMMAND` selects the command, `PI_WEB_AGENT_DIR` sets the state directory for any command, and `PI_WEB_AGENT_SESSION_DIR` overrides session storage separately from `agent.dir`. Existing Pi Coding Agent env names (`PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR`) remain supported for compatibility. +Environment variables take precedence over the config file. `PI_WEB_AGENT_COMMAND` selects the command, `PI_WEB_AGENT_DIR` sets the state directory, and `PI_WEB_AGENT_SESSION_DIR` overrides session storage separately from `agent.dir`. Existing Pi Coding Agent env names (`PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR`) remain supported only for Pi compatibility; alternate commands should use the explicit PI WEB variables or config keys. -Session directory overrides are environment-only; use `PI_WEB_AGENT_SESSION_DIR` unless you need the legacy Pi-compatible `PI_CODING_AGENT_SESSION_DIR` name. +Session directory overrides are environment-only; use `PI_WEB_AGENT_SESSION_DIR` unless you are intentionally using the legacy Pi-compatible `PI_CODING_AGENT_SESSION_DIR` name. Restart the session daemon after changing agent settings. The web/API process can display the new config immediately, and status/plugin discovery may re-read it on later requests, but active session runtime ownership is intentionally long-lived. diff --git a/src/cli.test.ts b/src/cli.test.ts index 065e55a..be297be 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -46,7 +46,7 @@ describe("commandWithVersionCheck", () => { it("shell-quotes command words", () => { process.env["SHELL"] = "/bin/bash"; - expect(commandWithVersionCheck("/tmp/agent's/omp")).toBe("command -v '/tmp/agent'\\''s/omp' && ('/tmp/agent'\\''s/omp' --version 2>&1 || true)"); + expect(commandWithVersionCheck("/tmp/agent's/acme-agent")).toBe("command -v '/tmp/agent'\\''s/acme-agent' && ('/tmp/agent'\\''s/acme-agent' --version 2>&1 || true)"); }); }); @@ -55,11 +55,11 @@ describe("agentCommandForChecks", () => { const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-")); try { const configPath = join(dir, "config.json"); - writeFileSync(configPath, `${JSON.stringify({ agent: { command: "omp" } })}\n`); + writeFileSync(configPath, `${JSON.stringify({ agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } })}\n`); process.env["PI_WEB_CONFIG"] = configPath; delete process.env["PI_WEB_AGENT_COMMAND"]; - expect(agentCommandForChecks()).toBe("omp"); + expect(agentCommandForChecks()).toBe("acme-agent"); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/src/client/src/components/settings/SettingsSessiondPanel.ts b/src/client/src/components/settings/SettingsSessiondPanel.ts index 4ac2e31..7a48a6c 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.ts @@ -71,7 +71,7 @@ export class SettingsSessiondPanel extends LitElement { ?disabled=${this.loading || this.saving || agentDirOverridden} @change=${(event: Event) => { void this.saveAgentField("dir", event); }} > - Choose which compatible auth, models, settings, and sessions PI WEB reads. Set a separate directory for isolated agent profiles, then restart the session daemon. + Choose which compatible auth, models, settings, and sessions PI WEB reads. Non-pi commands require an explicit state directory, then a session daemon restart.
      @@ -110,7 +110,7 @@ export class SettingsSessiondPanel extends LitElement {

      Effective after environment overrides

      Agent command
      ${effectiveAgent?.command ?? html`pi default`}
      -
      Agent state
      ${effectiveAgent?.dir ?? html`Pi default`}
      +
      Agent state
      ${effectiveAgent?.dir ?? html`~/.pi/agent default`}
      Spawn sessions
      ${effectiveSpawn ? "Enabled" : html`Disabled`}
      Subsessions
      ${effectiveSubsessions ? "Enabled" : html`Disabled`}
      diff --git a/src/config.test.ts b/src/config.test.ts index a1352b7..8d53aa4 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -50,23 +50,28 @@ describe("PI WEB config persistence", () => { }); it("persists and reads custom agent runtime settings", () => { - savePiWebConfig({ agent: { command: "omp", dir: "~/.omp/agent" } }, testOptions()); + savePiWebConfig({ agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } }, testOptions()); - expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "omp", dir: "~/.omp/agent" }); + expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "acme-agent", dir: "/opt/acme-agent/state" }); }); - it("keeps the Pi agent directory default for alternate commands", () => { - expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "omp" } })).toMatchObject({ - command: "omp", + it("defaults to the Pi agent directory only for Pi commands and launchers", () => { + expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "/tmp/pi.cmd" } })).toMatchObject({ + command: "/tmp/pi.cmd", dir: join(tempDir, ".home", ".pi", "agent"), sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], }); }); + it("requires an explicit agent directory for non-Pi commands", () => { + expect(() => effectiveAgentConfig({}, { agent: { command: "acme-agent" } })).toThrow('PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is "acme-agent"'); + expect(() => savePiWebConfig({ agent: { command: "acme-agent" } }, testOptions())).toThrow('PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is "acme-agent"'); + }); + it("resolves explicit alternate agent command and state directory settings", () => { - expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "omp", dir: "~/.omp/agent" } })).toMatchObject({ - command: "omp", - dir: join(tempDir, ".home", ".omp", "agent"), + expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "acme-agent", dir: "~/agent-profiles/acme" } })).toMatchObject({ + command: "acme-agent", + dir: join(tempDir, ".home", "agent-profiles", "acme"), }); }); @@ -80,36 +85,40 @@ describe("PI WEB config persistence", () => { PI_CODING_AGENT_SESSION_DIR: "", }; - expect(effectiveAgentConfig(env, { agent: { command: "omp", dir: "~/.omp/agent" } })).toMatchObject({ - command: "omp", - dir: join(tempDir, ".home", ".omp", "agent"), + expect(effectiveAgentConfig(env, { agent: { command: "acme-agent", dir: "~/agent-profiles/acme" } })).toMatchObject({ + command: "acme-agent", + dir: join(tempDir, ".home", "agent-profiles", "acme"), }); - expect(hasAgentDirEnvOverride(env)).toBe(false); - expect(hasAgentSessionDirEnvOverride(env)).toBe(false); + expect(hasAgentDirEnvOverride(env, "acme-agent")).toBe(false); + expect(hasAgentSessionDirEnvOverride(env, "acme-agent")).toBe(false); }); - it("uses generic agent directory env precedence and Pi compatibility fallback", () => { + it("uses explicit PI WEB agent directory env precedence", () => { expect(effectiveAgentConfig({ - PI_WEB_AGENT_COMMAND: "omp", + 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({ - command: "omp", + command: "acme-agent", dir: join(tempDir, "web-env-agent"), }); + }); + it("keeps legacy Pi env directory overrides scoped to Pi commands", () => { expect(effectiveAgentConfig({ PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"), }, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({ dir: join(tempDir, "pi-env-agent"), }); + + expect(() => effectiveAgentConfig({ + PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"), + }, { agent: { command: "acme-agent" } })).toThrow('PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is "acme-agent"'); }); - it("does not generate command-specific session directory env keys", () => { - const keys = ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]; - - expect(agentSessionDirEnvKeys()).toEqual(keys); - expect(effectiveAgentConfig({ HOME: join(tempDir, ".home"), PI_WEB_AGENT_COMMAND: "omp" }).sessionDirEnvKeys).toEqual(keys); + it("uses only explicit session directory env keys", () => { + expect(agentSessionDirEnvKeys()).toEqual(["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]); + expect(effectiveAgentConfig({ HOME: join(tempDir, ".home"), PI_WEB_AGENT_COMMAND: "acme-agent", PI_WEB_AGENT_DIR: join(tempDir, "agent") }).sessionDirEnvKeys).toEqual(["PI_WEB_AGENT_SESSION_DIR"]); }); it("exposes the default upload folder in the effective config", () => { diff --git a/src/config.ts b/src/config.ts index b775191..55affd9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -50,24 +50,27 @@ export interface EffectivePiWebAgentConfig { export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick = {}, cwd = process.cwd()): EffectivePiWebAgentConfig { const command = parseAgentCommand(envValue(env, PI_WEB_AGENT_COMMAND_ENV) ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment"); - const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? envValue(env, PI_CODING_AGENT_DIR_ENV) ?? config.agent?.dir ?? defaultAgentDir(env); + const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? (isPiCommand(command) ? envValue(env, PI_CODING_AGENT_DIR_ENV) : undefined) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env); return { command, dir: resolveAgentDirPath(configuredDir, env, cwd, "agent.dir", "environment"), - sessionDirEnvKeys: agentSessionDirEnvKeys(), + sessionDirEnvKeys: agentSessionDirEnvKeys(command), }; } -export function agentSessionDirEnvKeys(): string[] { - return uniqueStrings([PI_WEB_AGENT_SESSION_DIR_ENV, PI_CODING_AGENT_SESSION_DIR_ENV]); +export function agentSessionDirEnvKeys(command = DEFAULT_AGENT_COMMAND): string[] { + return uniqueStrings([ + PI_WEB_AGENT_SESSION_DIR_ENV, + ...(isPiCommand(command) ? [PI_CODING_AGENT_SESSION_DIR_ENV] : []), + ]); } -export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv): boolean { - return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || isEnvSet(env[PI_CODING_AGENT_DIR_ENV]); +export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { + return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || (isPiCommand(command) && isEnvSet(env[PI_CODING_AGENT_DIR_ENV])); } -export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv): boolean { - return agentSessionDirEnvKeys().some((key) => isEnvSet(env[key])); +export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { + return agentSessionDirEnvKeys(command).some((key) => isEnvSet(env[key])); } export function effectiveUploadsConfig(config: Pick = {}): NonNullable { @@ -141,6 +144,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): const env = options.env ?? process.env; const path = piWebConfigPath(env, options.cwd ?? process.cwd()); const normalized = parsePiWebConfig(piWebConfigRecord(config), path); + effectiveAgentConfig(env, normalized, options.cwd ?? process.cwd()); const existing = readExistingConfigObject(path); delete existing["host"]; delete existing["port"]; @@ -335,8 +339,14 @@ function expandHomePath(value: string, env: NodeJS.ProcessEnv): string { return value; } -function defaultAgentDir(env: NodeJS.ProcessEnv): string { - return expandHomePath("~/.pi/agent", env); +function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string { + if (isPiCommand(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 isPiCommand(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 { diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index 838e6ee..14b52dc 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -27,7 +27,7 @@ export function currentPiWebConfigResponse(options: LoadOptions = {}): PiWebConf exists: loaded.exists, config: loaded.config, effectiveConfig: effective.config, - envOverrides: piWebConfigEnvOverrides(env), + envOverrides: piWebConfigEnvOverrides(env, effective.config), }; } @@ -167,7 +167,8 @@ function parsePluginsRequest(value: unknown): NonNullable Date: Mon, 13 Jul 2026 22:50:50 +0200 Subject: [PATCH 5/8] feat: expose daemon-owned active agent profile --- src/config.ts | 15 +++++- src/server/piWebStatus.test.ts | 23 ++++++++ src/server/sessiond.ts | 55 +++++++++++-------- src/sessiond/activeAgentProfile.test.ts | 54 +++++++++++++++++++ src/sessiond/activeAgentProfile.ts | 22 ++++++++ src/sessiond/sessionDaemonClient.test.ts | 69 ++++++++++++++++++++++++ src/sessiond/sessionDaemonClient.ts | 40 ++++++++++++++ src/shared/activeAgentProfile.ts | 43 +++++++++++++++ src/shared/apiTypes.ts | 11 ++++ src/shared/piWebStatusParsing.test.ts | 50 +++++++++++++++++ src/shared/piWebStatusParsing.ts | 5 ++ 11 files changed, 362 insertions(+), 25 deletions(-) create mode 100644 src/sessiond/activeAgentProfile.test.ts create mode 100644 src/sessiond/activeAgentProfile.ts create mode 100644 src/sessiond/sessionDaemonClient.test.ts create mode 100644 src/shared/activeAgentProfile.ts diff --git a/src/config.ts b/src/config.ts index 55affd9..a41eeb5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,17 @@ export interface LoadedPiWebConfig { config: PiWebConfig; } +export interface EffectivePiWebConfig extends Omit { + uploads: NonNullable; + spawnSessions: boolean; + subsessions: boolean; + agent: Required>; +} + +export interface LoadedEffectivePiWebConfig extends Omit { + config: EffectivePiWebConfig; +} + export interface LoadOptions { env?: NodeJS.ProcessEnv; cwd?: string; @@ -110,11 +121,11 @@ export function loadPiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig { return { path, exists: true, config: parsePiWebConfig(parsed, path) }; } -export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig { +export function effectivePiWebConfig(options: LoadOptions = {}): LoadedEffectivePiWebConfig { return resolveEffectivePiWebConfig(loadPiWebConfig(options), options); } -export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options: LoadOptions = {}): LoadedPiWebConfig { +export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options: LoadOptions = {}): LoadedEffectivePiWebConfig { const env = options.env ?? process.env; const host = env["PI_WEB_HOST"]; const port = env["PI_WEB_PORT"] ?? env["PORT"]; diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index 442f11b..8c9f685 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -90,6 +90,29 @@ describe("PI WEB status", () => { expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings])); }); + it("carries the daemon-owned active agent profile through the web runtime response", async () => { + const activeAgentProfile = { + schemaVersion: 1 as const, + revision: `sha256:${"a".repeat(64)}`, + command: "acme-agent", + dir: "/opt/acme-agent/state", + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }; + const daemon = daemonWithRuntime({ + component: "sessiond", + label: "Session daemon", + runtimeVersion: "1.202605.7", + available: true, + capabilities: [], + activeAgentProfile, + }); + + const runtime = await getPiWebRuntime(daemon); + + expect(runtime.components.sessiond.activeAgentProfile).toEqual(activeAgentProfile); + expect(runtime.components.web.activeAgentProfile).toBeUndefined(); + }); + it("bypasses cached npm release data for a forced check", async () => { Reflect.deleteProperty(process.env, "PI_WEB_SKIP_VERSION_CHECK"); process.env["PI_WEB_DOCKER_RUNTIME"] = "1"; diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index b3cfdbb..d46973c 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -20,11 +20,16 @@ import { TerminalService } from "./terminals/terminalService.js"; import { registerTerminalRoutes } from "./terminals/terminalRoutes.js"; import { getPiWebRuntimeComponent } from "./piWebStatus.js"; import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js"; -import { effectiveAgentConfig, effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js"; +import { agentSessionDirEnvKeys, effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js"; +import { createActiveAgentProfileDescriptor } from "../sessiond/activeAgentProfile.js"; import { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js"; const { config } = effectivePiWebConfig(); -const agent = effectiveAgentConfig(process.env, config); +const activeAgentProfile = createActiveAgentProfileDescriptor({ + command: config.agent.command, + dir: config.agent.dir, + sessionDirEnvKeys: agentSessionDirEnvKeys(config.agent.command), +}); const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(process.env, config) }); await app.register(fastifyWebsocket); @@ -33,46 +38,50 @@ await runSessionDaemonStartup({ createRuntime() { const eventHub = new SessionEventHub(); const workspaceActivity = new WorkspaceActivityService(eventHub); - const auth = new AuthService({ agentDir: agent.dir }); + const auth = new AuthService({ agentDir: activeAgentProfile.dir }); const spawnTargets = spawnSessionsEnabled(process.env, config) ? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() }) : undefined; const sessions = new PiSessionService(eventHub, { modelRegistry: auth.modelRegistry, - agentDir: agent.dir, + agentDir: activeAgentProfile.dir, workspaceActivity, logger: app.log, ...(spawnTargets === undefined ? {} : { spawnTargets }), subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config), - sessionManager: createPiSessionManagerGateway({ agentDir: agent.dir, sessionDirEnvKeys: agent.sessionDirEnvKeys }), + sessionManager: createPiSessionManagerGateway({ + agentDir: activeAgentProfile.dir, + sessionDirEnvKeys: activeAgentProfile.sessionDirEnvKeys, + }), }); auth.subscribe((change) => { sessions.applyAuthChange(change); }); const terminals = new TerminalService(eventHub, workspaceActivity); - return { eventHub, workspaceActivity, auth, sessions, terminals }; + const runtimeComponent = Object.freeze({ + ...getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES), + activeAgentProfile, + }); + return { eventHub, workspaceActivity, auth, sessions, terminals, activeAgentProfile, runtimeComponent }; }, - registerRoutes({ eventHub, workspaceActivity, auth, sessions, terminals }) { + registerRoutes({ eventHub, workspaceActivity, auth, sessions, terminals, runtimeComponent }) { registerWorkspaceActivityRoutes(app, workspaceActivity); registerAuthRoutes(app, auth); registerSessionRoutes(app, sessions, eventHub); registerTerminalRoutes(app, terminals); - app.get("/health", () => { - const runtime = getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES); - return { - ok: true, - activeSessions: sessions.activeCount(), - checkedAt: new Date().toISOString(), - version: { - component: runtime.component, - label: runtime.label, - ...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }), - stale: false, - available: runtime.available, - }, - }; - }); + app.get("/health", () => ({ + ok: true, + activeSessions: sessions.activeCount(), + checkedAt: new Date().toISOString(), + version: { + component: runtimeComponent.component, + label: runtimeComponent.label, + ...(runtimeComponent.runtimeVersion === undefined ? {} : { runtimeVersion: runtimeComponent.runtimeVersion }), + stale: false, + available: runtimeComponent.available, + }, + })); - app.get("/runtime", () => getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES)); + app.get("/runtime", () => runtimeComponent); }, async listen({ auth, sessions, terminals }) { let shuttingDown = false; diff --git a/src/sessiond/activeAgentProfile.test.ts b/src/sessiond/activeAgentProfile.test.ts new file mode 100644 index 0000000..8b7ac4b --- /dev/null +++ b/src/sessiond/activeAgentProfile.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import type { EffectivePiWebAgentConfig } from "../config.js"; +import { createActiveAgentProfileDescriptor } from "./activeAgentProfile.js"; + +const baseAgent: EffectivePiWebAgentConfig = { + command: "acme-agent", + dir: "/opt/acme-agent/state", + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], +}; + +describe("active agent profile descriptor", () => { + it("builds a stable revision from every effective profile field", () => { + const first = createActiveAgentProfileDescriptor(baseAgent); + const second = createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys: [...baseAgent.sessionDirEnvKeys] }); + + expect(second).toEqual(first); + expect(first.revision).toMatch(/^sha256:[0-9a-f]{64}$/u); + expect(createActiveAgentProfileDescriptor({ ...baseAgent, command: "other-agent" }).revision).not.toBe(first.revision); + expect(createActiveAgentProfileDescriptor({ ...baseAgent, dir: "/other/state" }).revision).not.toBe(first.revision); + expect(createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys: ["OTHER_SESSION_DIR"] }).revision).not.toBe(first.revision); + }); + + it("takes an immutable snapshot for the session daemon profile epoch", () => { + const sessionDirEnvKeys = ["PI_WEB_AGENT_SESSION_DIR"]; + const profile = createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys }); + sessionDirEnvKeys.push("LATE_MUTATION"); + + expect(Object.isFrozen(profile)).toBe(true); + expect(Object.isFrozen(profile.sessionDirEnvKeys)).toBe(true); + expect(profile.sessionDirEnvKeys).toEqual(["PI_WEB_AGENT_SESSION_DIR"]); + expect(Reflect.set(profile, "command", "mutated-agent")).toBe(false); + expect(Reflect.set(profile.sessionDirEnvKeys, "0", "MUTATED_SESSION_DIR")).toBe(false); + }); + + it("copies only the secret-free descriptor fields", () => { + const input = { + ...baseAgent, + token: "must-not-cross-the-protocol", + auth: { apiKey: "also-secret" }, + }; + + const profile = createActiveAgentProfileDescriptor(input); + + expect(profile).toEqual({ + schemaVersion: 1, + revision: profile.revision, + command: baseAgent.command, + dir: baseAgent.dir, + sessionDirEnvKeys: baseAgent.sessionDirEnvKeys, + }); + expect(profile.revision).toMatch(/^sha256:[0-9a-f]{64}$/u); + expect(JSON.stringify(profile)).not.toContain("secret"); + }); +}); diff --git a/src/sessiond/activeAgentProfile.ts b/src/sessiond/activeAgentProfile.ts new file mode 100644 index 0000000..f5db70b --- /dev/null +++ b/src/sessiond/activeAgentProfile.ts @@ -0,0 +1,22 @@ +import { createHash } from "node:crypto"; +import type { EffectivePiWebAgentConfig } from "../config.js"; +import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js"; +import { ACTIVE_AGENT_PROFILE_SCHEMA_VERSION } from "../shared/activeAgentProfile.js"; + +export function createActiveAgentProfileDescriptor(agent: EffectivePiWebAgentConfig): ActiveAgentProfileDescriptor { + const sessionDirEnvKeys = Object.freeze([...agent.sessionDirEnvKeys]); + const revisionInput = JSON.stringify({ + schemaVersion: ACTIVE_AGENT_PROFILE_SCHEMA_VERSION, + command: agent.command, + dir: agent.dir, + sessionDirEnvKeys, + }); + + return Object.freeze({ + schemaVersion: ACTIVE_AGENT_PROFILE_SCHEMA_VERSION, + revision: `sha256:${createHash("sha256").update(revisionInput).digest("hex")}`, + command: agent.command, + dir: agent.dir, + sessionDirEnvKeys, + }); +} diff --git a/src/sessiond/sessionDaemonClient.test.ts b/src/sessiond/sessionDaemonClient.test.ts new file mode 100644 index 0000000..980468c --- /dev/null +++ b/src/sessiond/sessionDaemonClient.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { SessionDaemonClient } from "./sessionDaemonClient.js"; + +const activeAgentProfile = { + schemaVersion: 1, + revision: `sha256:${"a".repeat(64)}`, + command: "acme-agent", + dir: "/opt/acme-agent/state", + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], +}; + +describe("SessionDaemonClient active agent profile protocol", () => { + it("returns the validated immutable profile from the daemon runtime endpoint", async () => { + const client = new SessionDaemonClient(); + const request = vi.spyOn(client, "request").mockResolvedValue(runtimeResponse(activeAgentProfile)); + + const result = await client.getActiveAgentProfile(); + + expect(request).toHaveBeenCalledWith("GET", "/runtime"); + expect(result).toEqual({ status: "available", profile: activeAgentProfile }); + if (result.status === "available") { + expect(Object.isFrozen(result.profile)).toBe(true); + expect(Object.isFrozen(result.profile.sessionDirEnvKeys)).toBe(true); + } + }); + + it("distinguishes invalid protocol responses from daemon unavailability", async () => { + const invalidClient = new SessionDaemonClient(); + vi.spyOn(invalidClient, "request").mockResolvedValue(runtimeResponse({ + ...activeAgentProfile, + token: "must-not-cross-the-protocol", + })); + const unavailableClient = new SessionDaemonClient(); + vi.spyOn(unavailableClient, "request").mockRejectedValue(new Error("connect ECONNREFUSED")); + + await expect(invalidClient.getActiveAgentProfile()).resolves.toEqual({ + status: "invalid", + error: "session daemon runtime response was invalid", + }); + await expect(unavailableClient.getActiveAgentProfile()).resolves.toEqual({ + status: "unavailable", + error: "connect ECONNREFUSED", + }); + }); + + it("treats a legacy runtime response without a profile as invalid for profile-dependent work", async () => { + const client = new SessionDaemonClient(); + vi.spyOn(client, "request").mockResolvedValue(runtimeResponse(undefined)); + + await expect(client.getActiveAgentProfile()).resolves.toEqual({ + status: "invalid", + error: "session daemon runtime response did not include an active agent profile", + }); + }); +}); + +function runtimeResponse(profile: unknown) { + return { + statusCode: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + component: "sessiond", + label: "Session daemon", + available: true, + capabilities: [], + ...(profile === undefined ? {} : { activeAgentProfile: profile }), + }), + }; +} diff --git a/src/sessiond/sessionDaemonClient.ts b/src/sessiond/sessionDaemonClient.ts index 88c27e3..06b69d3 100644 --- a/src/sessiond/sessionDaemonClient.ts +++ b/src/sessiond/sessionDaemonClient.ts @@ -1,7 +1,14 @@ import http from "node:http"; import { WebSocket } from "ws"; +import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js"; +import { parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js"; import { sessiondHttpUrl, sessiondSocketPath } from "./config.js"; +export type SessionDaemonAgentProfileResult = + | { status: "available"; profile: ActiveAgentProfileDescriptor } + | { status: "unavailable"; error: string } + | { status: "invalid"; error: string }; + export class SessionDaemonClient { private readonly baseUrl = sessiondHttpUrl(); private readonly socketPath = sessiondSocketPath(); @@ -12,6 +19,35 @@ export class SessionDaemonClient { return this.requestSocket(method, path, payload); } + async getActiveAgentProfile(): Promise { + let response: Awaited>; + try { + response = await this.request("GET", "/runtime"); + } catch (error) { + return { status: "unavailable", error: errorMessage(error) }; + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + return { status: "unavailable", error: `session daemon runtime request returned HTTP ${String(response.statusCode)}` }; + } + + let value: unknown; + try { + value = response.body === "" ? undefined : JSON.parse(response.body); + } catch { + return { status: "invalid", error: "session daemon runtime response was not valid JSON" }; + } + + const runtime = parsePiWebRuntimeComponent(value); + if (runtime?.component !== "sessiond") { + return { status: "invalid", error: "session daemon runtime response was invalid" }; + } + if (runtime.activeAgentProfile === undefined) { + return { status: "invalid", error: "session daemon runtime response did not include an active agent profile" }; + } + return { status: "available", profile: runtime.activeAgentProfile }; + } + connectWebSocket(path: string): WebSocket { if (this.baseUrl !== undefined && this.baseUrl !== "") { const url = new URL(path, this.baseUrl); @@ -66,3 +102,7 @@ export class SessionDaemonClient { }); } } + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/shared/activeAgentProfile.ts b/src/shared/activeAgentProfile.ts new file mode 100644 index 0000000..18e0047 --- /dev/null +++ b/src/shared/activeAgentProfile.ts @@ -0,0 +1,43 @@ +import type { ActiveAgentProfileDescriptor } from "./apiTypes.js"; + +export const ACTIVE_AGENT_PROFILE_SCHEMA_VERSION = 1 as const; + +const ACTIVE_AGENT_PROFILE_FIELDS = new Set([ + "schemaVersion", + "revision", + "command", + "dir", + "sessionDirEnvKeys", +]); +const SHA256_REVISION_PATTERN = /^sha256:[0-9a-f]{64}$/u; + +export function parseActiveAgentProfileDescriptor(value: unknown): ActiveAgentProfileDescriptor | undefined { + if (!isRecord(value) || Object.keys(value).some((key) => !ACTIVE_AGENT_PROFILE_FIELDS.has(key))) return undefined; + + const schemaVersion = value["schemaVersion"]; + const revision = value["revision"]; + const command = value["command"]; + const dir = value["dir"]; + const sessionDirEnvKeys = value["sessionDirEnvKeys"]; + if (schemaVersion !== ACTIVE_AGENT_PROFILE_SCHEMA_VERSION) return undefined; + if (typeof revision !== "string" || !SHA256_REVISION_PATTERN.test(revision)) return undefined; + if (typeof command !== "string" || command === "" || typeof dir !== "string" || dir === "") return undefined; + if (!isNonEmptyStringArray(sessionDirEnvKeys)) return undefined; + if (new Set(sessionDirEnvKeys).size !== sessionDirEnvKeys.length) return undefined; + + return Object.freeze({ + schemaVersion, + revision, + command, + dir, + sessionDirEnvKeys: Object.freeze([...sessionDirEnvKeys]), + }); +} + +function isNonEmptyStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry: unknown) => typeof entry === "string" && entry !== ""); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 7d53904..2213b77 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -593,12 +593,23 @@ export interface PiWebComponentStatus { error?: string; } +/** Secret-free identity of the Pi-compatible CLI/state profile fixed for one sessiond lifetime. */ +export interface ActiveAgentProfileDescriptor { + readonly schemaVersion: 1; + readonly revision: string; + readonly command: string; + readonly dir: string; + readonly sessionDirEnvKeys: readonly string[]; +} + export interface PiWebRuntimeComponent { component: PiWebServiceComponent; label: string; runtimeVersion?: string; available: boolean; capabilities: PiWebCapability[]; + /** Present only for a session daemon that supports active-profile reporting. */ + activeAgentProfile?: ActiveAgentProfileDescriptor; error?: string; } diff --git a/src/shared/piWebStatusParsing.test.ts b/src/shared/piWebStatusParsing.test.ts index dbc7389..2795430 100644 --- a/src/shared/piWebStatusParsing.test.ts +++ b/src/shared/piWebStatusParsing.test.ts @@ -33,6 +33,56 @@ describe("PI WEB status parsing", () => { })).toBeUndefined(); }); + it("parses and freezes a session daemon active agent profile", () => { + const parsed = parsePiWebRuntimeResponse({ + packageName: "@jmfederico/pi-web", + generatedAt: "now", + components: { + web: { component: "web", label: "Web/UI", available: true, capabilities: [] }, + sessiond: { + component: "sessiond", + label: "Session daemon", + available: true, + capabilities: [], + activeAgentProfile: { + schemaVersion: 1, + revision: `sha256:${"a".repeat(64)}`, + command: "acme-agent", + dir: "/opt/acme-agent/state", + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }, + }, + }, + capabilities: [], + }); + + expect(parsed?.components.sessiond.activeAgentProfile).toMatchObject({ command: "acme-agent", dir: "/opt/acme-agent/state" }); + expect(Object.isFrozen(parsed?.components.sessiond.activeAgentProfile)).toBe(true); + expect(Object.isFrozen(parsed?.components.sessiond.activeAgentProfile?.sessionDirEnvKeys)).toBe(true); + }); + + it("rejects malformed, secret-bearing, or web-owned active profile descriptors", () => { + const profile = { + schemaVersion: 1, + revision: `sha256:${"a".repeat(64)}`, + command: "acme-agent", + dir: "/opt/acme-agent/state", + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }; + const responseFor = (webProfile: unknown, sessiondProfile: unknown) => ({ + packageName: "@jmfederico/pi-web", + generatedAt: "now", + components: { + web: { component: "web", label: "Web/UI", available: true, capabilities: [], ...(webProfile === undefined ? {} : { activeAgentProfile: webProfile }) }, + sessiond: { component: "sessiond", label: "Session daemon", available: true, capabilities: [], ...(sessiondProfile === undefined ? {} : { activeAgentProfile: sessiondProfile }) }, + }, + capabilities: [], + }); + + expect(parsePiWebRuntimeResponse(responseFor(undefined, { ...profile, token: "secret" }))).toBeUndefined(); + expect(parsePiWebRuntimeResponse(responseFor(profile, undefined))).toBeUndefined(); + }); + it("parses Docker installation metadata", () => { expect(parsePiWebInstallationInfo({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" })).toEqual({ kind: "docker", diff --git a/src/shared/piWebStatusParsing.ts b/src/shared/piWebStatusParsing.ts index 6bc85c5..01771a9 100644 --- a/src/shared/piWebStatusParsing.ts +++ b/src/shared/piWebStatusParsing.ts @@ -1,4 +1,5 @@ import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebVersionResponse } from "./apiTypes.js"; +import { parseActiveAgentProfileDescriptor } from "./activeAgentProfile.js"; import { parseKnownPiWebCapabilities } from "./capabilities.js"; export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined { @@ -33,15 +34,19 @@ export function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponen const runtimeVersion = value["runtimeVersion"]; const available = value["available"]; const capabilities = parseKnownPiWebCapabilities(value["capabilities"]); + const activeAgentProfileValue = value["activeAgentProfile"]; + const activeAgentProfile = activeAgentProfileValue === undefined ? undefined : parseActiveAgentProfileDescriptor(activeAgentProfileValue); const error = value["error"]; if (component !== "web" && component !== "sessiond") return undefined; if (typeof label !== "string" || label === "" || typeof available !== "boolean" || capabilities === undefined) return undefined; + if (activeAgentProfileValue !== undefined && (component !== "sessiond" || activeAgentProfile === undefined)) return undefined; return { component, label, ...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}), available, capabilities, + ...(activeAgentProfile === undefined ? {} : { activeAgentProfile }), ...(typeof error === "string" ? { error } : {}), }; } From 97e0afc6fa1cb7d5f4eb3be68b236c55c137e087 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 13 Jul 2026 23:09:32 +0200 Subject: [PATCH 6/8] feat: route profile consumers through sessiond --- src/server/activeAgentProfileProvider.test.ts | 82 ++++++++++ src/server/activeAgentProfileProvider.ts | 36 ++++ src/server/app.activeAgentProfile.test.ts | 154 ++++++++++++++++++ src/server/app.agentConfig.test.ts | 41 ++++- src/server/app.testSupport.ts | 24 ++- src/server/app.ts | 55 ++++--- src/server/piPackageRoutes.test.ts | 10 ++ src/server/piPackageRoutes.ts | 11 +- src/server/piPackageService.test.ts | 64 +++++++- src/server/piPackageService.ts | 50 +++++- src/server/piWebPluginService.test.ts | 25 ++- src/server/piWebPluginService.ts | 55 ++++--- src/server/piWebStatus.test.ts | 50 +++++- src/server/piWebStatus.ts | 36 ++-- src/sessiond/sessionDaemonClient.ts | 62 ++++--- 15 files changed, 637 insertions(+), 118 deletions(-) create mode 100644 src/server/activeAgentProfileProvider.test.ts create mode 100644 src/server/activeAgentProfileProvider.ts create mode 100644 src/server/app.activeAgentProfile.test.ts diff --git a/src/server/activeAgentProfileProvider.test.ts b/src/server/activeAgentProfileProvider.test.ts new file mode 100644 index 0000000..7878e14 --- /dev/null +++ b/src/server/activeAgentProfileProvider.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js"; +import type { SessionDaemonRequestClient } from "../sessiond/sessionDaemonClient.js"; +import { + ActiveAgentProfileAccessError, + requireActiveAgentProfile, + SessionDaemonActiveAgentProfileProvider, +} from "./activeAgentProfileProvider.js"; + +const firstProfile = activeProfile("a", "first-agent", "/state/first"); +const secondProfile = activeProfile("b", "second-agent", "/state/second"); + +describe("SessionDaemonActiveAgentProfileProvider", () => { + it("queries sessiond on every read and observes a new daemon profile epoch", async () => { + const request = vi.fn() + .mockResolvedValueOnce(runtimeResponse(firstProfile)) + .mockResolvedValueOnce(runtimeResponse(secondProfile)); + const provider = new SessionDaemonActiveAgentProfileProvider({ request }); + + await expect(provider.getActiveAgentProfile()).resolves.toEqual({ status: "available", profile: firstProfile }); + await expect(provider.getActiveAgentProfile()).resolves.toEqual({ status: "available", profile: secondProfile }); + + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith(1, "GET", "/runtime"); + expect(request).toHaveBeenNthCalledWith(2, "GET", "/runtime"); + }); + + it("preserves invalid protocol and daemon unavailability as distinct results", async () => { + const invalidRequest = vi.fn().mockResolvedValue({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: "not-json", + }); + const unavailableRequest = vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED")); + + await expect(new SessionDaemonActiveAgentProfileProvider({ request: invalidRequest }).getActiveAgentProfile()).resolves.toEqual({ + status: "invalid", + error: "session daemon runtime response was not valid JSON", + }); + await expect(new SessionDaemonActiveAgentProfileProvider({ request: unavailableRequest }).getActiveAgentProfile()).resolves.toEqual({ + status: "unavailable", + error: "connect ECONNREFUSED", + }); + }); +}); + +describe("requireActiveAgentProfile", () => { + it.each(["invalid", "unavailable"] as const)("fails closed for an %s active profile", async (status) => { + const provider = { + getActiveAgentProfile: () => Promise.resolve({ status, error: `${status} profile` } as const), + }; + + const error = await requireActiveAgentProfile(provider).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ActiveAgentProfileAccessError); + expect(error).toMatchObject({ profileStatus: status, message: `Active agent profile is ${status}: ${status} profile` }); + }); +}); + +function activeProfile(revisionCharacter: string, command: string, dir: string): ActiveAgentProfileDescriptor { + return { + schemaVersion: 1, + revision: `sha256:${revisionCharacter.repeat(64)}`, + command, + dir, + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }; +} + +function runtimeResponse(profile: ActiveAgentProfileDescriptor) { + return { + statusCode: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + component: "sessiond", + label: "Session daemon", + available: true, + capabilities: [], + activeAgentProfile: profile, + }), + }; +} diff --git a/src/server/activeAgentProfileProvider.ts b/src/server/activeAgentProfileProvider.ts new file mode 100644 index 0000000..75dfccf --- /dev/null +++ b/src/server/activeAgentProfileProvider.ts @@ -0,0 +1,36 @@ +import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js"; +import { + getSessionDaemonActiveAgentProfile, + type SessionDaemonAgentProfileResult, + type SessionDaemonRequestClient, +} from "../sessiond/sessionDaemonClient.js"; + +export interface ActiveAgentProfileProvider { + getActiveAgentProfile(): Promise; +} + +/** Reads the daemon-owned profile on every call so a new sessiond epoch is observed. */ +export class SessionDaemonActiveAgentProfileProvider implements ActiveAgentProfileProvider { + constructor(private readonly daemon: SessionDaemonRequestClient) {} + + getActiveAgentProfile(): Promise { + return getSessionDaemonActiveAgentProfile(this.daemon); + } +} + +export class ActiveAgentProfileAccessError extends Error { + readonly profileStatus: "unavailable" | "invalid"; + + constructor(result: Exclude) { + const label = result.status === "unavailable" ? "unavailable" : "invalid"; + super(`Active agent profile is ${label}: ${result.error}`); + this.name = "ActiveAgentProfileAccessError"; + this.profileStatus = result.status; + } +} + +export async function requireActiveAgentProfile(provider: ActiveAgentProfileProvider): Promise { + const result = await provider.getActiveAgentProfile(); + if (result.status !== "available") throw new ActiveAgentProfileAccessError(result); + return result.profile; +} diff --git a/src/server/app.activeAgentProfile.test.ts b/src/server/app.activeAgentProfile.test.ts new file mode 100644 index 0000000..c80b3fd --- /dev/null +++ b/src/server/app.activeAgentProfile.test.ts @@ -0,0 +1,154 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebPluginInfo } from "../shared/apiTypes.js"; +import type { SessionDaemonAgentProfileResult } from "../sessiond/sessionDaemonClient.js"; +import type { ActiveAgentProfileProvider } from "./activeAgentProfileProvider.js"; +import { buildApp } from "./app.js"; +import type { PiWebConfigService } from "./configRoutes.js"; + +let tempDir: string; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-web-active-profile-app-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +describe("buildApp active profile composition", () => { + it("routes package and package-backed plugin reads through the same refreshable provider", async () => { + const firstAgentDir = join(tempDir, "first-agent"); + const secondAgentDir = join(tempDir, "second-agent"); + const firstPackageDir = join(tempDir, "first-package"); + const secondPackageDir = join(tempDir, "second-package"); + await Promise.all([ + writePackagePlugin(firstPackageDir, "profile-first"), + writePackagePlugin(secondPackageDir, "profile-second"), + writePiPackageSettings(firstAgentDir, [firstPackageDir]), + writePiPackageSettings(secondAgentDir, [secondPackageDir]), + ]); + + let result: SessionDaemonAgentProfileResult = { status: "available", profile: activeProfile("a", "first-agent", firstAgentDir) }; + const getActiveAgentProfile = vi.fn(() => Promise.resolve(result)); + const app = await buildApp({ + agentProfileProvider: { getActiveAgentProfile }, + config: emptyConfigService(), + clientDist: false, + logger: false, + }); + + try { + const firstPackages = await app.inject({ method: "GET", url: "/api/pi-packages" }); + const firstPlugins = await app.inject({ method: "GET", url: "/api/plugins" }); + expect(firstPackages.statusCode).toBe(200); + expect(packageSources(firstPackages.json())).toContain(firstPackageDir); + expect(pluginIds(firstPlugins.json())).toContain("profile-first"); + expect(pluginIds(firstPlugins.json())).not.toContain("profile-second"); + + result = { status: "available", profile: activeProfile("b", "second-agent", secondAgentDir) }; + + const secondPackages = await app.inject({ method: "GET", url: "/api/pi-packages" }); + const secondPlugins = await app.inject({ method: "GET", url: "/api/plugins" }); + expect(secondPackages.statusCode).toBe(200); + expect(packageSources(secondPackages.json())).toContain(secondPackageDir); + expect(packageSources(secondPackages.json())).not.toContain(firstPackageDir); + expect(pluginIds(secondPlugins.json())).toContain("profile-second"); + expect(pluginIds(secondPlugins.json())).not.toContain("profile-first"); + expect(getActiveAgentProfile).toHaveBeenCalledTimes(4); + } finally { + await app.close(); + } + }); + + it.each(["unavailable", "invalid"] as const)("returns 503 instead of falling back when the active profile is %s", async (status) => { + const provider: ActiveAgentProfileProvider = { + getActiveAgentProfile: () => Promise.resolve({ status, error: `${status} daemon profile` }), + }; + const app = await buildApp({ + agentProfileProvider: provider, + config: emptyConfigService(), + clientDist: false, + logger: false, + }); + + try { + const packages = await app.inject({ method: "GET", url: "/api/pi-packages" }); + const plugins = await app.inject({ method: "GET", url: "/api/plugins" }); + const manifest = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" }); + + expect(packages.statusCode).toBe(503); + expect(packages.json()).toEqual({ error: `Active agent profile is ${status}: ${status} daemon profile` }); + expect(plugins.statusCode).toBe(503); + expect(plugins.json()).toEqual({ error: `Active agent profile is ${status}: ${status} daemon profile` }); + expect(manifest.statusCode).toBe(503); + expect(manifest.json()).toEqual({ error: `Active agent profile is ${status}: ${status} daemon profile` }); + } finally { + await app.close(); + } + }); +}); + +function activeProfile(revisionCharacter: string, command: string, dir: string): ActiveAgentProfileDescriptor { + return { + schemaVersion: 1, + revision: `sha256:${revisionCharacter.repeat(64)}`, + command, + dir, + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }; +} + +async function writePiPackageSettings(agentDir: string, packages: string[]): Promise { + await mkdir(agentDir, { recursive: true }); + await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages }, null, 2)}\n`, "utf8"); +} + +async function writePackagePlugin(root: string, pluginId: string): Promise { + await mkdir(root, { recursive: true }); + await writeFile(join(root, "package.json"), `${JSON.stringify({ + name: `@test/${pluginId}`, + version: "1.0.0", + piWeb: { plugins: [{ id: pluginId, module: "pi-web-plugin.js" }] }, + }, null, 2)}\n`, "utf8"); + await writeFile(join(root, "pi-web-plugin.js"), "export default {};\n", "utf8"); +} + +function emptyConfigService(): PiWebConfigService { + const response: PiWebConfigResponse = { + path: join(tempDir, "config.json"), + exists: false, + config: {}, + effectiveConfig: {}, + envOverrides: { + host: false, + port: false, + allowedHosts: false, + spawnSessions: false, + subsessions: false, + agentCommand: false, + agentDir: false, + agentSessionDir: false, + }, + }; + return { + read: () => Promise.resolve(response), + write: () => Promise.resolve(response), + }; +} + +function packageSources(value: unknown): string[] { + if (!isRecord(value) || !Array.isArray(value["packages"])) return []; + return value["packages"].flatMap((entry) => isRecord(entry) && typeof entry["source"] === "string" ? [entry["source"]] : []); +} + +function pluginIds(value: unknown): PiWebPluginInfo["id"][] { + if (!isRecord(value) || !Array.isArray(value["plugins"])) return []; + return value["plugins"].flatMap((entry) => isRecord(entry) && typeof entry["id"] === "string" ? [entry["id"]] : []); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/server/app.agentConfig.test.ts b/src/server/app.agentConfig.test.ts index b129343..21622ff 100644 --- a/src/server/app.agentConfig.test.ts +++ b/src/server/app.agentConfig.test.ts @@ -1,13 +1,13 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import type { PiWebStatusResponse } from "../shared/apiTypes.js"; +import type { ActiveAgentProfileDescriptor, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js"; import { appTestContext, registerAppTestHooks } from "./app.testSupport.js"; registerAppTestHooks(); -describe("buildApp agent config", () => { - it.each(["/api/config", "/api/machines/local/config"])("uses the latest configured agent dir for status after writes through %s", async (configRoute) => { +describe("buildApp active agent profile", () => { + it.each(["/api/config", "/api/machines/local/config"])("keeps desired writes separate from the active profile and observes a new daemon epoch through %s", async (configRoute) => { const originalEnv = captureEnv([ "PI_WEB_SKIP_VERSION_CHECK", "PI_WEB_DOCKER_RUNTIME", @@ -24,9 +24,11 @@ describe("buildApp agent config", () => { try { const initialAgentDir = join(appTestContext.tempDir, "initial-agent"); const updatedAgentDir = join(appTestContext.tempDir, "updated-agent"); - appTestContext.piWebConfig = { agent: { command: "pi", dir: initialAgentDir } }; + appTestContext.piWebConfig = { agent: { command: "desired-agent", dir: initialAgentDir } }; + appTestContext.agentProfileResult = { status: "available", profile: activeProfile("a", "active-agent", initialAgentDir) }; await mkdir(initialAgentDir, { recursive: true }); await installConfiguredPiWebPackage(updatedAgentDir); + process.env["PI_WEB_AGENT_DIR"] = updatedAgentDir; const initialStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status" }); expect(initialStatus.statusCode).toBe(200); @@ -35,14 +37,27 @@ describe("buildApp agent config", () => { const updateResponse = await appTestContext.app.inject({ method: "PUT", url: configRoute, - payload: { config: { agent: { command: "pi", dir: updatedAgentDir } } }, + payload: { config: { agent: { command: "next-agent", dir: updatedAgentDir } } }, }); expect(updateResponse.statusCode).toBe(200); - const refreshedStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status" }); + const desiredWriteStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status" }); + const desiredWriteVersion = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/version" }); + expect(desiredWriteStatus.statusCode).toBe(200); + expect(desiredWriteStatus.json().components.web.installation?.kind).not.toBe("pi-package"); + expect(desiredWriteVersion.json().components.web.installation?.kind).not.toBe("pi-package"); - expect(refreshedStatus.statusCode).toBe(200); - expect(refreshedStatus.json().components.web.installation).toMatchObject({ + appTestContext.agentProfileResult = { status: "available", profile: activeProfile("b", "next-agent", updatedAgentDir) }; + const restartedStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status?refresh=1" }); + const restartedVersion = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/version" }); + + expect(restartedStatus.statusCode).toBe(200); + expect(restartedStatus.json().components.web.installation).toMatchObject({ + kind: "pi-package", + source: process.cwd(), + scope: "user", + }); + expect(restartedVersion.json().components.web.installation).toMatchObject({ kind: "pi-package", source: process.cwd(), scope: "user", @@ -53,6 +68,16 @@ describe("buildApp agent config", () => { }); }); +function activeProfile(revisionCharacter: string, command: string, dir: string): ActiveAgentProfileDescriptor { + return { + schemaVersion: 1, + revision: `sha256:${revisionCharacter.repeat(64)}`, + command, + dir, + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }; +} + async function installConfiguredPiWebPackage(agentDir: string): Promise { await mkdir(agentDir, { recursive: true }); await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8"); diff --git a/src/server/app.testSupport.ts b/src/server/app.testSupport.ts index 7478ddd..3d689e1 100644 --- a/src/server/app.testSupport.ts +++ b/src/server/app.testSupport.ts @@ -14,7 +14,8 @@ import { WorkspaceService } from "./workspaces/workspaceService.js"; import type { PiPackageService } from "./piPackageService.js"; import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js"; import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js"; -import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; +import type { ActiveAgentProfileDescriptor, PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; +import type { SessionDaemonAgentProfileResult } from "../sessiond/sessionDaemonClient.js"; interface AppTestContext { readonly app: FastifyInstance; @@ -24,6 +25,7 @@ interface AppTestContext { readonly sessionDaemonRequests: CapturedSessionDaemonRequest[]; readonly piPackageRequests: CapturedPiPackageRequest[]; piWebConfig: PiWebConfigValues; + agentProfileResult: SessionDaemonAgentProfileResult; } let app: FastifyInstance | undefined; @@ -33,6 +35,7 @@ let remoteClient: MachineClient | undefined; let sessionDaemonRequests: CapturedSessionDaemonRequest[] = []; let piPackageRequests: CapturedPiPackageRequest[] = []; let piWebConfig: PiWebConfigValues = {}; +let agentProfileResult: SessionDaemonAgentProfileResult = { status: "invalid", error: "App test harness was not initialized" }; export const appTestContext: AppTestContext = { get app() { @@ -65,6 +68,12 @@ export const appTestContext: AppTestContext = { set piWebConfig(config) { piWebConfig = config; }, + get agentProfileResult() { + return agentProfileResult; + }, + set agentProfileResult(result) { + agentProfileResult = result; + }, }; export function registerAppTestHooks(): void { @@ -75,6 +84,7 @@ export function registerAppTestHooks(): void { sessionDaemonRequests = []; piPackageRequests = []; piWebConfig = {}; + agentProfileResult = { status: "available", profile: appTestAgentProfile(join(tempDir, "agent")) }; app = await buildApp({ projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))), workspaces: new WorkspaceService(), @@ -95,6 +105,7 @@ export function registerAppTestHooks(): void { }), }), sessionDaemon: fakeSessionDaemon(), + agentProfileProvider: { getActiveAgentProfile: () => Promise.resolve(agentProfileResult) }, config: fakeConfigService(), piPackages: fakePiPackageService(), piWebPlugins: { @@ -117,6 +128,7 @@ export function registerAppTestHooks(): void { sessionDaemonRequests = []; piPackageRequests = []; piWebConfig = {}; + agentProfileResult = { status: "invalid", error: "App test harness was not initialized" }; if (appToClose !== undefined) await appToClose.close(); if (tempDirToRemove !== undefined) await rm(tempDirToRemove, { recursive: true, force: true }); @@ -152,6 +164,16 @@ function fakeConfigService() { }; } +function appTestAgentProfile(dir: string): ActiveAgentProfileDescriptor { + return { + schemaVersion: 1, + revision: `sha256:${"a".repeat(64)}`, + command: "pi", + dir, + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], + }; +} + export function fullPiWebConfig(): PiWebConfigValues { return { host: "127.0.0.1", diff --git a/src/server/app.ts b/src/server/app.ts index a7c0035..6bd2eee 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify"; +import Fastify, { type FastifyInstance, type FastifyReply, type FastifyServerOptions } from "fastify"; import fastifyCompress from "@fastify/compress"; import fastifyStatic from "@fastify/static"; import fastifyWebsocket from "@fastify/websocket"; @@ -21,11 +21,16 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js"; import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js"; import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; import { PiWebPluginService } from "./piWebPluginService.js"; -import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js"; +import { createActiveProfilePiPackageService, type PiPackageService } from "./piPackageService.js"; import { registerPiPackageRoutes } from "./piPackageRoutes.js"; import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js"; import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; -import { effectiveAgentConfig, type EffectivePiWebAgentConfig } from "../config.js"; +import { + ActiveAgentProfileAccessError, + requireActiveAgentProfile, + SessionDaemonActiveAgentProfileProvider, + type ActiveAgentProfileProvider, +} from "./activeAgentProfileProvider.js"; import { MachineService } from "./machines/machineService.js"; import { registerMachineRoutes } from "./machines/machineRoutes.js"; import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js"; @@ -37,6 +42,7 @@ export interface AppDependencies { workspaces?: WorkspaceService; machines?: MachineService; sessionDaemon?: SessionProxyDaemon; + agentProfileProvider?: ActiveAgentProfileProvider; piWebPlugins?: Pick; piPackages?: PiPackageService; piWebStatusCache?: PiWebStatusCache; @@ -125,10 +131,6 @@ async function readEffectiveConfig(config: Pick) { return (await config.read()).effectiveConfig; } -async function readEffectiveAgentConfig(config: Pick): Promise { - return effectiveAgentConfig(process.env, await readEffectiveConfig(config)); -} - function invalidatePiWebStatusOnWrite(config: PiWebConfigService, statusCache: Pick): PiWebConfigService { return { read: () => config.read(), @@ -140,6 +142,15 @@ function invalidatePiWebStatusOnWrite(config: PiWebConfigService, statusCache: P }; } +async function withProfileDependency(reply: FastifyReply, operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + if (!(error instanceof ActiveAgentProfileAccessError)) throw error; + return reply.code(503).send({ error: error.message }); + } +} + export async function buildApp(deps: AppDependencies = {}): Promise { const app = Fastify({ logger: deps.logger ?? true, ...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }) }); // Vite proxies development API requests here, while production and machine-scoped @@ -155,19 +166,19 @@ export async function buildApp(deps: AppDependencies = {}): Promise readEffectiveConfig(configService); - const readAgentConfig = () => readEffectiveAgentConfig(configService); + const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient(); + const agentProfileProvider = deps.agentProfileProvider ?? new SessionDaemonActiveAgentProfileProvider(sessionDaemon); const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService({ configProvider: readConfig, + agentDirProvider: async () => (await requireActiveAgentProfile(agentProfileProvider)).dir, }); - const piPackages = deps.piPackages ?? createDefaultPiPackageService(process.cwd(), (await readAgentConfig()).dir); - const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient(); + const piPackages = deps.piPackages ?? createActiveProfilePiPackageService(agentProfileProvider); const piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache( async ({ force }) => { - const agent = await readAgentConfig(); + const activeAgentProfile = await agentProfileProvider.getActiveAgentProfile(); return getPiWebStatus(sessionDaemon, { forceReleaseCheck: force, - agentCommand: agent.command, - agentDir: agent.dir, + ...(activeAgentProfile.status === "available" ? { activeAgentProfile: activeAgentProfile.profile } : {}), }); }, { onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); } }, @@ -176,26 +187,28 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebRuntime(sessionDaemon), }); - app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest()); + app.get("/pi-web-plugins/manifest.json", async (_request, reply) => withProfileDependency(reply, () => piWebPlugins.manifest())); app.get<{ Params: { pluginId: string; "*": string } }>("/pi-web-plugins/:pluginId/*", async (request, reply) => { if (await proxyMachinePluginAsset(machines, request.params.pluginId, request.params["*"], request.url, reply)) return; - const asset = await piWebPlugins.readAsset(request.params.pluginId, request.params["*"]); - if (asset === undefined) return reply.code(404).send({ error: "Plugin asset not found" }); - return reply.type(asset.contentType).send(asset.content); + return withProfileDependency(reply, async () => { + const asset = await piWebPlugins.readAsset(request.params.pluginId, request.params["*"]); + if (asset === undefined) return reply.code(404).send({ error: "Plugin asset not found" }); + return reply.type(asset.contentType).send(asset.content); + }); }); app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1" ? piWebStatusCache.refresh({ force: true }) : piWebStatusCache.get()); app.get("/api/pi-web/version", async () => { - const agent = await readAgentConfig(); - return getPiWebVersionStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir }); + const activeAgentProfile = await agentProfileProvider.getActiveAgentProfile(); + return getPiWebVersionStatus(sessionDaemon, activeAgentProfile.status === "available" ? { activeAgentProfile: activeAgentProfile.profile } : {}); }); app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon)); - app.get("/api/plugins", async () => piWebPlugins.plugins()); - app.get("/api/machines/local/plugins", async () => piWebPlugins.plugins()); + app.get("/api/plugins", async (_request, reply) => withProfileDependency(reply, () => piWebPlugins.plugins())); + app.get("/api/machines/local/plugins", async (_request, reply) => withProfileDependency(reply, () => piWebPlugins.plugins())); registerPiPackageRoutes(app, piPackages); registerPiPackageRoutes(app, piPackages, "/api/machines/local"); const invalidatingConfigService = invalidatePiWebStatusOnWrite(configService, piWebStatusCache); diff --git a/src/server/piPackageRoutes.test.ts b/src/server/piPackageRoutes.test.ts index 18bbb20..ed603d4 100644 --- a/src/server/piPackageRoutes.test.ts +++ b/src/server/piPackageRoutes.test.ts @@ -1,6 +1,7 @@ import Fastify, { type FastifyInstance } from "fastify"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PiPackageInfo } from "../shared/apiTypes.js"; +import { ActiveAgentProfileAccessError } from "./activeAgentProfileProvider.js"; import type { PiPackageService } from "./piPackageService.js"; import { registerPiPackageRoutes } from "./piPackageRoutes.js"; @@ -95,6 +96,15 @@ describe("registerPiPackageRoutes", () => { expect(serviceMocks.update).not.toHaveBeenCalled(); }); + it("returns 503 when the daemon-owned active profile is unavailable", async () => { + serviceMocks.list.mockRejectedValueOnce(new ActiveAgentProfileAccessError({ status: "unavailable", error: "connect ECONNREFUSED" })); + + const response = await app.inject({ method: "GET", url: "/api/pi-packages" }); + + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ error: "Active agent profile is unavailable: connect ECONNREFUSED" }); + }); + it("returns stable 500 errors for package-manager failures", async () => { serviceMocks.install.mockRejectedValueOnce(new Error("install failed")); diff --git a/src/server/piPackageRoutes.ts b/src/server/piPackageRoutes.ts index f6c01b4..444833f 100644 --- a/src/server/piPackageRoutes.ts +++ b/src/server/piPackageRoutes.ts @@ -1,10 +1,11 @@ import type { FastifyInstance, FastifyReply } from "fastify"; import type { PiPackageScope } from "../shared/apiTypes.js"; -import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js"; +import { ActiveAgentProfileAccessError } from "./activeAgentProfileProvider.js"; +import type { PiPackageService } from "./piPackageService.js"; class PiPackageRequestValidationError extends Error {} -export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService(), prefix = "/api"): void { +export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService, prefix = "/api"): void { const routePrefix = normalizeRoutePrefix(prefix); app.get(`${routePrefix}/pi-packages`, async (_request, reply) => { @@ -79,7 +80,11 @@ function requireRequestObject(value: unknown): Record { } function sendPiPackageError(reply: FastifyReply, error: unknown): FastifyReply { - const status = error instanceof PiPackageRequestValidationError ? 400 : 500; + const status = error instanceof PiPackageRequestValidationError + ? 400 + : error instanceof ActiveAgentProfileAccessError + ? 503 + : 500; return reply.code(status).send({ error: errorMessage(error) }); } diff --git a/src/server/piPackageService.test.ts b/src/server/piPackageService.test.ts index 2bcd536..c6a0600 100644 --- a/src/server/piPackageService.test.ts +++ b/src/server/piPackageService.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { PiPackageInfo } from "../shared/apiTypes.js"; -import { DefaultPiPackageService, type PiPackageManagerPort } from "./piPackageService.js"; +import { type ActiveAgentProfileProvider } from "./activeAgentProfileProvider.js"; +import { ActiveProfilePiPackageService, DefaultPiPackageService, type PiPackageManagerPort, type PiPackageService } from "./piPackageService.js"; function fakeManager(packages: PiPackageInfo[] = []) { const listConfiguredPackages = vi.fn(() => packages); @@ -21,6 +22,44 @@ function deferred() { return { promise, resolve, reject }; } +describe("ActiveProfilePiPackageService", () => { + it("uses the daemon profile active when each package operation begins", async () => { + const getActiveAgentProfile = vi.fn() + .mockResolvedValueOnce(availableProfile("a", "/state/first")) + .mockResolvedValueOnce(availableProfile("b", "/state/second")); + const firstService = fakePiPackageService("first"); + const secondService = fakePiPackageService("second"); + const serviceForAgentDir = vi.fn((agentDir: string): PiPackageService => agentDir === "/state/first" ? firstService : secondService); + const service = new ActiveProfilePiPackageService({ getActiveAgentProfile }, serviceForAgentDir); + + await expect(service.list()).resolves.toEqual({ packages: [{ source: "first", scope: "user", filtered: false }] }); + await expect(service.install("npm:@acme/tools")).resolves.toMatchObject({ action: "install", source: "npm:@acme/tools", packages: [{ source: "second" }] }); + + expect(serviceForAgentDir).toHaveBeenNthCalledWith(1, "/state/first"); + expect(serviceForAgentDir).toHaveBeenNthCalledWith(2, "/state/second"); + expect(firstService.list).toHaveBeenCalledOnce(); + expect(secondService.install).toHaveBeenCalledWith("npm:@acme/tools"); + }); + + it.each(["unavailable", "invalid"] as const)("fails closed without constructing a package manager when the profile is %s", async (status) => { + const activeAgentProfile: ActiveAgentProfileProvider = { + getActiveAgentProfile: () => Promise.resolve({ status, error: `${status} profile` }), + }; + const serviceForAgentDir = vi.fn<(agentDir: string) => PiPackageService>(); + const service = new ActiveProfilePiPackageService(activeAgentProfile, serviceForAgentDir); + + await expect(service.list()).rejects.toMatchObject({ + profileStatus: status, + message: `Active agent profile is ${status}: ${status} profile`, + }); + await expect(service.install("npm:@acme/tools")).rejects.toMatchObject({ + profileStatus: status, + message: `Active agent profile is ${status}: ${status} profile`, + }); + expect(serviceForAgentDir).not.toHaveBeenCalled(); + }); +}); + describe("DefaultPiPackageService", () => { it("lists configured Pi packages with source, scope, filtered status, and installed path", async () => { const fake = fakeManager([ @@ -201,3 +240,26 @@ describe("DefaultPiPackageService", () => { ]); }); }); + +function availableProfile(revisionCharacter: string, dir: string) { + return { + status: "available" as const, + profile: { + schemaVersion: 1 as const, + revision: `sha256:${revisionCharacter.repeat(64)}`, + command: `${revisionCharacter}-agent`, + dir, + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }, + }; +} + +function fakePiPackageService(source: string) { + const packages = [{ source, scope: "user" as const, filtered: false }]; + return { + list: vi.fn(() => Promise.resolve({ packages })), + install: vi.fn((installedSource: string) => Promise.resolve({ action: "install" as const, source: installedSource, packages })), + remove: vi.fn((removedSource: string, scope: "user" | "project" = "user") => Promise.resolve({ action: "remove" as const, source: removedSource, scope, removed: true, packages })), + update: vi.fn((updatedSource?: string) => Promise.resolve({ action: "update" as const, ...(updatedSource === undefined ? {} : { source: updatedSource }), packages })), + } satisfies PiPackageService; +} diff --git a/src/server/piPackageService.ts b/src/server/piPackageService.ts index 98472cc..5e93bb5 100644 --- a/src/server/piPackageService.ts +++ b/src/server/piPackageService.ts @@ -1,5 +1,6 @@ -import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; +import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent"; import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../shared/apiTypes.js"; +import { requireActiveAgentProfile, type ActiveAgentProfileProvider } from "./activeAgentProfileProvider.js"; export interface PiPackageManagerPort { listConfiguredPackages(): PiPackageInfo[]; @@ -16,6 +17,47 @@ export interface PiPackageService { update(source?: string): Promise; } +export type PiPackageServiceForAgentDir = (agentDir: string) => PiPackageService; + +export class ActiveProfilePiPackageService implements PiPackageService { + private mutationQueue: Promise = Promise.resolve(); + + constructor( + private readonly activeAgentProfile: ActiveAgentProfileProvider, + private readonly serviceForAgentDir: PiPackageServiceForAgentDir, + ) {} + + async list(): Promise { + return await this.withActiveService((service) => service.list()); + } + + install(source: string): Promise { + return this.enqueueMutation((service) => service.install(source)); + } + + remove(source: string, scope?: PiPackageScope): Promise { + return this.enqueueMutation((service) => service.remove(source, scope)); + } + + update(source?: string): Promise { + return this.enqueueMutation((service) => service.update(source)); + } + + private enqueueMutation(operation: (service: PiPackageService) => Promise): Promise { + const queuedMutation = this.mutationQueue.then(() => this.withActiveService(operation)); + this.mutationQueue = queuedMutation.then( + () => undefined, + () => undefined, + ); + return queuedMutation; + } + + private async withActiveService(operation: (service: PiPackageService) => Promise): Promise { + const profile = await requireActiveAgentProfile(this.activeAgentProfile); + return await operation(this.serviceForAgentDir(profile.dir)); + } +} + export class DefaultPiPackageService implements PiPackageService { private mutationQueue: Promise = Promise.resolve(); @@ -84,7 +126,11 @@ export class DefaultPiPackageService implements PiPackageService { } } -export function createDefaultPiPackageService(cwd = process.cwd(), agentDir = getAgentDir()): PiPackageService { +export function createActiveProfilePiPackageService(activeAgentProfile: ActiveAgentProfileProvider, cwd = process.cwd()): PiPackageService { + return new ActiveProfilePiPackageService(activeAgentProfile, (agentDir) => createDefaultPiPackageService(cwd, agentDir)); +} + +export function createDefaultPiPackageService(cwd: string, agentDir: string): PiPackageService { const settingsManager = SettingsManager.create(cwd, agentDir); const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); return new DefaultPiPackageService({ diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 5a0967c..0576a24 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, rm, writeFile, mkdir, symlink } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ActiveAgentProfileAccessError } from "./activeAgentProfileProvider.js"; import { PiWebPluginService, type PiPackageProvider } from "./piWebPluginService.js"; let tempDir: string; @@ -133,11 +134,11 @@ describe("PiWebPluginService", () => { expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u); }); - it("uses the current config provider for Pi package plugin discovery", async () => { + it("uses the active agent directory on every Pi package plugin discovery", async () => { const packageDir = join(tempDir, "pkg"); const initialAgentDir = join(tempDir, "initial-agent"); const updatedAgentDir = join(tempDir, "updated-agent"); - let currentConfig = { agent: { dir: initialAgentDir } }; + let activeAgentDir = initialAgentDir; await writePlugin(packageDir, { packageJson: { piWeb: { plugins: [{ id: "agent-package", module: "dist/plugin.js" }] } }, files: { "dist/plugin.js": "export default {};" }, @@ -145,15 +146,31 @@ describe("PiWebPluginService", () => { await mkdir(initialAgentDir, { recursive: true }); await mkdir(updatedAgentDir, { recursive: true }); await writeFile(join(updatedAgentDir, "settings.json"), `${JSON.stringify({ packages: [packageDir] }, null, 2)}\n`, "utf8"); - const service = new PiWebPluginService({ roots: [], cwd: tempDir, configProvider: () => currentConfig }); + const service = new PiWebPluginService({ roots: [], cwd: tempDir, agentDirProvider: () => activeAgentDir }); await expect(service.manifest()).resolves.toEqual({ plugins: [] }); - currentConfig = { agent: { dir: updatedAgentDir } }; + activeAgentDir = updatedAgentDir; await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "agent-package", source: packageDir, scope: "user" }] }); }); + it("fails complete package-backed discovery closed while keeping known local assets independent", async () => { + const pluginDir = join(tempDir, "plugins", "local-only"); + await writePlugin(pluginDir, { + packageJson: { piWeb: { plugins: [{ id: "local-only", module: "pi-web-plugin.js" }] } }, + files: { "pi-web-plugin.js": "export default {};" }, + }); + const profileError = new ActiveAgentProfileAccessError({ status: "invalid", error: "missing descriptor" }); + const service = new PiWebPluginService({ + roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], + agentDirProvider: () => { throw profileError; }, + }); + + await expect(service.manifest()).rejects.toBe(profileError); + await expect(service.readAsset("local-only", "pi-web-plugin.js")).resolves.toMatchObject({ contentType: "application/javascript; charset=utf-8" }); + }); + it("refreshes Pi package plugin discovery after Pi package settings change", async () => { const agentDir = join(tempDir, "agent"); const firstPackageDir = join(tempDir, "first-package"); diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 8271d44..fe3ea53 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -3,7 +3,7 @@ import { readdir, readFile, realpath, stat } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent"; -import { effectiveAgentConfig, loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js"; +import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js"; import type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js"; import { isPiWebPluginId } from "../shared/pluginIds.js"; @@ -71,8 +71,8 @@ type ArraylessPluginRecord = Omit; export class DefaultPiPackageProvider implements PiPackageProvider { constructor( - private readonly cwd = process.cwd(), - private readonly agentDir = defaultAgentDirForCwd(cwd), + private readonly cwd: string, + private readonly agentDir: string, ) {} listPackages(): ConfiguredPiPackage[] { @@ -92,32 +92,24 @@ export class DefaultPiPackageProvider implements PiPackageProvider { } } -function defaultAgentDirForCwd(cwd: string): string { - return effectiveAgentConfig(process.env, loadPiWebConfig({ cwd }).config, cwd).dir; -} - export class PiWebPluginService { - private readonly cwd: string; private readonly roots: LocalPluginRoot[]; private readonly agentDir: string | undefined; private readonly agentDirProvider: (() => string | Promise) | undefined; + private readonly staticPackageProvider: PiPackageProvider | undefined; private readonly packageProviderForAgentDir: ((agentDir: string) => PiPackageProvider) | undefined; private readonly configProvider: () => PiWebConfig | Promise; constructor(options: PiWebPluginServiceOptions = {}) { const cwd = options.cwd ?? process.cwd(); - this.cwd = cwd; this.roots = options.roots ?? defaultPluginRoots(cwd); this.agentDir = options.agentDir; this.agentDirProvider = options.agentDirProvider; const packageProvider = options.packageProvider; - if (packageProvider === false) { - this.packageProviderForAgentDir = undefined; - } else if (packageProvider !== undefined) { - this.packageProviderForAgentDir = () => packageProvider; - } else { - this.packageProviderForAgentDir = (agentDir) => new DefaultPiPackageProvider(cwd, agentDir); - } + this.staticPackageProvider = packageProvider === false || packageProvider === undefined ? undefined : packageProvider; + this.packageProviderForAgentDir = packageProvider === false || packageProvider !== undefined + ? undefined + : (agentDir) => new DefaultPiPackageProvider(cwd, agentDir); this.configProvider = options.configProvider ?? (() => loadPiWebConfig({ cwd }).config); } @@ -131,13 +123,13 @@ export class PiWebPluginService { async plugins(): Promise { const config = await this.configProvider(); - const plugins = await this.discoverPlugins(config); + const plugins = await this.discoverPlugins(); return { plugins: plugins.map((plugin) => this.pluginInfo(plugin, config)) }; } async readAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> { if (!isPiWebPluginId(pluginId)) return undefined; - const plugin = (await this.discoverPlugins()).find((candidate) => candidate.id === pluginId); + const plugin = await this.findPlugin(pluginId); if (plugin === undefined) return undefined; const resolved = resolve(plugin.root, assetPath); @@ -164,26 +156,37 @@ export class PiWebPluginService { }; } - private async discoverPlugins(config?: PiWebConfig): Promise { + private async discoverPlugins(): Promise { const records = new Map(); for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin); - const packageProvider = await this.packageProvider(config); + const packageProvider = await this.currentPackageProvider(); if (packageProvider !== undefined) { for (const plugin of await this.discoverPiPackagePlugins(packageProvider)) addUnique(records, plugin); } return [...records.values()].sort((left, right) => left.id.localeCompare(right.id)); } - private async packageProvider(config?: PiWebConfig): Promise { - if (this.packageProviderForAgentDir === undefined) return undefined; - return this.packageProviderForAgentDir(await this.currentAgentDir(config)); + private async findPlugin(pluginId: string): Promise { + const localPlugin = (await this.discoverLocalPlugins()).find((candidate) => candidate.id === pluginId); + if (localPlugin !== undefined) return localPlugin; + + const packageProvider = await this.currentPackageProvider(); + if (packageProvider === undefined) return undefined; + const records = new Map(); + for (const plugin of await this.discoverPiPackagePlugins(packageProvider)) addUnique(records, plugin); + return records.get(pluginId); } - private async currentAgentDir(config?: PiWebConfig): Promise { + private async currentPackageProvider(): Promise { + if (this.staticPackageProvider !== undefined) return this.staticPackageProvider; + if (this.packageProviderForAgentDir === undefined) return undefined; + return this.packageProviderForAgentDir(await this.currentAgentDir()); + } + + private async currentAgentDir(): Promise { if (this.agentDirProvider !== undefined) return await this.agentDirProvider(); if (this.agentDir !== undefined) return this.agentDir; - const currentConfig = config ?? await this.configProvider(); - return effectiveAgentConfig(process.env, currentConfig, this.cwd).dir; + throw new Error("Pi package plugin discovery requires an explicit active agent directory"); } private async discoverLocalPlugins(): Promise { diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index 8c9f685..7b81e0a 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -14,6 +14,7 @@ const originalDockerRuntime = process.env["PI_WEB_DOCKER_RUNTIME"]; const originalDockerMode = process.env["PI_WEB_DOCKER_MODE"]; const originalDockerInstallDir = process.env["PI_WEB_DOCKER_INSTALL_DIR"]; const originalDockerDevRepoRoot = process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"]; +const originalAgentDir = process.env["PI_WEB_AGENT_DIR"]; afterEach(() => { restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck); @@ -23,6 +24,7 @@ afterEach(() => { restoreEnv("PI_WEB_DOCKER_MODE", originalDockerMode); restoreEnv("PI_WEB_DOCKER_INSTALL_DIR", originalDockerInstallDir); restoreEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", originalDockerDevRepoRoot); + restoreEnv("PI_WEB_AGENT_DIR", originalAgentDir); vi.restoreAllMocks(); }); @@ -64,7 +66,7 @@ describe("PI WEB status", () => { capabilities: [], }); - const status = await getPiWebVersionStatus(daemon, { agentCommand: "alt-agent", agentDir }); + const status = await getPiWebVersionStatus(daemon, { activeAgentProfile: activeProfile("a", "alt-agent", agentDir) }); expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: process.cwd(), scope: "user" }); } finally { @@ -72,6 +74,29 @@ describe("PI WEB status", () => { } }); + it("does not fall back to the web process environment when no active profile is available", async () => { + disableDockerRuntimeEnv(); + const agentDir = await tempHome(); + try { + await installConfiguredPiWebPackage(agentDir); + process.env["PI_WEB_AGENT_DIR"] = agentDir; + const daemon = daemonWithRuntime({ + component: "sessiond", + label: "Session daemon", + runtimeVersion: "1.202605.7", + available: true, + capabilities: [], + }); + + const status = await getPiWebVersionStatus(daemon); + + expect(status.components.web.installation?.kind).not.toBe("pi-package"); + expect(status.components.sessiond.installation?.kind).not.toBe("pi-package"); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } + }); + it("reports web-only capabilities from the web runtime", async () => { const daemon = daemonWithComponent({ component: "sessiond", @@ -161,6 +186,19 @@ describe("PI WEB status", () => { expect(status.messages.map((message) => message.id)).toContain("sessiond-stale"); }); + it("suppresses Pi package update planning without an active companion command", async () => { + const hasCommand = vi.fn(() => Promise.resolve(true)); + + const updateCommand = await updateCommandFor( + { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, + "pi-web restart", + { agentCommand: undefined, hasCommand }, + ); + + expect(updateCommand).toBeUndefined(); + expect(hasCommand).not.toHaveBeenCalled(); + }); + it("shell-quotes pi-package agent update commands", async () => { const updateCommand = await updateCommandFor( { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, @@ -276,6 +314,16 @@ describe("PI WEB status", () => { }); }); +function activeProfile(revisionCharacter: string, command: string, dir: string) { + return { + schemaVersion: 1 as const, + revision: `sha256:${revisionCharacter.repeat(64)}`, + command, + dir, + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }; +} + function npmVersionResponse(version: string): Response { return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } }); } diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts index dbe7ece..56c2a92 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -6,12 +6,11 @@ import { homedir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent"; -import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js"; +import type { ActiveAgentProfileDescriptor, PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js"; import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js"; import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js"; import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; -import { effectiveAgentConfig } from "../config.js"; import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; @@ -77,21 +76,10 @@ interface PiWebStatusDaemon { export interface PiWebStatusOptions { forceReleaseCheck?: boolean; - agentCommand?: string; - agentDir?: string; + activeAgentProfile?: ActiveAgentProfileDescriptor; hasCommand?: (command: string) => Promise; } -function effectiveStatusAgentConfig(options: PiWebStatusOptions): { command: string; dir: string } { - const agent = effectiveAgentConfig(process.env, { - agent: { - ...(options.agentCommand === undefined ? {} : { command: options.agentCommand }), - ...(options.agentDir === undefined ? {} : { dir: options.agentDir }), - }, - }); - return { command: agent.command, dir: agent.dir }; -} - const latestReleaseLookupCache = createPiWebReleaseLookupCache(fetchLatestNpmVersion); const runtimePackageInfo = readPackageInfoSync(); @@ -119,7 +107,7 @@ export async function getPiWebRuntime(daemon: PiWebStatusDaemon = new SessionDae export async function getPiWebComponentStatus(component: PiWebServiceComponent, options: PiWebStatusOptions = {}): Promise { const [installed, installation] = await Promise.all([ readInstalledPackageInfo(), - detectPiWebInstallation(options.agentDir), + detectPiWebInstallation(options.activeAgentProfile?.dir), ]); const runtimeVersion = runtimePackageInfo?.version ?? DEFAULT_VERSION; const installedVersion = installed?.version; @@ -147,12 +135,11 @@ export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new Sess } export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise { - const agent = effectiveStatusAgentConfig(options); - const versionStatus = await getPiWebVersionStatus(daemon, { ...options, agentDir: agent.dir }); + const versionStatus = await getPiWebVersionStatus(daemon, options); const { web, sessiond } = versionStatus.components; const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true); const components = { web, sessiond }; - const commands = await commandsFor(components, { agentCommand: agent.command, hasCommand: options.hasCommand ?? hasCommand }); + const commands = await commandsFor(components, { agentCommand: options.activeAgentProfile?.command, hasCommand: options.hasCommand ?? hasCommand }); const messages = buildMessages(components, release, commands); return { ...versionStatus, @@ -209,11 +196,12 @@ function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined async function detectPiWebInstallation(agentDir?: string): Promise { const docker = detectDockerInstallation(); if (docker !== undefined) return docker; - const resolvedAgentDir = agentDir ?? effectiveAgentConfig().dir; const root = packageRootPath(); const realRoot = await realPathOrSelf(root); - const piPackage = await detectPiPackageInstallation(realRoot, root, resolvedAgentDir); - if (piPackage !== undefined) return piPackage; + if (agentDir !== undefined) { + const piPackage = await detectPiPackageInstallation(realRoot, root, agentDir); + if (piPackage !== undefined) return piPackage; + } const npmGlobal = await detectNpmGlobalInstallation(realRoot, root); if (npmGlobal !== undefined) return npmGlobal; return { kind: "local", path: root }; @@ -427,7 +415,7 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise { return version; } -async function commandsFor(components: PiWebStatusResponse["components"], options: { agentCommand: string; hasCommand: (command: string) => Promise }): Promise { +async function commandsFor(components: PiWebStatusResponse["components"], options: { agentCommand: string | undefined; hasCommand: (command: string) => Promise }): Promise { const installation = preferredInstallation(components); if (installation?.kind === "docker") return dockerCommands(installation); @@ -478,10 +466,10 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv return cliCommands.restart ?? serviceCommands.restart; } -export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { agentCommand: string; hasCommand: (command: string) => Promise }): Promise { +export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { agentCommand: string | undefined; hasCommand: (command: string) => Promise }): Promise { if (restartCommand === undefined) return undefined; if (installation?.kind === "pi-package") { - if (!(await options.hasCommand(options.agentCommand))) return undefined; + if (options.agentCommand === undefined || !(await options.hasCommand(options.agentCommand))) return undefined; return `${shellQuote(options.agentCommand)} update ${shellQuote(installation.source ?? PI_WEB_NPM_SOURCE)} && ${restartCommand}`; } if (installation?.kind === "local" && installation.path !== undefined) { diff --git a/src/sessiond/sessionDaemonClient.ts b/src/sessiond/sessionDaemonClient.ts index 06b69d3..ca097b2 100644 --- a/src/sessiond/sessionDaemonClient.ts +++ b/src/sessiond/sessionDaemonClient.ts @@ -9,6 +9,10 @@ export type SessionDaemonAgentProfileResult = | { status: "unavailable"; error: string } | { status: "invalid"; error: string }; +export interface SessionDaemonRequestClient { + request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }>; +} + export class SessionDaemonClient { private readonly baseUrl = sessiondHttpUrl(); private readonly socketPath = sessiondSocketPath(); @@ -19,33 +23,8 @@ export class SessionDaemonClient { return this.requestSocket(method, path, payload); } - async getActiveAgentProfile(): Promise { - let response: Awaited>; - try { - response = await this.request("GET", "/runtime"); - } catch (error) { - return { status: "unavailable", error: errorMessage(error) }; - } - - if (response.statusCode < 200 || response.statusCode >= 300) { - return { status: "unavailable", error: `session daemon runtime request returned HTTP ${String(response.statusCode)}` }; - } - - let value: unknown; - try { - value = response.body === "" ? undefined : JSON.parse(response.body); - } catch { - return { status: "invalid", error: "session daemon runtime response was not valid JSON" }; - } - - const runtime = parsePiWebRuntimeComponent(value); - if (runtime?.component !== "sessiond") { - return { status: "invalid", error: "session daemon runtime response was invalid" }; - } - if (runtime.activeAgentProfile === undefined) { - return { status: "invalid", error: "session daemon runtime response did not include an active agent profile" }; - } - return { status: "available", profile: runtime.activeAgentProfile }; + getActiveAgentProfile(): Promise { + return getSessionDaemonActiveAgentProfile(this); } connectWebSocket(path: string): WebSocket { @@ -103,6 +82,35 @@ export class SessionDaemonClient { } } +export async function getSessionDaemonActiveAgentProfile(client: SessionDaemonRequestClient): Promise { + let response: Awaited>; + try { + response = await client.request("GET", "/runtime"); + } catch (error) { + return { status: "unavailable", error: errorMessage(error) }; + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + return { status: "unavailable", error: `session daemon runtime request returned HTTP ${String(response.statusCode)}` }; + } + + let value: unknown; + try { + value = response.body === "" ? undefined : JSON.parse(response.body); + } catch { + return { status: "invalid", error: "session daemon runtime response was not valid JSON" }; + } + + const runtime = parsePiWebRuntimeComponent(value); + if (runtime?.component !== "sessiond") { + return { status: "invalid", error: "session daemon runtime response was invalid" }; + } + if (runtime.activeAgentProfile === undefined) { + return { status: "invalid", error: "session daemon runtime response did not include an active agent profile" }; + } + return { status: "available", profile: runtime.activeAgentProfile }; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } From adc2e297a4d4efd54336bae1890629d5a02c52bd Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 13 Jul 2026 23:39:49 +0200 Subject: [PATCH 7/8] fix: harden agent profile boundaries --- src/cli.test.ts | 5 + src/cli.ts | 4 +- src/config.test.ts | 71 +++++++++--- src/config.ts | 105 +++++++++++++----- src/server/configRoutes.test.ts | 31 +++++- src/server/configRoutes.ts | 42 ++----- src/server/machines/machineProxyRoutes.ts | 2 +- src/server/piWebStatus.test.ts | 28 ++++- src/server/piWebStatus.ts | 13 ++- src/server/sessiond.ts | 16 +-- .../sessions/piSessionManagerGateway.test.ts | 38 +++++-- .../sessions/piSessionManagerGateway.ts | 56 +++++----- .../piSessionService.archiveCleanup.test.ts | 11 ++ .../piSessionService.lifecycle.test.ts | 24 +++- .../piSessionService.promptQueue.test.ts | 14 +++ .../piSessionService.spawnSession.test.ts | 5 + .../piSessionService.spawnSubsession.test.ts | 20 ++++ src/server/sessions/piSessionService.ts | 12 +- src/server/sessions/sessionRoutes.test.ts | 4 +- src/sessiond/activeAgentProfile.test.ts | 8 +- src/sessiond/activeAgentProfile.ts | 14 ++- src/sessiond/sessionDaemonClient.test.ts | 13 +++ src/sessiond/sessionDaemonClient.ts | 4 + src/shared/activeAgentProfile.ts | 26 ++++- src/shared/piWebStatusParsing.test.ts | 3 + 25 files changed, 419 insertions(+), 150 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 732197d..80fa32e 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -68,6 +68,11 @@ describe("agentCommandForChecks", () => { delete process.env["PI_WEB_AGENT_COMMAND"]; expect(agentCommandForChecks()).toBe("acme-agent"); + expect(agentCommandForChecks({ + PI_WEB_CONFIG: configPath, + PI_WEB_AGENT_COMMAND: "environment-agent", + PI_WEB_AGENT_DIR: join(dir, "environment-agent-state"), + })).toBe("environment-agent"); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/src/cli.ts b/src/cli.ts index a3329e7..3b2a0e1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { homedir, userInfo } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { defaultPiWebConfigPath, defaultPiWebDataDir, effectiveAgentConfig, effectivePiWebConfig, examplePiWebConfig } from "./config.js"; +import { defaultPiWebConfigPath, defaultPiWebDataDir, effectivePiWebConfig, examplePiWebConfig } from "./config.js"; import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js"; import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js"; import { @@ -780,7 +780,7 @@ function nodeVersionCheck(): string { } export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): string { - return effectiveAgentConfig(env, effectivePiWebConfig({ env }).config).command; + return effectivePiWebConfig({ env }).config.agent.command; } function generalDoctorChecks(): Check[] { diff --git a/src/config.test.ts b/src/config.test.ts index c4523c2..431ecf1 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -69,23 +69,50 @@ describe("PI WEB config persistence", () => { expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "acme-agent", dir: "/opt/acme-agent/state" }); }); - it("defaults to the Pi agent directory only for Pi commands and launchers", () => { - expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "/tmp/pi.cmd" } })).toMatchObject({ - command: "/tmp/pi.cmd", - dir: join(tempDir, ".home", ".pi", "agent"), - sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], - }); + it("defaults to the Pi agent directory only for canonical Pi companion names", () => { + for (const command of ["pi", "pi.cmd"]) { + expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command } })).toMatchObject({ + command, + dir: join(tempDir, ".home", ".pi", "agent"), + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], + }); + } }); - it("requires an explicit agent directory for non-Pi commands", () => { - expect(() => effectiveAgentConfig({}, { agent: { command: "acme-agent" } })).toThrow('PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is "acme-agent"'); - expect(() => savePiWebConfig({ agent: { command: "acme-agent" } }, testOptions())).toThrow('PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is "acme-agent"'); + it("requires explicit state for alternate names and absolute Pi launchers", () => { + const absolutePiCommand = join(tempDir, "bin", "pi"); + for (const command of ["acme-agent", absolutePiCommand]) { + expect(() => effectiveAgentConfig({}, { agent: { command } })).toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`); + expect(() => savePiWebConfig({ agent: { command } }, testOptions())).toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`); + } + }); + + it("accepts safe bare executable names and host-absolute executable paths", () => { + const absoluteCommand = join(tempDir, "bin", "acme-agent"); + const agentDir = join(tempDir, "state", "acme"); + + expect(effectiveAgentConfig({}, { agent: { command: "acme-agent", dir: agentDir } })).toMatchObject({ command: "acme-agent", dir: agentDir }); + expect(effectiveAgentConfig({}, { agent: { command: absoluteCommand, dir: agentDir } })).toMatchObject({ command: absoluteCommand, dir: agentDir }); + }); + + it.each(["./acme-agent", "bin/acme-agent", "../acme-agent", "node acme-agent.js", "acme-agent;other", "-acme-agent"])("rejects unsafe or workspace-relative agent command %j", (command) => { + expect(() => savePiWebConfig({ agent: { command, dir: join(tempDir, "agent") } }, testOptions())).toThrow("safe bare executable name or host-absolute executable path"); + }); + + it.skipIf(process.platform === "win32")("rejects foreign-platform absolute agent command and state paths", () => { + expect(() => effectiveAgentConfig({}, { agent: { command: "C:\\tools\\acme-agent.exe", dir: join(tempDir, "agent") } })).toThrow("safe bare executable name or host-absolute executable path"); + expect(() => effectiveAgentConfig({}, { agent: { command: "acme-agent", dir: "C:\\profiles\\acme" } })).toThrow("agent.dir must be a host-absolute path"); + }); + + it("rejects home expansion that would create a workspace-relative agent directory", () => { + expect(() => effectiveAgentConfig({ HOME: "relative-home" })).toThrow("agent.dir must be a host-absolute path"); }); it("resolves explicit alternate agent command and state directory settings", () => { expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "acme-agent", dir: "~/agent-profiles/acme" } })).toMatchObject({ command: "acme-agent", dir: join(tempDir, ".home", "agent-profiles", "acme"), + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], }); }); @@ -118,21 +145,29 @@ describe("PI WEB config persistence", () => { }); }); - it("keeps legacy Pi env directory overrides scoped to Pi commands", () => { - expect(effectiveAgentConfig({ - PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"), - }, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({ - dir: join(tempDir, "pi-env-agent"), - }); + 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 }); - expect(() => effectiveAgentConfig({ - PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"), - }, { agent: { command: "acme-agent" } })).toThrow('PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is "acme-agent"'); + for (const command of ["acme-agent", join(tempDir, "bin", "pi")]) { + expect(() => effectiveAgentConfig({ PI_CODING_AGENT_DIR: legacyDir }, { agent: { command } })) + .toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`); + } }); it("uses only explicit session directory env keys", () => { expect(agentSessionDirEnvKeys()).toEqual(["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]); expect(effectiveAgentConfig({ HOME: join(tempDir, ".home"), PI_WEB_AGENT_COMMAND: "acme-agent", PI_WEB_AGENT_DIR: join(tempDir, "agent") }).sessionDirEnvKeys).toEqual(["PI_WEB_AGENT_SESSION_DIR"]); + expect(agentSessionDirEnvKeys(join(tempDir, "bin", "pi"))).toEqual(["PI_WEB_AGENT_SESSION_DIR"]); + }); + + it("rejects unknown nested agent keys instead of erasing them", async () => { + const original = { agent: { command: "acme-agent", dir: join(tempDir, "agent"), futureSetting: true } }; + await writeFile(configPath, `${JSON.stringify(original, null, 2)}\n`, "utf8"); + + expect(() => loadPiWebConfig(testOptions())).toThrow('PI WEB config agent contains unknown key "futureSetting"'); + expect(() => savePiWebConfig({ port: 9000 }, testOptions())).toThrow('PI WEB config agent contains unknown key "futureSetting"'); + expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual(original); }); it("exposes the default upload folder in the effective config", () => { diff --git a/src/config.ts b/src/config.ts index a41eeb5..21286cd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, normalize, resolve } from "node:path"; import type { PiWebConfigValues } from "./shared/apiTypes.js"; import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js"; @@ -59,12 +59,12 @@ export interface EffectivePiWebAgentConfig { sessionDirEnvKeys: string[]; } -export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick = {}, cwd = process.cwd()): EffectivePiWebAgentConfig { - const command = parseAgentCommand(envValue(env, PI_WEB_AGENT_COMMAND_ENV) ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment"); - const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? (isPiCommand(command) ? envValue(env, PI_CODING_AGENT_DIR_ENV) : undefined) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env); +export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick = {}): EffectivePiWebAgentConfig { + const command = parseAgentCommand(envValue(env, PI_WEB_AGENT_COMMAND_ENV) ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment", "current"); + const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? (usesDefaultPiStatePolicy(command) ? envValue(env, PI_CODING_AGENT_DIR_ENV) : undefined) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env); return { command, - dir: resolveAgentDirPath(configuredDir, env, cwd, "agent.dir", "environment"), + dir: resolveAgentDirPath(configuredDir, env, "agent.dir", "environment"), sessionDirEnvKeys: agentSessionDirEnvKeys(command), }; } @@ -72,12 +72,12 @@ 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, - ...(isPiCommand(command) ? [PI_CODING_AGENT_SESSION_DIR_ENV] : []), + ...(usesDefaultPiStatePolicy(command) ? [PI_CODING_AGENT_SESSION_DIR_ENV] : []), ]); } export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { - return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || (isPiCommand(command) && isEnvSet(env[PI_CODING_AGENT_DIR_ENV])); + return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || (usesDefaultPiStatePolicy(command) && isEnvSet(env[PI_CODING_AGENT_DIR_ENV])); } export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { @@ -131,7 +131,7 @@ export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options: const port = env["PI_WEB_PORT"] ?? env["PORT"]; const allowedHosts = env["PI_WEB_ALLOWED_HOSTS"]; const maxUpload = env["PI_WEB_MAX_UPLOAD_BYTES"]; - const agent = effectiveAgentConfig(env, loaded.config, options.cwd ?? process.cwd()); + const agent = effectiveAgentConfig(env, loaded.config); return { ...loaded, config: { @@ -155,8 +155,9 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): const env = options.env ?? process.env; const path = piWebConfigPath(env, options.cwd ?? process.cwd()); const normalized = parsePiWebConfig(piWebConfigRecord(config), path); - effectiveAgentConfig(env, normalized, options.cwd ?? process.cwd()); + effectiveAgentConfig(env, normalized); const existing = readExistingConfigObject(path); + if (existing["agent"] !== undefined) parseAgentConfig(existing["agent"], path); delete existing["host"]; delete existing["port"]; delete existing["allowedHosts"]; @@ -260,33 +261,72 @@ function parseString(value: unknown, key: string, path: string): string { return value; } -function parseAgentConfig(value: unknown, path: string): NonNullable { +const AGENT_CONFIG_KEYS = new Set(["command", "dir"]); +const SAFE_BARE_AGENT_COMMAND_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._+-]*$/u; + +export type AgentPathHost = "current" | "portable"; + +export function parseAgentConfig(value: unknown, path: string, pathHost: AgentPathHost = "current"): NonNullable { if (!isRecord(value)) throw new Error(`PI WEB config agent must be an object: ${path}`); + const unknownKey = Object.keys(value).find((key) => !AGENT_CONFIG_KEYS.has(key)); + if (unknownKey !== undefined) throw new Error(`PI WEB config agent contains unknown key ${JSON.stringify(unknownKey)}: ${path}`); const command = value["command"]; const dir = value["dir"]; return { - ...(command !== undefined ? { command: parseAgentCommand(command, "agent.command", path) } : {}), - ...(dir !== undefined ? { dir: parseAgentDir(dir, "agent.dir", path) } : {}), + ...(command !== undefined ? { command: parseAgentCommand(command, "agent.command", path, pathHost) } : {}), + ...(dir !== undefined ? { dir: parseAgentDir(dir, "agent.dir", path, pathHost) } : {}), }; } -function parseAgentCommand(value: unknown, key: string, path: string): string { +function parseAgentCommand(value: unknown, key: string, path: string, pathHost: AgentPathHost): string { const command = parseString(value, key, path).trim(); - if (command === "") throw new Error(`PI WEB config ${key} must be a non-empty string: ${path}`); - if (/[\s;&|`$<>]/u.test(command)) throw new Error(`PI WEB config ${key} must be a single command name or path without shell metacharacters: ${path}`); + if (!isSafeAgentCommand(command, pathHost)) { + const absoluteLabel = pathHost === "current" ? "host-absolute" : "absolute"; + throw new Error(`PI WEB config ${key} must be a safe bare executable name or ${absoluteLabel} executable path: ${path}`); + } return command; } -function parseAgentDir(value: unknown, key: string, path: string): string { - const dir = parseString(value, key, path); - if (!isAbsoluteOrHomePath(dir)) throw new Error(`PI WEB config ${key} must be an absolute path or start with ~: ${path}`); +function parseAgentDir(value: unknown, key: string, path: string, pathHost: AgentPathHost): string { + const dir = parseString(value, key, path).trim(); + const isAbsoluteDir = pathHost === "current" ? isHostAbsoluteAgentDir(dir) : isPortableAbsoluteAgentPath(dir); + if (!isAbsoluteDir && !isHomePath(dir, pathHost)) { + const absoluteLabel = pathHost === "current" ? "a host-absolute" : "an absolute"; + throw new Error(`PI WEB config ${key} must be ${absoluteLabel} path or start with ~: ${path}`); + } return dir; } -function resolveAgentDirPath(value: string, env: NodeJS.ProcessEnv, cwd: string, key: string, path: string): string { - const parsed = parseAgentDir(value, key, path); +function resolveAgentDirPath(value: string, env: NodeJS.ProcessEnv, key: string, path: string): string { + const parsed = parseAgentDir(value, key, path, "current"); const expanded = expandHomePath(parsed, env); - return isAbsoluteLike(expanded) ? expanded : resolve(cwd, expanded); + if (!isHostAbsoluteAgentDir(expanded)) { + throw new Error(`PI WEB config ${key} must resolve to a host-absolute path: ${path}`); + } + return normalize(expanded); +} + +export function isSafeAgentCommandForHost(value: string): boolean { + return isSafeAgentCommand(value, "current"); +} + +function isSafeAgentCommand(value: string, pathHost: AgentPathHost): boolean { + if (value === "" || value !== value.trim() || value.includes("\0") || /[\s;&|`$<>]/u.test(value)) return false; + if (SAFE_BARE_AGENT_COMMAND_PATTERN.test(value)) return true; + if (pathHost === "current") return isAbsolute(value) && basename(value) !== ""; + return isAbsoluteLike(value) && value.split(/[\\/]/u).at(-1) !== ""; +} + +export function isHostAbsoluteAgentDir(value: string): boolean { + return isSafeAgentDirPath(value) && isAbsolute(value); +} + +function isPortableAbsoluteAgentPath(value: string): boolean { + return isSafeAgentDirPath(value) && isAbsoluteLike(value); +} + +function isSafeAgentDirPath(value: string): boolean { + return value !== "" && value === value.trim() && !hasControlCharacter(value); } function parsePort(value: unknown, key: string, path = "environment"): number { @@ -339,23 +379,27 @@ function parseWorkspaceRelativeFolder(value: unknown, key: string, path: string) } -function isAbsoluteOrHomePath(value: string): boolean { - return value === "~" || value.startsWith("~/") || value.startsWith("~\\") || isAbsoluteLike(value); +function isHomePath(value: string, pathHost: AgentPathHost): boolean { + return value === "~" || value.startsWith("~/") || ((pathHost === "portable" || process.platform === "win32") && value.startsWith("~\\")); } function expandHomePath(value: string, env: NodeJS.ProcessEnv): string { const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir(); if (value === "~") return home; - if (value.startsWith("~/") || value.startsWith("~\\")) return join(home, value.slice(2)); + if (value.startsWith("~/") || (process.platform === "win32" && value.startsWith("~\\"))) return join(home, value.slice(2)); return value; } function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string { - if (isPiCommand(command)) return expandHomePath("~/.pi/agent", env); + if (usesDefaultPiStatePolicy(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 isPiCommand(command: string): boolean { +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; } @@ -372,6 +416,15 @@ function isEnvSet(value: string | undefined): boolean { function uniqueStrings(values: readonly string[]): string[] { return [...new Set(values)]; } + +function hasControlCharacter(value: string): boolean { + for (const character of value) { + const code = character.charCodeAt(0); + if (code < 32 || code === 127) return true; + } + return false; +} + function isAbsoluteLike(value: string): boolean { const withForwardSlashes = value.replace(/\\/g, "/"); return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes); diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index 5f1593f..6225d12 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -1,6 +1,6 @@ import Fastify, { type FastifyInstance } from "fastify"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { parsePiWebConfigResponseBody, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; +import { parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; let app: FastifyInstance; @@ -112,6 +112,21 @@ describe("config routes", () => { expect(service.write).not.toHaveBeenCalled(); }); + it.each([ + { agent: { command: "./agent", dir: "/srv/agent" }, error: "safe bare executable name or host-absolute executable path" }, + { agent: { command: "agent", dir: "/srv/agent", futureSetting: true }, error: 'agent contains unknown key "futureSetting"' }, + ])("rejects unsafe agent profile payloads before writing", async ({ agent, error }) => { + const response = await app.inject({ + method: "PUT", + url: "/api/config", + payload: { config: { agent } }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json<{ error: string }>().error).toContain(error); + expect(service.write).not.toHaveBeenCalled(); + }); + it("filters local machine config reads to selected-machine-safe keys", async () => { savedConfig = fullConfig(); @@ -161,6 +176,20 @@ describe("config routes", () => { }); }); + it("keeps foreign-platform agent paths portable at federation transport boundaries", () => { + const agent = { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\pi" }; + const response = { + ...responseFor({ agent }, true), + effectiveConfig: { agent }, + }; + + expect(parsePiWebConfigResponseBody(response).config.agent).toEqual(agent); + expect(parseSelectedMachineConfigRequest({ agent }, "portable").agent).toEqual(agent); + if (process.platform !== "win32") { + expect(() => parseSelectedMachineConfigRequest({ agent })).toThrow("host-absolute executable path"); + } + }); + it("defaults missing agent override fields from older config responses", () => { const parsed = parsePiWebConfigResponseBody({ path: "/tmp/pi-web/config.json", diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index a99c3d1..5ab96a3 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseUploadsConfig, resolveEffectivePiWebConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; +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 { isPiWebPluginId } from "../shared/pluginIds.js"; @@ -83,13 +83,13 @@ export function registerLocalMachineConfigRoutes(app: FastifyInstance, service: }); } -export function parseSelectedMachineConfigRequest(value: unknown): PiWebConfig { +export function parseSelectedMachineConfigRequest(value: unknown, agentPathHost: AgentPathHost = "current"): PiWebConfig { if (!isRecord(value)) throw new Error("PI WEB selected-machine config update must include a config object"); for (const key of Object.keys(value)) { if (!SELECTED_MACHINE_CONFIG_KEY_SET.has(key)) throw new Error(`PI WEB selected-machine config key is not allowed: ${key}`); } try { - return pickSelectedMachineConfig(parseConfigRequest(value)); + return pickSelectedMachineConfig(parseConfigRequest(value, agentPathHost)); } catch (error) { throw new Error(selectedMachineConfigErrorMessage(error), { cause: error }); } @@ -112,13 +112,13 @@ export function parsePiWebConfigResponseBody(value: unknown, source = "PI WEB co return { path: requireResponseString(record, "path", source), exists: requireResponseBoolean(record, "exists", source), - config: parseConfigRequest(record["config"]), - effectiveConfig: parseConfigRequest(record["effectiveConfig"]), + config: parseConfigRequest(record["config"], "portable"), + effectiveConfig: parseConfigRequest(record["effectiveConfig"], "portable"), envOverrides: parsePiWebConfigEnvOverridesResponse(record["envOverrides"], source), }; } -function parseConfigRequest(value: unknown): PiWebConfig { +function parseConfigRequest(value: unknown, agentPathHost: AgentPathHost = "current"): PiWebConfig { if (!isRecord(value)) throw new Error("PI WEB config update must include a config object"); const config: PiWebConfig = {}; const host = value["host"]; @@ -154,7 +154,7 @@ function parseConfigRequest(value: unknown): PiWebConfig { if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean"); config.subsessions = subsessions; } - if (agent !== undefined) config.agent = parseAgentRequest(agent); + if (agent !== undefined) config.agent = parseAgentRequest(agent, agentPathHost); return config; } @@ -216,28 +216,8 @@ function parseMaxUploadBytesRequest(value: unknown): number { return value; } -function parseAgentRequest(value: unknown): NonNullable { - if (!isRecord(value)) throw new Error("PI WEB config agent must be an object"); - const command = value["command"]; - const dir = value["dir"]; - return { - ...(command === undefined ? {} : { command: parseAgentCommandRequest(command) }), - ...(dir === undefined ? {} : { dir: parseAgentDirRequest(dir) }), - }; -} - -function parseAgentCommandRequest(value: unknown): string { - if (typeof value !== "string" || value.trim() === "") throw new Error("PI WEB config agent.command must be a non-empty string"); - const command = value.trim(); - if (/[\s;&|`$<>]/u.test(command)) throw new Error("PI WEB config agent.command must be a single command name or path without shell metacharacters"); - return command; -} - -function parseAgentDirRequest(value: unknown): string { - if (typeof value !== "string" || value.trim() === "") throw new Error("PI WEB config agent.dir must be a non-empty string"); - const dir = value.trim(); - if (!isAbsoluteOrHomePath(dir)) throw new Error("PI WEB config agent.dir must be an absolute path or start with ~"); - return dir; +function parseAgentRequest(value: unknown, pathHost: AgentPathHost): NonNullable { + return parseAgentConfig(value, "request", pathHost); } function parsePluginsRequest(value: unknown): NonNullable { @@ -317,10 +297,6 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function isAbsoluteOrHomePath(value: string): boolean { - return value === "~" || value.startsWith("~/") || value.startsWith("~\\") || value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(value); -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/server/machines/machineProxyRoutes.ts b/src/server/machines/machineProxyRoutes.ts index a5e71ff..e9d85d0 100644 --- a/src/server/machines/machineProxyRoutes.ts +++ b/src/server/machines/machineProxyRoutes.ts @@ -69,7 +69,7 @@ async function proxySelectedMachineConfigRequest(client: MachineClient, machineI } if (method === "PUT") { - const patch = parseSelectedMachineConfigRequest(configPayload(body)); + const patch = parseSelectedMachineConfigRequest(configPayload(body), "portable"); const currentResponse = await client.requestJson("GET", remotePath); if (!isSuccessfulStatus(currentResponse.statusCode)) return sendUpstreamJsonResponse(reply, currentResponse, machineId); diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index 7b81e0a..f9626ce 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -192,24 +192,42 @@ describe("PI WEB status", () => { const updateCommand = await updateCommandFor( { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, "pi-web restart", - { agentCommand: undefined, hasCommand }, + { activeAgentProfile: undefined, hasCommand }, ); expect(updateCommand).toBeUndefined(); expect(hasCommand).not.toHaveBeenCalled(); }); - it("shell-quotes pi-package agent update commands", async () => { + it("preserves and shell-quotes the active state profile in Pi-package update commands", async () => { + const command = "/tmp/agent's/pi"; + const dir = "/tmp/profile's/state"; const updateCommand = await updateCommandFor( { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, "pi-web restart", { - agentCommand: "/tmp/agent's/alt-agent", - hasCommand: (command) => Promise.resolve(command === "/tmp/agent's/alt-agent"), + activeAgentProfile: activeProfile("a", command, dir), + hasCommand: (candidate) => Promise.resolve(candidate === command), }, ); - expect(updateCommand).toBe("'/tmp/agent'\\''s/alt-agent' update 'npm:@jmfederico/pi-web' && pi-web restart"); + expect(updateCommand).toBe("PI_CODING_AGENT_DIR='/tmp/profile'\\''s/state' '/tmp/agent'\\''s/pi' update 'npm:@jmfederico/pi-web' && pi-web restart"); + }); + + it.each([ + activeProfile("a", "acme-agent", "/opt/acme/state"), + activeProfile("b", "pi", "relative/state"), + ])("suppresses Pi-package updates when the active companion profile cannot be represented safely", async (profile) => { + const hasCommand = vi.fn(() => Promise.resolve(true)); + + const updateCommand = await updateCommandFor( + { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, + "pi-web restart", + { activeAgentProfile: profile, hasCommand }, + ); + + expect(updateCommand).toBeUndefined(); + expect(hasCommand).not.toHaveBeenCalled(); }); it.skipIf(process.platform !== "linux")("suggests native systemd commands for local development services", async () => { diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts index 56c2a92..8f75d3c 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -11,6 +11,7 @@ import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/ import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js"; import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; +import { isHostAbsoluteAgentDir, isPiCompanionCommand, isSafeAgentCommandForHost, PI_CODING_AGENT_DIR_ENV } from "../config.js"; import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; @@ -139,7 +140,7 @@ export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaem const { web, sessiond } = versionStatus.components; const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true); const components = { web, sessiond }; - const commands = await commandsFor(components, { agentCommand: options.activeAgentProfile?.command, hasCommand: options.hasCommand ?? hasCommand }); + const commands = await commandsFor(components, { activeAgentProfile: options.activeAgentProfile, hasCommand: options.hasCommand ?? hasCommand }); const messages = buildMessages(components, release, commands); return { ...versionStatus, @@ -415,7 +416,7 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise { return version; } -async function commandsFor(components: PiWebStatusResponse["components"], options: { agentCommand: string | undefined; hasCommand: (command: string) => Promise }): Promise { +async function commandsFor(components: PiWebStatusResponse["components"], options: { activeAgentProfile: ActiveAgentProfileDescriptor | undefined; hasCommand: (command: string) => Promise }): Promise { const installation = preferredInstallation(components); if (installation?.kind === "docker") return dockerCommands(installation); @@ -466,11 +467,13 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv return cliCommands.restart ?? serviceCommands.restart; } -export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { agentCommand: string | undefined; hasCommand: (command: string) => Promise }): Promise { +export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { activeAgentProfile: ActiveAgentProfileDescriptor | undefined; hasCommand: (command: string) => Promise }): Promise { if (restartCommand === undefined) return undefined; if (installation?.kind === "pi-package") { - if (options.agentCommand === undefined || !(await options.hasCommand(options.agentCommand))) return undefined; - return `${shellQuote(options.agentCommand)} update ${shellQuote(installation.source ?? PI_WEB_NPM_SOURCE)} && ${restartCommand}`; + const profile = options.activeAgentProfile; + if (profile === undefined || !isSafeAgentCommandForHost(profile.command) || !isHostAbsoluteAgentDir(profile.dir) || !isPiCompanionCommand(profile.command)) return undefined; + if (!(await options.hasCommand(profile.command))) return undefined; + return `${PI_CODING_AGENT_DIR_ENV}=${shellQuote(profile.dir)} ${shellQuote(profile.command)} update ${shellQuote(installation.source ?? PI_WEB_NPM_SOURCE)} && ${restartCommand}`; } if (installation?.kind === "local" && installation.path !== undefined) { if (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined; diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index d46973c..de7e0f6 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -20,17 +20,18 @@ import { TerminalService } from "./terminals/terminalService.js"; import { registerTerminalRoutes } from "./terminals/terminalRoutes.js"; import { getPiWebRuntimeComponent } from "./piWebStatus.js"; import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js"; -import { agentSessionDirEnvKeys, effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js"; +import { agentSessionDirEnvKeys, effectivePiWebConfig, maxUploadBytes } from "../config.js"; import { createActiveAgentProfileDescriptor } from "../sessiond/activeAgentProfile.js"; import { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js"; -const { config } = effectivePiWebConfig(); +const daemonEnvironment: NodeJS.ProcessEnv = Object.freeze({ ...process.env }); +const { config } = effectivePiWebConfig({ env: daemonEnvironment }); const activeAgentProfile = createActiveAgentProfileDescriptor({ command: config.agent.command, dir: config.agent.dir, sessionDirEnvKeys: agentSessionDirEnvKeys(config.agent.command), }); -const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(process.env, config) }); +const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(daemonEnvironment, config) }); await app.register(fastifyWebsocket); await runSessionDaemonStartup({ @@ -39,7 +40,7 @@ await runSessionDaemonStartup({ const eventHub = new SessionEventHub(); const workspaceActivity = new WorkspaceActivityService(eventHub); const auth = new AuthService({ agentDir: activeAgentProfile.dir }); - const spawnTargets = spawnSessionsEnabled(process.env, config) + const spawnTargets = config.spawnSessions ? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() }) : undefined; const sessions = new PiSessionService(eventHub, { @@ -48,9 +49,10 @@ await runSessionDaemonStartup({ workspaceActivity, logger: app.log, ...(spawnTargets === undefined ? {} : { spawnTargets }), - subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config), + subsessionsEnabled: spawnTargets !== undefined && config.subsessions, sessionManager: createPiSessionManagerGateway({ agentDir: activeAgentProfile.dir, + env: daemonEnvironment, sessionDirEnvKeys: activeAgentProfile.sessionDirEnvKeys, }), }); @@ -98,9 +100,9 @@ await runSessionDaemonStartup({ process.once("SIGINT", (signal) => { void shutdown(signal); }); process.once("SIGTERM", (signal) => { void shutdown(signal); }); - const portValue = process.env["PI_WEB_SESSIOND_PORT"]; + const portValue = daemonEnvironment["PI_WEB_SESSIOND_PORT"]; const port = portValue !== undefined && portValue !== "" ? Number(portValue) : undefined; - const host = process.env["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1"; + const host = daemonEnvironment["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1"; if (port !== undefined) { await app.listen({ port, host }); diff --git a/src/server/sessions/piSessionManagerGateway.test.ts b/src/server/sessions/piSessionManagerGateway.test.ts index 144df97..997f966 100644 --- a/src/server/sessions/piSessionManagerGateway.test.ts +++ b/src/server/sessions/piSessionManagerGateway.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { agentSessionDirEnvKeys } from "../../config.js"; import { createPiSessionManagerGateway, defaultPiSessionDir, defaultPiSessionsRoot, filterSessionsForCwd, SessionDirResolver } from "./piSessionManagerGateway.js"; import type { PiSessionListEntry } from "./piSessionService.js"; import type { PiSessionManager } from "./piSessionService.js"; @@ -24,7 +25,7 @@ afterEach(async () => { describe("SessionDirResolver", () => { it("uses Pi default session storage when no Pi override is configured", () => { - const resolver = new SessionDirResolver({ agentDir, env: {} }); + const resolver = new SessionDirResolver(piProfileOptions()); expect(resolver.resolve(cwd)).toMatchObject({ source: "pi-default", sessionDir: defaultPiSessionDir(cwd, agentDir), usesConfiguredSessionDir: false }); expect(defaultPiSessionsRoot(agentDir)).toBe(join(agentDir, "sessions")); @@ -34,7 +35,7 @@ describe("SessionDirResolver", () => { await mkdir(agentDir, { recursive: true }); await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: ".pi/sessions" }, null, 2)}\n`, "utf8"); - const resolver = new SessionDirResolver({ agentDir, env: {} }); + const resolver = new SessionDirResolver(piProfileOptions()); expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".pi", "sessions"), usesConfiguredSessionDir: true }); }); @@ -45,7 +46,7 @@ describe("SessionDirResolver", () => { await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "global-sessions") }, null, 2)}\n`, "utf8"); await writeFile(join(cwd, ".pi", "settings.json"), `${JSON.stringify({ sessionDir: ".workspace-sessions" }, null, 2)}\n`, "utf8"); - const resolver = new SessionDirResolver({ agentDir, env: {} }); + const resolver = new SessionDirResolver(piProfileOptions()); expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".workspace-sessions"), usesConfiguredSessionDir: true }); }); @@ -55,7 +56,7 @@ describe("SessionDirResolver", () => { await mkdir(agentDir, { recursive: true }); await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8"); - const resolver = new SessionDirResolver({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envDir } }); + const resolver = new SessionDirResolver(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: envDir })); expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true }); }); @@ -65,10 +66,22 @@ describe("SessionDirResolver", () => { await mkdir(agentDir, { recursive: true }); await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8"); - const resolver = new SessionDirResolver({ agentDir, env: { PI_WEB_AGENT_SESSION_DIR: envDir } }); + const resolver = new SessionDirResolver(piProfileOptions({ PI_WEB_AGENT_SESSION_DIR: envDir })); expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true }); }); + + it("snapshots the daemon epoch's injected session-directory environment", () => { + const firstDir = join(tempDir, "first-env-sessions"); + const env = { PI_WEB_AGENT_SESSION_DIR: firstDir }; + const sessionDirEnvKeys = ["PI_WEB_AGENT_SESSION_DIR"]; + const resolver = new SessionDirResolver({ agentDir, env, sessionDirEnvKeys }); + + env.PI_WEB_AGENT_SESSION_DIR = join(tempDir, "mutated-env-sessions"); + sessionDirEnvKeys[0] = "OTHER_SESSION_DIR"; + + expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: firstDir, usesConfiguredSessionDir: true }); + }); }); describe("Pi session manager gateway", () => { @@ -76,7 +89,7 @@ describe("Pi session manager gateway", () => { const otherCwd = join(tempDir, "other-workspace"); await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-a", cwd); await writeSessionFile(defaultPiSessionDir(otherCwd, agentDir), "session-b", otherCwd); - const gateway = createPiSessionManagerGateway({ agentDir, env: {} }); + const gateway = createPiSessionManagerGateway(piProfileOptions()); if (gateway.listAll === undefined) throw new Error("Expected legacy listing support"); await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })])); @@ -86,7 +99,7 @@ describe("Pi session manager gateway", () => { const envSessionDir = join(tempDir, "env-sessions"); await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "default-session", cwd); await writeSessionFile(envSessionDir, "env-session", cwd); - const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envSessionDir } }); + const gateway = createPiSessionManagerGateway(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: envSessionDir })); if (gateway.listAll === undefined) throw new Error("Expected legacy listing support"); await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })])); @@ -99,6 +112,7 @@ describe("Pi session manager gateway", () => { const gateway = createPiSessionManagerGateway({ agentDir, env: { [envKey]: envSessionDir }, + sessionDirEnvKeys: [envKey], }); if (gateway.listAll === undefined) throw new Error("Expected legacy listing support"); @@ -111,7 +125,7 @@ describe("Pi session manager gateway", () => { const otherCwd = join(tempDir, "other-workspace"); await writeSessionFile(sharedSessionDir, "session-a", cwd); await writeSessionFile(sharedSessionDir, "session-b", otherCwd); - const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: sharedSessionDir } }); + const gateway = createPiSessionManagerGateway(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: sharedSessionDir })); await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-a", cwd }]); const created = gateway.create(cwd); @@ -125,7 +139,7 @@ describe("Pi session manager gateway", () => { // hiding every session outside the daemon's own launch directory. expect(cwd).not.toBe(process.cwd()); await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-elsewhere", cwd); - const gateway = createPiSessionManagerGateway({ agentDir, env: {} }); + const gateway = createPiSessionManagerGateway(piProfileOptions()); await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-elsewhere", cwd }]); }); @@ -153,12 +167,16 @@ describe("session listing canonicalization", () => { // Headers are written by the Pi CLI / SDK consumers and may contain // unnormalized paths (trailing separators, redundant segments). await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-messy", `${cwd}${sep}.${sep}`); - const gateway = createPiSessionManagerGateway({ agentDir, env: {} }); + const gateway = createPiSessionManagerGateway(piProfileOptions()); await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-messy", cwd }]); }); }); +function piProfileOptions(env: NodeJS.ProcessEnv = {}) { + return { agentDir, env, sessionDirEnvKeys: agentSessionDirEnvKeys() }; +} + function hasSessionDir(manager: PiSessionManager): manager is PiSessionManager & { getSessionDir(): string } { return "getSessionDir" in manager && typeof manager.getSessionDir === "function"; } diff --git a/src/server/sessions/piSessionManagerGateway.ts b/src/server/sessions/piSessionManagerGateway.ts index d13ea10..3593c2d 100644 --- a/src/server/sessions/piSessionManagerGateway.ts +++ b/src/server/sessions/piSessionManagerGateway.ts @@ -3,7 +3,6 @@ import { readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent"; -import { agentSessionDirEnvKeys, effectiveAgentConfig } from "../../config.js"; import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js"; import type { PiSessionListEntry, PiSessionManager, PiSessionManagerGateway } from "./piSessionService.js"; @@ -16,20 +15,23 @@ export interface SessionDirResolution { } export interface SessionDirResolverOptions { - agentDir?: string; - env?: NodeJS.ProcessEnv; - sessionDirEnvKeys?: readonly string[]; + agentDir: string; + env: Readonly; + sessionDirEnvKeys: readonly string[]; } export class SessionDirResolver { private readonly agentDir: string; - private readonly env: NodeJS.ProcessEnv; - private readonly sessionDirEnvKeys: readonly string[]; + private readonly envSessionDir: string | undefined; + private readonly homeDir: string; - constructor(options: SessionDirResolverOptions = {}) { - this.agentDir = options.agentDir ?? effectiveAgentConfig().dir; - this.env = options.env ?? process.env; - this.sessionDirEnvKeys = options.sessionDirEnvKeys ?? agentSessionDirEnvKeys(); + constructor(options: SessionDirResolverOptions) { + this.agentDir = options.agentDir; + this.envSessionDir = options.sessionDirEnvKeys + .map((key) => options.env[key]) + .find((value) => value !== undefined && value !== ""); + const configuredHome = options.env["HOME"]; + this.homeDir = configuredHome !== undefined && configuredHome !== "" && isAbsolute(configuredHome) ? configuredHome : homedir(); } defaultSessionsRoot(): string { @@ -37,34 +39,28 @@ export class SessionDirResolver { } globalEnvSessionDir(): string | undefined { - const envSessionDir = this.envSessionDir(); - if (envSessionDir === undefined) return undefined; - const expanded = expandTildePath(envSessionDir); + if (this.envSessionDir === undefined) return undefined; + const expanded = expandTildePath(this.envSessionDir, this.homeDir); return isAbsolute(expanded) ? expanded : undefined; } resolve(cwd: string): SessionDirResolution { - const envSessionDir = this.envSessionDir(); - if (envSessionDir !== undefined) { - return { source: "env", sessionDir: resolveConfiguredPath(envSessionDir, cwd), usesConfiguredSessionDir: true }; + if (this.envSessionDir !== undefined) { + return { source: "env", sessionDir: resolveConfiguredPath(this.envSessionDir, cwd, this.homeDir), usesConfiguredSessionDir: true }; } const settingsSessionDir = SettingsManager.create(cwd, this.agentDir).getSessionDir(); if (settingsSessionDir !== undefined && settingsSessionDir !== "") { - return { source: "settings", sessionDir: resolveConfiguredPath(settingsSessionDir, cwd), usesConfiguredSessionDir: true }; + return { source: "settings", sessionDir: resolveConfiguredPath(settingsSessionDir, cwd, this.homeDir), usesConfiguredSessionDir: true }; } return { source: "pi-default", sessionDir: defaultPiSessionDir(cwd, this.agentDir), usesConfiguredSessionDir: false }; } - - private envSessionDir(): string | undefined { - return this.sessionDirEnvKeys.map((key) => this.env[key]).find((value) => value !== undefined && value !== ""); - } } export type PiSessionManagerGatewayOptions = SessionDirResolverOptions; -export function createPiSessionManagerGateway(options: PiSessionManagerGatewayOptions = {}): PiSessionManagerGateway { +export function createPiSessionManagerGateway(options: PiSessionManagerGatewayOptions): PiSessionManagerGateway { return new SettingsAwarePiSessionManagerGateway(new SessionDirResolver(options)); } @@ -105,7 +101,7 @@ export async function listSessionsInDir(sessionDir: string): Promise ({ ...session, cwd: canonicalizeStoredCwd(session.cwd) })); } -export async function listSessionsInDefaultPiStore(storeRoot = defaultPiSessionsRoot()): Promise { +export async function listSessionsInDefaultPiStore(storeRoot: string): Promise { let entries: Dirent[]; try { entries = await readdir(storeRoot, { withFileTypes: true }); @@ -130,11 +126,11 @@ function uniqueSessionsByPath(sessions: readonly PiSessionListEntry[]): PiSessio return [...byPath.values()].sort((a, b) => b.modified.getTime() - a.modified.getTime()); } -export function defaultPiSessionsRoot(agentDir = effectiveAgentConfig().dir): string { +export function defaultPiSessionsRoot(agentDir: string): string { return join(agentDir, "sessions"); } -export function defaultPiSessionDir(cwd: string, agentDir = effectiveAgentConfig().dir): string { +export function defaultPiSessionDir(cwd: string, agentDir: string): string { return sessionDirInDefaultPiStore(defaultPiSessionsRoot(agentDir), cwd); } @@ -143,13 +139,13 @@ export function sessionDirInDefaultPiStore(storeRoot: string, cwd: string): stri return join(storeRoot, safePath); } -export function resolveConfiguredPath(path: string, cwd: string): string { - const expanded = expandTildePath(path); +export function resolveConfiguredPath(path: string, cwd: string, homeDir: string): string { + const expanded = expandTildePath(path, homeDir); return isAbsolute(expanded) ? expanded : resolve(cwd, expanded); } -function expandTildePath(path: string): string { - if (path === "~") return homedir(); - if (path.startsWith("~/")) return join(homedir(), path.slice(2)); +function expandTildePath(path: string, homeDir: string): string { + if (path === "~") return homeDir; + if (path.startsWith("~/")) return join(homeDir, path.slice(2)); return path; } diff --git a/src/server/sessions/piSessionService.archiveCleanup.test.ts b/src/server/sessions/piSessionService.archiveCleanup.test.ts index 3116a66..969ff15 100644 --- a/src/server/sessions/piSessionService.archiveCleanup.test.ts +++ b/src/server/sessions/piSessionService.archiveCleanup.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { PiSessionService } from "./piSessionService.js"; import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js"; +const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; + describe("PiSessionService archive and cleanup", () => { it("archives a session subtree within the root workspace", async () => { const archivedInputs: string[] = []; @@ -12,6 +14,7 @@ describe("PiSessionService archive and cleanup", () => { const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path }; const fake = fakeRuntime("root", { sessionFile: root.path }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), archiveStore: { list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]), @@ -45,6 +48,7 @@ describe("PiSessionService archive and cleanup", () => { it("permanently deletes archived sessions through the archive store", async () => { const deletedSessionIds: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([]), get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) @@ -78,6 +82,7 @@ describe("PiSessionService archive and cleanup", () => { const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); }); const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([]), get: () => Promise.resolve(undefined), @@ -112,6 +117,7 @@ describe("PiSessionService archive and cleanup", () => { let createCalls = 0; const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { createCalls += 1; return Promise.resolve(busy.runtime); @@ -152,6 +158,7 @@ describe("PiSessionService archive and cleanup", () => { const busy = fakeRuntime("busy-archived", { isStreaming: true }); const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(busy.runtime), archiveStore: { list: () => Promise.resolve([busyRecord, idleRecord]), @@ -188,6 +195,7 @@ describe("PiSessionService archive and cleanup", () => { const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const listCalls: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([ { sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }, @@ -230,6 +238,7 @@ describe("PiSessionService archive and cleanup", () => { const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" }; const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" }; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, now: () => new Date("2026-06-25T00:00:00.000Z"), archiveStore: { list: () => Promise.resolve([archived, otherArchived]), @@ -282,6 +291,7 @@ describe("PiSessionService archive and cleanup", () => { const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` })))); const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, now: () => new Date("2026-06-25T00:00:00.000Z"), archiveStore: { list: () => Promise.resolve([ @@ -323,6 +333,7 @@ describe("PiSessionService archive and cleanup", () => { const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" }); const archivedInputs: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, now: () => new Date("2026-06-25T00:00:00.000Z"), createAgentRuntime: runtimeCreator(fake.runtime), archiveStore: { diff --git a/src/server/sessions/piSessionService.lifecycle.test.ts b/src/server/sessions/piSessionService.lifecycle.test.ts index 67f5e6a..3fbc23a 100644 --- a/src/server/sessions/piSessionService.lifecycle.test.ts +++ b/src/server/sessions/piSessionService.lifecycle.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it, vi } from "vitest"; import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js"; import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js"; +const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; + function deferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; @@ -20,12 +22,15 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const hub = new CapturingSessionEventHub(); const fake = fakeRuntime(); let createCalls = 0; - const createAgentRuntime: RuntimeCreator = async () => { + let runtimeAgentDir: string | undefined; + const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { createCalls += 1; + runtimeAgentDir = options.agentDir; await Promise.resolve(); return fake.runtime; }; const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -34,6 +39,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const session = await service.start("/workspace"); expect(createCalls).toBe(1); + expect(runtimeAgentDir).toBe(TEST_AGENT_DIR); expect(fake.calls.bindExtensions).toHaveLength(1); expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 }); expect(service.activeCount()).toBe(1); @@ -53,6 +59,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { let service: PiSessionService | undefined; try { service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -79,6 +86,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const fake = fakeRuntime("legacy-session"); const open = vi.fn(() => fakeSessionManager()); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: { create: () => fakeSessionManager(), @@ -127,6 +135,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const gateway = sessionGateway([sessionRecord(sessionId)]); const open = vi.spyOn(gateway, "open"); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: emptyArchiveStore(), createAgentRuntime, sessionManager: gateway, @@ -180,6 +189,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { : Promise.resolve(runtime); }; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: emptyArchiveStore(), createAgentRuntime, sessionManager: sessionGateway([sessionRecord(sessionId)]), @@ -219,6 +229,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const runtimeResult = deferred(); const fake = fakeRuntime(sessionId); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: emptyArchiveStore(), createAgentRuntime: () => { createStarted.resolve(); @@ -252,6 +263,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { let rebindSession: ((session: PiAgentSession) => Promise) | undefined; fake.runtime.setRebindSession = (callback) => { rebindSession = callback; }; const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -278,6 +290,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }, }); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -312,6 +325,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }, }); service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("idle-session")]), heartbeatIntervalMs: 1_000, @@ -347,6 +361,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }, }); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("completion-session")]), heartbeatIntervalMs: 60_000, @@ -365,6 +380,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("uses injected archive and session-manager gateways for listing", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]), get: () => Promise.resolve(undefined), @@ -394,6 +410,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("lists archived records that have been moved out of the active session directory", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), get: () => Promise.resolve(undefined), @@ -424,6 +441,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const hub = new CapturingSessionEventHub(); const fake = fakeRuntime("runtime-reload-session"); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]), heartbeatIntervalMs: 60_000, @@ -456,6 +474,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { return runtime; }; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: sessionGateway([sessionRecord("reload-session")]), heartbeatIntervalMs: 60_000, @@ -479,6 +498,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("refuses to reload a session that has active work in progress", async () => { const fake = fakeRuntime("busy-session", { isStreaming: true }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("busy-session")]), heartbeatIntervalMs: 60_000, @@ -493,6 +513,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("refuses to reload an archived session", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([]), get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) @@ -514,6 +535,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("reconciles workspace activity when listing only archived sessions", async () => { const reconciliations: { cwd: string; sessionIds: string[] }[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), get: () => Promise.resolve(undefined), diff --git a/src/server/sessions/piSessionService.promptQueue.test.ts b/src/server/sessions/piSessionService.promptQueue.test.ts index 1e2d7a5..442f037 100644 --- a/src/server/sessions/piSessionService.promptQueue.test.ts +++ b/src/server/sessions/piSessionService.promptQueue.test.ts @@ -5,10 +5,13 @@ import { describe, expect, it, vi } from "vitest"; import { PiSessionService } from "./piSessionService.js"; import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; +const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; + describe("PiSessionService prompt, queue, and auth warnings", () => { it("sends prompts to an injected runtime without touching the SDK runtime", async () => { const fake = fakeRuntime("prompt-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("prompt-session")]), heartbeatIntervalMs: 60_000, @@ -26,6 +29,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { }); const hub = new CapturingSessionEventHub(); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("echo-session")]), heartbeatIntervalMs: 60_000, @@ -55,6 +59,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { return fake.runtime; }; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: sessionGateway([sessionRecord("prompt-session")]), heartbeatIntervalMs: 60_000, @@ -90,6 +95,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const hub = new CapturingSessionEventHub(); const fake = fakeRuntime("name-session", { model, agent: { streamFn } }); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("name-session")]), heartbeatIntervalMs: 60_000, @@ -111,6 +117,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { getFollowUpMessages: () => ["then do this"], }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("status-session")]), heartbeatIntervalMs: 60_000, @@ -131,6 +138,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { getFollowUpMessages: () => ["already queued"], }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("dedupe-session")]), heartbeatIntervalMs: 60_000, @@ -146,6 +154,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const hub = new CapturingSessionEventHub(); const fake = fakeRuntime("queued-session", { isStreaming: true }); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("queued-session")]), heartbeatIntervalMs: 60_000, @@ -171,6 +180,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { return Promise.resolve(); }; const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("compacting-session")]), heartbeatIntervalMs: 60_000, @@ -215,6 +225,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { it("clears queued messages when aborting active work", async () => { const fake = fakeRuntime("abort-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("abort-session")]), heartbeatIntervalMs: 60_000, @@ -231,6 +242,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { it("clears prompts queued during compaction when aborting active work", async () => { const fake = fakeRuntime("abort-compaction-session", { isCompacting: true }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]), heartbeatIntervalMs: 60_000, @@ -255,6 +267,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("auth-session", { model, modelRegistry }); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, modelRegistry, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("auth-session")]), @@ -285,6 +298,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { it("clears queued messages when stopping a session runtime", async () => { const fake = fakeRuntime("stop-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("stop-session")]), heartbeatIntervalMs: 60_000, diff --git a/src/server/sessions/piSessionService.spawnSession.test.ts b/src/server/sessions/piSessionService.spawnSession.test.ts index 0f1244b..29346ee 100644 --- a/src/server/sessions/piSessionService.spawnSession.test.ts +++ b/src/server/sessions/piSessionService.spawnSession.test.ts @@ -3,12 +3,15 @@ import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; import type { SpawnTargetDecision } from "./spawnTargetResolver.js"; import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; +const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; + describe("PiSessionService", () => { describe("spawnSession", () => { function spawnService(decision: SpawnTargetDecision) { const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" }); const log: { details: Record; message: string }[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, @@ -41,6 +44,7 @@ describe("PiSessionService", () => { return fake.runtime; }; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: sessionGateway([]), spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, @@ -75,6 +79,7 @@ describe("PiSessionService", () => { it("is disabled when no spawn target resolver is configured", async () => { const fake = fakeRuntime("spawned-x"); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, diff --git a/src/server/sessions/piSessionService.spawnSubsession.test.ts b/src/server/sessions/piSessionService.spawnSubsession.test.ts index 0ba16fa..d418a27 100644 --- a/src/server/sessions/piSessionService.spawnSubsession.test.ts +++ b/src/server/sessions/piSessionService.spawnSubsession.test.ts @@ -6,6 +6,8 @@ import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; import type { SpawnTargetDecision } from "./spawnTargetResolver.js"; import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; +const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; + describe("PiSessionService", () => { describe("spawnSubsession", () => { function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) { @@ -32,6 +34,7 @@ describe("PiSessionService", () => { isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)), }; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: sessionGateway([]), archiveStore, @@ -72,6 +75,7 @@ describe("PiSessionService", () => { return runtime; }; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: sessionGateway([]), archiveStore: emptyArchiveStore(), @@ -111,6 +115,7 @@ describe("PiSessionService", () => { const runtimes = [parent.runtime, child.runtime]; let index = 0; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { const runtime = runtimes[index] ?? child.runtime; index += 1; @@ -162,6 +167,7 @@ describe("PiSessionService", () => { let index = 0; const open = vi.fn(() => childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { const runtime = runtimes[index] ?? child.runtime; index += 1; @@ -203,6 +209,7 @@ describe("PiSessionService", () => { }), }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -227,6 +234,7 @@ describe("PiSessionService", () => { }), }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -248,6 +256,7 @@ describe("PiSessionService", () => { }), }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -268,6 +277,7 @@ describe("PiSessionService", () => { sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }), }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -288,6 +298,7 @@ describe("PiSessionService", () => { }), }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(forkedParent.runtime), sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -326,6 +337,7 @@ describe("PiSessionService", () => { let index = 0; const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: (_createRuntime, options) => { delegationCapabilities.push(options.delegationToolsEnabled); const runtime = runtimes[index] ?? parent.runtime; @@ -388,6 +400,7 @@ describe("PiSessionService", () => { return childManager; }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -445,6 +458,7 @@ describe("PiSessionService", () => { let index = 0; const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -509,6 +523,7 @@ describe("PiSessionService", () => { throw new Error(`unexpected open path ${path}`); }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: { create: () => parentManager, @@ -584,6 +599,7 @@ describe("PiSessionService", () => { throw new Error(`unexpected open path ${path}`); }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: { create: () => copiedParentManager, @@ -641,6 +657,7 @@ describe("PiSessionService", () => { let index = 0; const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -693,6 +710,7 @@ describe("PiSessionService", () => { let index = 0; const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -733,6 +751,7 @@ describe("PiSessionService", () => { const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager }); const open = vi.fn(() => childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(child.runtime), sessionManager: { create: () => childManager, @@ -842,6 +861,7 @@ describe("PiSessionService", () => { it("is disabled when no spawn target resolver is configured", async () => { const fake = fakeRuntime("nope"); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 765ae82..a999212 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -25,8 +25,6 @@ import type { ActiveSession } from "./sessionRuntimeStore.js"; import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js"; import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; import { computeEditPreview, type EditPreviewResult } from "./editPreview.js"; -import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js"; -import { effectiveAgentConfig } from "../../config.js"; import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js"; import { parsePromptAttachments } from "../../shared/promptAttachments.js"; import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js"; @@ -380,9 +378,9 @@ function createPiWebEditToolDefinition(cwd: string) { } export interface PiSessionServiceDependencies { + agentDir: string; + sessionManager: PiSessionManagerGateway; archiveStore?: SessionArchiveRepository; - agentDir?: string; - sessionManager?: PiSessionManagerGateway; createRuntime?: PiWebCreateAgentSessionRuntimeFactory; createAgentRuntime?: CreateAgentRuntime; modelRegistry?: ModelRegistryInstance; @@ -441,10 +439,10 @@ export class PiSessionService implements SessionRouteService { private readonly logger: PiSessionLogger; private readonly now: () => Date; - constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) { + constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies) { this.archiveStore = deps.archiveStore ?? new SessionArchiveStore(); - this.agentDir = deps.agentDir ?? effectiveAgentConfig().dir; - this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir }); + this.agentDir = deps.agentDir; + this.sessionManager = deps.sessionManager; this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir); this.spawnTargets = deps.spawnTargets; this.logger = deps.logger ?? noopLogger; diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts index e60f790..0b31fe7 100644 --- a/src/server/sessions/sessionRoutes.test.ts +++ b/src/server/sessions/sessionRoutes.test.ts @@ -9,6 +9,8 @@ import type { SessionRouteLookup, SessionRouteService } from "./sessionService.j import { registerSessionRoutes } from "./sessionRoutes.js"; import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js"; +const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; + let app: FastifyInstance; let service: PiSessionService; let sessionManager: RejectingSessionManager; @@ -18,7 +20,7 @@ beforeEach(async () => { await app.register(fastifyWebsocket); sessionManager = new RejectingSessionManager(); const eventHub = new SessionEventHub(); - service = new PiSessionService(eventHub, { sessionManager, heartbeatIntervalMs: 60_000 }); + service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, sessionManager, heartbeatIntervalMs: 60_000 }); registerSessionRoutes(app, service, eventHub); }); diff --git a/src/sessiond/activeAgentProfile.test.ts b/src/sessiond/activeAgentProfile.test.ts index 8b7ac4b..6e8251a 100644 --- a/src/sessiond/activeAgentProfile.test.ts +++ b/src/sessiond/activeAgentProfile.test.ts @@ -17,7 +17,7 @@ describe("active agent profile descriptor", () => { expect(first.revision).toMatch(/^sha256:[0-9a-f]{64}$/u); expect(createActiveAgentProfileDescriptor({ ...baseAgent, command: "other-agent" }).revision).not.toBe(first.revision); expect(createActiveAgentProfileDescriptor({ ...baseAgent, dir: "/other/state" }).revision).not.toBe(first.revision); - expect(createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys: ["OTHER_SESSION_DIR"] }).revision).not.toBe(first.revision); + expect(createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"] }).revision).not.toBe(first.revision); }); it("takes an immutable snapshot for the session daemon profile epoch", () => { @@ -32,6 +32,12 @@ describe("active agent profile descriptor", () => { expect(Reflect.set(profile.sessionDirEnvKeys, "0", "MUTATED_SESSION_DIR")).toBe(false); }); + it("rejects profile fields outside the host and explicit environment policy", () => { + expect(() => createActiveAgentProfileDescriptor({ ...baseAgent, command: "./acme-agent" })).toThrow("must be valid for this host"); + expect(() => createActiveAgentProfileDescriptor({ ...baseAgent, dir: "relative/state" })).toThrow("must be valid for this host"); + expect(() => createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys: ["ARBITRARY_AGENT_SESSION_DIR"] })).toThrow("explicit PI WEB policy"); + }); + it("copies only the secret-free descriptor fields", () => { const input = { ...baseAgent, diff --git a/src/sessiond/activeAgentProfile.ts b/src/sessiond/activeAgentProfile.ts index f5db70b..604a035 100644 --- a/src/sessiond/activeAgentProfile.ts +++ b/src/sessiond/activeAgentProfile.ts @@ -1,9 +1,15 @@ import { createHash } from "node:crypto"; -import type { EffectivePiWebAgentConfig } from "../config.js"; +import { isHostAbsoluteAgentDir, isSafeAgentCommandForHost, PI_CODING_AGENT_SESSION_DIR_ENV, PI_WEB_AGENT_SESSION_DIR_ENV, type EffectivePiWebAgentConfig } from "../config.js"; import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js"; import { ACTIVE_AGENT_PROFILE_SCHEMA_VERSION } from "../shared/activeAgentProfile.js"; export function createActiveAgentProfileDescriptor(agent: EffectivePiWebAgentConfig): ActiveAgentProfileDescriptor { + if (!isSafeAgentCommandForHost(agent.command) || !isHostAbsoluteAgentDir(agent.dir)) { + throw new Error("Active agent profile command and directory must be valid for this host"); + } + if (!hasValidSessionDirEnvKeys(agent.sessionDirEnvKeys)) { + throw new Error("Active agent profile session directory environment keys must use the explicit PI WEB policy"); + } const sessionDirEnvKeys = Object.freeze([...agent.sessionDirEnvKeys]); const revisionInput = JSON.stringify({ schemaVersion: ACTIVE_AGENT_PROFILE_SCHEMA_VERSION, @@ -20,3 +26,9 @@ export function createActiveAgentProfileDescriptor(agent: EffectivePiWebAgentCon sessionDirEnvKeys, }); } + +function hasValidSessionDirEnvKeys(keys: readonly string[]): boolean { + return (keys.length === 1 || keys.length === 2) + && keys[0] === PI_WEB_AGENT_SESSION_DIR_ENV + && (keys.length === 1 || keys[1] === PI_CODING_AGENT_SESSION_DIR_ENV); +} diff --git a/src/sessiond/sessionDaemonClient.test.ts b/src/sessiond/sessionDaemonClient.test.ts index 980468c..bde974d 100644 --- a/src/sessiond/sessionDaemonClient.test.ts +++ b/src/sessiond/sessionDaemonClient.test.ts @@ -43,6 +43,19 @@ describe("SessionDaemonClient active agent profile protocol", () => { }); }); + it.skipIf(process.platform === "win32")("rejects foreign-platform active state paths before local consumers use them", async () => { + const client = new SessionDaemonClient(); + vi.spyOn(client, "request").mockResolvedValue(runtimeResponse({ + ...activeAgentProfile, + dir: "C:\\agent-profiles\\acme", + })); + + await expect(client.getActiveAgentProfile()).resolves.toEqual({ + status: "invalid", + error: "session daemon active agent profile was not valid for this host", + }); + }); + it("treats a legacy runtime response without a profile as invalid for profile-dependent work", async () => { const client = new SessionDaemonClient(); vi.spyOn(client, "request").mockResolvedValue(runtimeResponse(undefined)); diff --git a/src/sessiond/sessionDaemonClient.ts b/src/sessiond/sessionDaemonClient.ts index ca097b2..8befa77 100644 --- a/src/sessiond/sessionDaemonClient.ts +++ b/src/sessiond/sessionDaemonClient.ts @@ -1,5 +1,6 @@ import http from "node:http"; import { WebSocket } from "ws"; +import { isHostAbsoluteAgentDir, isSafeAgentCommandForHost } from "../config.js"; import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js"; import { parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js"; import { sessiondHttpUrl, sessiondSocketPath } from "./config.js"; @@ -108,6 +109,9 @@ export async function getSessionDaemonActiveAgentProfile(client: SessionDaemonRe if (runtime.activeAgentProfile === undefined) { return { status: "invalid", error: "session daemon runtime response did not include an active agent profile" }; } + if (!isSafeAgentCommandForHost(runtime.activeAgentProfile.command) || !isHostAbsoluteAgentDir(runtime.activeAgentProfile.dir)) { + return { status: "invalid", error: "session daemon active agent profile was not valid for this host" }; + } return { status: "available", profile: runtime.activeAgentProfile }; } diff --git a/src/shared/activeAgentProfile.ts b/src/shared/activeAgentProfile.ts index 18e0047..7bf07cc 100644 --- a/src/shared/activeAgentProfile.ts +++ b/src/shared/activeAgentProfile.ts @@ -10,6 +10,8 @@ const ACTIVE_AGENT_PROFILE_FIELDS = new Set([ "sessionDirEnvKeys", ]); const SHA256_REVISION_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const SAFE_BARE_AGENT_COMMAND_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._+-]*$/u; +const ACTIVE_SESSION_DIR_ENV_KEYS = new Set(["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]); export function parseActiveAgentProfileDescriptor(value: unknown): ActiveAgentProfileDescriptor | undefined { if (!isRecord(value) || Object.keys(value).some((key) => !ACTIVE_AGENT_PROFILE_FIELDS.has(key))) return undefined; @@ -21,9 +23,11 @@ export function parseActiveAgentProfileDescriptor(value: unknown): ActiveAgentPr const sessionDirEnvKeys = value["sessionDirEnvKeys"]; if (schemaVersion !== ACTIVE_AGENT_PROFILE_SCHEMA_VERSION) return undefined; if (typeof revision !== "string" || !SHA256_REVISION_PATTERN.test(revision)) return undefined; - if (typeof command !== "string" || command === "" || typeof dir !== "string" || dir === "") return undefined; + if (typeof command !== "string" || !isPortableAgentCommand(command)) return undefined; + if (typeof dir !== "string" || !isPortableAbsolutePath(dir)) return undefined; if (!isNonEmptyStringArray(sessionDirEnvKeys)) return undefined; if (new Set(sessionDirEnvKeys).size !== sessionDirEnvKeys.length) return undefined; + if (sessionDirEnvKeys[0] !== "PI_WEB_AGENT_SESSION_DIR" || sessionDirEnvKeys.some((key) => !ACTIVE_SESSION_DIR_ENV_KEYS.has(key))) return undefined; return Object.freeze({ schemaVersion, @@ -34,6 +38,26 @@ export function parseActiveAgentProfileDescriptor(value: unknown): ActiveAgentPr }); } +function isPortableAgentCommand(value: string): boolean { + if (value !== value.trim() || /[\s;&|`$<>]/u.test(value)) return false; + if (isPortableAbsolutePath(value)) return !value.endsWith("/") && !value.endsWith("\\"); + return SAFE_BARE_AGENT_COMMAND_PATTERN.test(value); +} + +function isPortableAbsolutePath(value: string): boolean { + if (value === "" || value !== value.trim() || hasControlCharacter(value)) return false; + const withForwardSlashes = value.replace(/\\/g, "/"); + return withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//u.test(withForwardSlashes); +} + +function hasControlCharacter(value: string): boolean { + for (const character of value) { + const code = character.charCodeAt(0); + if (code < 32 || code === 127) return true; + } + return false; +} + function isNonEmptyStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((entry: unknown) => typeof entry === "string" && entry !== ""); } diff --git a/src/shared/piWebStatusParsing.test.ts b/src/shared/piWebStatusParsing.test.ts index 2795430..1bbb69d 100644 --- a/src/shared/piWebStatusParsing.test.ts +++ b/src/shared/piWebStatusParsing.test.ts @@ -80,6 +80,9 @@ describe("PI WEB status parsing", () => { }); expect(parsePiWebRuntimeResponse(responseFor(undefined, { ...profile, token: "secret" }))).toBeUndefined(); + expect(parsePiWebRuntimeResponse(responseFor(undefined, { ...profile, command: "./acme-agent" }))).toBeUndefined(); + expect(parsePiWebRuntimeResponse(responseFor(undefined, { ...profile, dir: "relative/state" }))).toBeUndefined(); + expect(parsePiWebRuntimeResponse(responseFor(undefined, { ...profile, sessionDirEnvKeys: ["ARBITRARY_AGENT_SESSION_DIR"] }))).toBeUndefined(); expect(parsePiWebRuntimeResponse(responseFor(profile, undefined))).toBeUndefined(); }); From 8b5ccc2fd9724007ca513f6fe0b6a3c51d14c80c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 14 Jul 2026 00:11:39 +0200 Subject: [PATCH 8/8] feat: apply agent profile settings atomically --- src/client/src/api.ts | 2 +- src/client/src/api/clients.test.ts | 8 +- src/client/src/api/clients.ts | 2 +- src/client/src/api/parsers.test.ts | 71 ++++++- src/client/src/api/parsers.ts | 18 +- src/client/src/components/PiWebApp.ts | 2 +- .../SettingsDialog.sessiond.test.ts | 53 ++++++ src/client/src/components/SettingsDialog.ts | 27 ++- .../settings/SettingsSessiondPanel.test.ts | 95 +++++++++- .../settings/SettingsSessiondPanel.ts | 174 +++++++++++++----- .../settings/settingsConfigDraft.test.ts | 19 ++ .../settings/settingsConfigDraft.ts | 33 ++++ .../settings/settingsMachineTarget.test.ts | 23 ++- .../settings/settingsMachineTarget.ts | 21 +++ .../settings/settingsSessiondConfig.test.ts | 60 ++++-- .../settings/settingsSessiondConfig.ts | 68 ++++--- .../src/controllers/machineController.test.ts | 2 +- .../src/controllers/machineController.ts | 2 +- src/config.test.ts | 18 +- src/config.ts | 29 +-- src/server/app.machines.test.ts | 131 ++++++++++++- src/server/configRoutes.test.ts | 17 ++ src/server/configRoutes.ts | 14 +- src/server/machines/machineProxyRoutes.ts | 20 +- src/server/machines/machineRoutes.ts | 4 +- src/server/machines/machineService.test.ts | 5 +- src/server/machines/machineService.ts | 4 +- src/server/piWebStatus.test.ts | 5 +- src/shared/activeAgentProfile.ts | 9 + src/shared/apiTypes.ts | 5 + src/shared/capabilities.test.ts | 6 +- src/shared/capabilities.ts | 2 + src/shared/piWebStatusParsing.test.ts | 8 +- 33 files changed, 794 insertions(+), 163 deletions(-) diff --git a/src/client/src/api.ts b/src/client/src/api.ts index e8a61f7..3b68515 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads"; export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads"; -export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; +export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 07955de..5107c95 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -79,12 +79,16 @@ describe("machine-scoped runtime API", () => { }); it("reads machine runtime through the gateway route", async () => { - const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }); + const response = { machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }; + const fetchMock = stubSequenceFetch([jsonResponse(response), jsonResponse(response)]); await machinesApi.runtime("remote a"); + await machinesApi.runtime("remote a", true); - expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime"); + expect(fetchCall(fetchMock, 1)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime?refresh=1"); + expect(fetchCall(fetchMock, 1)[1]?.cache).toBe("no-store"); }); }); diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 35b9940..190abb4 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -115,7 +115,7 @@ export const machinesApi = { addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }), deleteMachine: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }), health: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth), - runtime: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime), + runtime: (machineId: string, refresh = false) => request(`api/machines/${encodeURIComponent(machineId)}/runtime${refresh ? "?refresh=1" : ""}`, parseMachineRuntime, refresh ? { cache: "no-store" } : {}), }; function configPath(machineId?: string): string { diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 521db79..5b687a7 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; +import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; describe("API parsers", () => { it("parses PI WEB config responses", () => { @@ -9,26 +9,85 @@ describe("API parsers", () => { exists: true, config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } }, effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } }, - envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false }, + envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false }, })).toEqual({ path: "/tmp/config.json", exists: true, config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } }, effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } }, - envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false }, + envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false }, }); }); - it("parses PI WEB runtime responses", () => { + it("parses PI WEB runtime responses including the daemon-owned active profile", () => { expect(parsePiWebRuntimeResponse({ packageName: "@jmfederico/pi-web", generatedAt: "now", components: { web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] }, - sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }, + sessiond: { + component: "sessiond", + label: "Session daemon", + runtimeVersion: "1.0.0", + available: true, + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived], + activeAgentProfile: { + schemaVersion: 1, + revision: `sha256:${"a".repeat(64)}`, + command: "agent-lab", + dir: "/srv/agent-lab", + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }, + }, }, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"], - })).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] }); + })).toMatchObject({ + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage], + components: { sessiond: { activeAgentProfile: { command: "agent-lab", dir: "/srv/agent-lab" } } }, + }); + }); + + it("retains portable active profiles in machine runtime snapshots and rejects invalid ownership", () => { + const profile = { + schemaVersion: 1, + revision: `sha256:${"b".repeat(64)}`, + command: "C:\\tools\\pi.exe", + dir: "C:\\agent-profiles\\work", + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }; + const components = { + web: { component: "web", label: "Web/UI", available: true, capabilities: [] }, + sessiond: { component: "sessiond", label: "Session daemon", available: true, capabilities: [], activeAgentProfile: profile }, + }; + + const parsed = parseMachineRuntime({ machineId: "remote-a", ok: true, checkedAt: "now", components, capabilities: [] }); + + expect(parsed.components?.sessiond.activeAgentProfile).toMatchObject({ command: profile.command, dir: profile.dir }); + expect(Object.isFrozen(parsed.components?.sessiond.activeAgentProfile)).toBe(true); + expect(() => parseMachineRuntime({ + machineId: "remote-a", + ok: true, + checkedAt: "now", + components: { ...components, web: { ...components.web, activeAgentProfile: profile } }, + capabilities: [], + })).toThrow("Invalid active agent profile descriptor"); + expect(() => parseMachineRuntime({ + machineId: "remote-a", + ok: true, + checkedAt: "now", + components: { ...components, sessiond: { ...components.sessiond, activeAgentProfile: { ...profile, token: "secret" } } }, + capabilities: [], + })).toThrow("Invalid active agent profile descriptor"); + }); + + it("rejects malformed agent directory override metadata", () => { + expect(() => parsePiWebConfigResponse({ + path: "/tmp/config.json", + exists: true, + config: {}, + effectiveConfig: {}, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentDirSource: "future" }, + })).toThrow("Invalid PI WEB agentDirSource field"); }); it("parses Pi package list and mutation responses", () => { diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 1ca6267..c2508a6 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,5 +1,6 @@ -import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; +import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes"; +import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile"; import { parseKnownPiWebCapabilities } from "../../../shared/capabilities"; function isRecord(value: unknown): value is Record { @@ -647,10 +648,18 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides { subsessions: requireBoolean(record, "subsessions"), agentCommand: optionalBoolean(record, "agentCommand") ?? false, agentDir: optionalBoolean(record, "agentDir") ?? false, + ...optionalAgentDirSource(record), agentSessionDir: optionalBoolean(record, "agentSessionDir") ?? false, }; } +function optionalAgentDirSource(record: Record): { agentDirSource?: PiWebAgentDirEnvSource } { + const value = record["agentDirSource"]; + if (value === undefined) return {}; + if (value !== "pi-web" && value !== "pi-compatibility") throw new Error("Invalid PI WEB agentDirSource field"); + return { agentDirSource: value }; +} + export function parsePiPackagesResponse(value: unknown): PiPackagesResponse { const record = requireRecord(value); return { packages: arrayOf(parsePiPackageInfo)(record["packages"]) }; @@ -752,12 +761,17 @@ function parsePiWebRuntimeComponents(value: unknown): PiWebRuntimeResponse["comp function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent { const record = requireRecord(value); + const component = parsePiWebServiceComponent(record["component"]); + const activeAgentProfileValue = record["activeAgentProfile"]; + const activeAgentProfile = activeAgentProfileValue === undefined ? undefined : parseActiveAgentProfileDescriptor(activeAgentProfileValue); + if (activeAgentProfileValue !== undefined && (component !== "sessiond" || activeAgentProfile === undefined)) throw new Error("Invalid active agent profile descriptor"); return { - component: parsePiWebServiceComponent(record["component"]), + component, label: requireString(record, "label"), ...optionalField("runtimeVersion", optionalString(record, "runtimeVersion")), available: requireBoolean(record, "available"), capabilities: parsePiWebCapabilities(record["capabilities"]), + ...optionalField("activeAgentProfile", activeAgentProfile), ...optionalField("error", optionalString(record, "error")), }; } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index b6ff8cf..998c413 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1947,7 +1947,7 @@ export class PiWebApp extends LitElement { ${state.machineDialogOpen ? html` this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}>` : null} ${this.sessionCleanupDialog !== undefined ? html` { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}>` : null} ${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} - ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}>` : null} + ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }} .onRefreshMachineRuntime=${(machineId: string) => this.machines.refreshMachineRuntime(machineId)}>` : null}
      `; } diff --git a/src/client/src/components/SettingsDialog.sessiond.test.ts b/src/client/src/components/SettingsDialog.sessiond.test.ts index cbd7528..2d6126f 100644 --- a/src/client/src/components/SettingsDialog.sessiond.test.ts +++ b/src/client/src/components/SettingsDialog.sessiond.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api"; import { SettingsDialog } from "./SettingsDialog"; import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement as runtimeWithoutSelectedMachineSettings, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport"; @@ -40,6 +41,21 @@ describe("settings-dialog session daemon machine targeting", () => { expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false); }); + it("reloads desired config and the active runtime descriptor together", async () => { + const config = configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } }); + const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config); + const runtimeRefresh = vi.fn(() => Promise.resolve()); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.onRefreshMachineRuntime = runtimeRefresh; + + await callDialogPromise(dialog, "reloadSessiondState"); + + expect(configSpy).toHaveBeenCalledWith(remoteMachine.id); + expect(runtimeRefresh).toHaveBeenCalledWith(remoteMachine.id); + expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config); + }); + it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => { stubWindowTimers(); const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false }); @@ -57,6 +73,43 @@ describe("settings-dialog session daemon machine targeting", () => { expect(getDialogProperty(dialog, "saving")).toBe(false); }); + it("fails closed for a remote agent-profile save without granular support", async () => { + const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } })); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.machineRuntime = { + machineId: remoteMachine.id, + ok: true, + checkedAt: "now", + capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings], + }; + + await callDialogPromise(dialog, "saveSessiondConfig", { agent: { command: "agent-lab", dir: "/srv/agent-lab" } }); + + expect(saveSpy).not.toHaveBeenCalled(); + expect(getDialogProperty(dialog, "sessiondError")).toBe("Agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); + }); + + it("saves a remote agent profile when granular support is advertised", async () => { + stubWindowTimers(); + const patch = { agent: { command: "agent-lab", dir: "/srv/agent-lab" } }; + const saved = configResponse(patch); + const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(saved); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.machineRuntime = { + machineId: remoteMachine.id, + ok: true, + checkedAt: "now", + capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig], + }; + + await callDialogPromise(dialog, "saveSessiondConfig", patch); + + expect(saveSpy).toHaveBeenCalledWith(patch, remoteMachine.id); + expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(saved); + }); + it("ignores stale session-daemon load responses after the selected machine changes", async () => { const load = deferred(); vi.spyOn(configApi, "config").mockReturnValue(load.promise); diff --git a/src/client/src/components/SettingsDialog.ts b/src/client/src/components/SettingsDialog.ts index 7139b30..bb6c5b4 100644 --- a/src/client/src/components/SettingsDialog.ts +++ b/src/client/src/components/SettingsDialog.ts @@ -11,7 +11,7 @@ import "./settings/SettingsShortcutsPanel"; import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings"; import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading"; import { mergeSelectedMachineAccessConfig } from "./settings/settingsMachineAccessConfig"; -import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget"; +import { agentProfileSettingsSupport, friendlySelectedMachineSettingsErrorMessage, isAgentProfileSettingsSupported, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type AgentProfileSettingsSupport, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget"; import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settings/settingsPluginConfig"; import { mergeSelectedMachineSessiondConfig } from "./settings/settingsSessiondConfig"; @@ -24,6 +24,7 @@ export class SettingsDialog extends LitElement { @property({ attribute: false }) onNavigate?: (section: SettingsSection) => void; @property({ attribute: false }) onClose?: () => void; @property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void; + @property({ attribute: false }) onRefreshMachineRuntime?: (machineId: string) => void | Promise; @state() private configResponse: PiWebConfigResponse | undefined; @state() private accessConfigResponse: PiWebConfigResponse | undefined; @state() private sessiondConfigResponse: PiWebConfigResponse | undefined; @@ -57,7 +58,7 @@ export class SettingsDialog extends LitElement { super.connectedCallback(); void this.loadConfig(); void this.loadAccessConfigForTarget(); - void this.loadSessiondConfigForTarget(); + void this.reloadSessiondState(); void this.loadPluginsForTarget(); void this.loadPackagesForTarget(); } @@ -137,7 +138,9 @@ export class SettingsDialog extends LitElement { .error=${this.sessiondError} .savedMessage=${this.savedMessage} .targetLabel=${settingsMachineTargetLabel(this.settingsTarget())} - .onReload=${() => this.loadSessiondConfigForTarget()} + .activeAgentProfile=${this.machineRuntime?.components?.sessiond.activeAgentProfile} + .agentProfileSupport=${this.agentProfileSettingsSupport()} + .onReload=${() => this.reloadSessiondState()} .onSave=${(config: PiWebConfigValues) => this.saveSessiondConfig(config)} > `; @@ -264,6 +267,13 @@ export class SettingsDialog extends LitElement { } } + private async reloadSessiondState(target = this.settingsTarget()): Promise { + await Promise.all([ + this.loadSessiondConfigForTarget(target), + this.onRefreshMachineRuntime?.(target.id), + ]); + } + private async loadSessiondConfigForTarget(target = this.settingsTarget()): Promise { const requestSeq = ++this.sessiondLoadRequestSeq; const support = this.selectedMachineSettingsSupport(target); @@ -424,6 +434,13 @@ export class SettingsDialog extends LitElement { this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`; return; } + if (config.agent !== undefined) { + const profileSupport = this.agentProfileSettingsSupport(target); + if (!isAgentProfileSettingsSupported(profileSupport)) { + this.sessiondError = profileSupport.message ?? `Agent profile settings are not available on ${settingsMachineTargetLabel(target)}.`; + return; + } + } this.saving = true; this.sessiondError = ""; this.savedMessage = ""; @@ -521,6 +538,10 @@ export class SettingsDialog extends LitElement { return selectedMachineSettingsSupport(target, this.machineRuntime); } + private agentProfileSettingsSupport(target = this.settingsTarget()): AgentProfileSettingsSupport { + return agentProfileSettingsSupport(target, this.machineRuntime); + } + private selectedMachineSettingsSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: SettingsMachineTarget): boolean { const previousSupport = selectedMachineSettingsSupport(target, previousRuntime); const currentSupport = this.selectedMachineSettingsSupport(target); diff --git a/src/client/src/components/settings/SettingsSessiondPanel.test.ts b/src/client/src/components/settings/SettingsSessiondPanel.test.ts index 965c886..56bdebf 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.test.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { TemplateResult } from "lit"; -import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; +import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api"; import { SettingsSessiondPanel } from "./SettingsSessiondPanel"; import type { SettingsNotice } from "./SettingsPanelFrame"; @@ -8,11 +8,12 @@ describe("settings-sessiond-panel layout", () => { it("names the selected machine in the scope and restart notice when config is available", () => { const panel = new SettingsSessiondPanel(); panel.targetLabel = "Lab Mac (remote machine)"; - panel.configResponse = configResponse({ + setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" }, spawnSessions: true, subsessions: false, - }); + })); + panel.activeAgentProfile = activeProfile("pi", "/srv/pi"); const rendered = flattenTemplateContent(panel.render()); @@ -20,10 +21,10 @@ describe("settings-sessiond-panel layout", () => { "Session daemon", "These settings affect the long-lived session runtime on Lab Mac (remote machine).", "Reload", - "Restart required on Lab Mac (remote machine)", - "run pi-web restart on that machine", + "Agent profile restart required on Lab Mac (remote machine)", + "Run pi-web restart on that machine", "Config file", - "Agent command for diagnostics", + "Companion CLI command", "agent-lab", "Agent state directory", "/srv/agent-lab", @@ -33,7 +34,8 @@ describe("settings-sessiond-panel layout", () => { it("orders save/load notices before the restart notice and settings content", () => { const panel = new SettingsSessiondPanel(); - panel.configResponse = configResponse({ spawnSessions: false }); + setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" }, spawnSessions: false })); + panel.activeAgentProfile = activeProfile("pi", "/srv/pi"); panel.error = "Failed to save session-daemon config."; panel.savedMessage = "Session daemon settings saved."; @@ -42,11 +44,55 @@ describe("settings-sessiond-panel layout", () => { expectTextOrder(rendered, [ "Failed to save session-daemon config.", "Session daemon settings saved.", - "Restart required on local (local gateway)", + "Agent profile restart required on local (local gateway)", "Config file", ]); }); + it("shows the profile as active without restart guidance when desired and active match", () => { + const panel = new SettingsSessiondPanel(); + setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } })); + panel.activeAgentProfile = activeProfile("agent-lab", "/srv/agent-lab"); + + const rendered = flattenTemplateContent(panel.render()); + + expect(rendered).toContain("Profile status"); + expect(rendered).toContain("Active"); + expect(rendered).not.toContain("restart required on"); + }); + + it("submits command and directory together as one profile save", async () => { + const panel = new SettingsSessiondPanel(); + const onSave = vi.fn(); + setPanelConfig(panel, configResponse({ agent: { command: "pi", dir: "/srv/pi" } })); + setPanelProperty(panel, "agentDraft", { command: " alternate-agent ", dir: " /srv/alternate " }); + panel.onSave = onSave; + const event = new Event("submit", { cancelable: true }); + + await callPanelPromise(panel, "saveAgentProfile", event); + + expect(event.defaultPrevented).toBe(true); + expect(onSave.mock.calls).toEqual([[{ agent: { command: "alternate-agent", dir: "/srv/alternate" } }]]); + }); + + it("preserves a dirty profile draft when an unrelated daemon setting is saved", () => { + const panel = new SettingsSessiondPanel(); + const initial = configResponse({ agent: { command: "pi", dir: "/srv/pi" }, spawnSessions: false }); + setPanelConfig(panel, initial); + callPanelMethod(panel, "updateAgentDraft", { command: "alternate-agent", dir: "/srv/alternate" }); + + const toggled = configResponse({ agent: { command: "pi", dir: "/srv/pi" }, spawnSessions: true }); + panel.configResponse = toggled; + callPanelMethod(panel, "willUpdate", new Map([["configResponse", initial]])); + + expect(Reflect.get(panel, "agentDraft")).toEqual({ command: "alternate-agent", dir: "/srv/alternate" }); + + const saved = configResponse({ agent: { command: "alternate-agent", dir: "/srv/alternate" }, spawnSessions: true }); + panel.configResponse = saved; + callPanelMethod(panel, "willUpdate", new Map([["configResponse", toggled]])); + expect(Reflect.get(panel, "agentDraftDirty")).toBe(false); + }); + it("shows one blocked content state without restart guidance or toggles when config is unavailable", () => { const panel = new SettingsSessiondPanel(); panel.targetLabel = "Lab Mac (remote machine)"; @@ -65,6 +111,37 @@ describe("settings-sessiond-panel layout", () => { }); }); +function activeProfile(command: string, dir: string): ActiveAgentProfileDescriptor { + return { + schemaVersion: 1, + revision: `sha256:${"a".repeat(64)}`, + command, + dir, + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }; +} + +function setPanelConfig(panel: SettingsSessiondPanel, config: PiWebConfigResponse): void { + panel.configResponse = config; + callPanelMethod(panel, "willUpdate", new Map([["configResponse", undefined]])); +} + +function setPanelProperty(panel: SettingsSessiondPanel, property: string, value: unknown): void { + if (!Reflect.set(panel, property, value)) throw new Error(`Failed to set SettingsSessiondPanel property ${property}`); +} + +async function callPanelPromise(panel: SettingsSessiondPanel, methodName: string, ...args: readonly unknown[]): Promise { + const result = callPanelMethod(panel, methodName, ...args); + if (!(result instanceof Promise)) throw new Error(`SettingsSessiondPanel.${methodName} did not return a promise`); + await result; +} + +function callPanelMethod(panel: SettingsSessiondPanel, methodName: string, ...args: readonly unknown[]): unknown { + const method: unknown = Reflect.get(panel, methodName); + if (typeof method !== "function") throw new Error(`SettingsSessiondPanel.${methodName} is not callable`); + return Reflect.apply(method, panel, args); +} + function flattenTemplateContent(template: TemplateResult): string { const chunks: string[] = []; visitTemplate(template); diff --git a/src/client/src/components/settings/SettingsSessiondPanel.ts b/src/client/src/components/settings/SettingsSessiondPanel.ts index 212a3dd..81568c9 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.ts @@ -1,9 +1,11 @@ -import { css, html, LitElement, type TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; +import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api"; import "./SettingsPanelFrame"; import type { SettingsNotice } from "./SettingsPanelFrame"; -import { agentFieldConfigPatch, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig"; +import { agentProfileConfigPatchFromDraft, agentProfileDraftFromConfig, agentProfileDraftMatchesConfig, emptyAgentProfileConfigDraft, type AgentProfileConfigDraft } from "./settingsConfigDraft"; +import type { AgentProfileSettingsSupport } from "./settingsMachineTarget"; +import { agentDirFieldOverridden, agentProfileActivationState, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig"; @customElement("settings-sessiond-panel") export class SettingsSessiondPanel extends LitElement { @@ -13,8 +15,28 @@ export class SettingsSessiondPanel extends LitElement { @property() error = ""; @property() savedMessage = ""; @property() targetLabel = "local (local gateway)"; + @property({ attribute: false }) activeAgentProfile: ActiveAgentProfileDescriptor | undefined; + @property({ attribute: false }) agentProfileSupport: AgentProfileSettingsSupport = { state: "supported" }; @property({ attribute: false }) onReload?: () => void | Promise; @property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise; + @state() private agentDraft: AgentProfileConfigDraft = emptyAgentProfileConfigDraft(); + @state() private agentDraftDirty = false; + @state() private agentLocalError = ""; + + protected override willUpdate(changed: PropertyValues): void { + if (!changed.has("configResponse")) return; + if (this.configResponse === undefined) { + this.agentDraft = emptyAgentProfileConfigDraft(); + this.agentDraftDirty = false; + this.agentLocalError = ""; + return; + } + if (!this.agentDraftDirty || agentProfileDraftMatchesConfig(this.agentDraft, this.configResponse.config)) { + this.agentDraft = agentProfileDraftFromConfig(this.configResponse.config); + this.agentDraftDirty = false; + this.agentLocalError = ""; + } + } override render(): TemplateResult { const config = this.configResponse; @@ -26,8 +48,12 @@ export class SettingsSessiondPanel extends LitElement { // Beta, off by default; also requires spawn to be enabled. const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn; const agentCommandOverridden = config?.envOverrides.agentCommand === true; - const agentDirOverridden = config?.envOverrides.agentDir === true; + const profileEditingSupported = this.agentProfileSupport.state === "supported"; + const draftCommand = agentCommandOverridden ? (config.effectiveConfig.agent?.command ?? this.agentDraft.command) : this.agentDraft.command; + const agentDirLocked = agentDirFieldOverridden(config?.envOverrides, draftCommand); + const effectiveAgentDirOverridden = config?.envOverrides.agentDir === true; const effectiveAgent = config?.effectiveConfig.agent; + const profileActivation = agentProfileActivationState(config, this.activeAgentProfile); return html` Config file ${config.path}
    - - +
    { void this.saveAgentProfile(event); }}> + ${profileEditingSupported ? null : html`
    ${this.agentProfileSupport.message ?? "Agent profile editing is unavailable for this machine."}
    `} + + +
    + +
    +
    Allow agents to start sessions @@ -109,11 +141,14 @@ export class SettingsSessiondPanel extends LitElement { Beta: agents can start child sessions they stay attached to (spawn_subsession, list_subsessions, check_subsession, read_subsession) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.
    -
    -

    Effective after environment overrides

    +
    +

    Desired after environment overrides

    -
    Agent command
    ${effectiveAgent?.command ?? html`pi default`}
    -
    Agent state
    ${effectiveAgent?.dir ?? html`~/.pi/agent default`}
    +
    Desired command
    ${effectiveAgent?.command ?? html`Unavailable`}
    +
    Desired state
    ${effectiveAgent?.dir ?? html`Unavailable`}
    +
    Active command
    ${this.activeAgentProfile?.command ?? html`Unavailable`}
    +
    Active state
    ${this.activeAgentProfile?.dir ?? html`Unavailable`}
    +
    Profile status
    ${profileActivationLabel(profileActivation)}
    Spawn sessions
    ${effectiveSpawn ? "Enabled" : html`Disabled`}
    Subsessions
    ${effectiveSubsessions ? "Enabled" : html`Disabled`}
    @@ -125,13 +160,21 @@ export class SettingsSessiondPanel extends LitElement { private panelNotices(config: PiWebConfigResponse | undefined): readonly SettingsNotice[] { const notices: SettingsNotice[] = []; - if (this.error !== "") notices.push({ type: "error", content: this.error }); + const error = this.agentLocalError || this.error; + if (error !== "") notices.push({ type: "error", content: error }); if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage }); - if (config !== undefined) { + const activation = agentProfileActivationState(config, this.activeAgentProfile); + if (activation === "restart-required") { notices.push({ type: "warning", - title: `Restart required on ${this.targetLabel}`, - content: html`run pi-web restart on that machine (or restart its session daemon service) after changing these settings.`, + title: `Agent profile restart required on ${this.targetLabel}`, + content: html`The desired profile differs from the active session-daemon profile. Run pi-web restart on that machine (or restart its session daemon service) to apply the command and state directory together.`, + }); + } else if (config !== undefined && activation === "unavailable" && this.agentProfileSupport.state === "supported") { + notices.push({ + type: "info", + title: `Active agent profile unavailable on ${this.targetLabel}`, + content: "PI WEB cannot compare the desired profile with the running session daemon. Reload after the daemon is available.", }); } return notices; @@ -141,9 +184,20 @@ export class SettingsSessiondPanel extends LitElement { return html`
    ${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}
    `; } - private async saveAgentField(field: "command" | "dir", event: Event): Promise { - if (!(event.target instanceof HTMLInputElement)) return; - await this.onSave?.(agentFieldConfigPatch(this.configResponse?.config ?? {}, field, event.target.value)); + private async saveAgentProfile(event: Event): Promise { + event.preventDefault(); + this.agentLocalError = ""; + try { + await this.onSave?.(agentProfileConfigPatchFromDraft(this.agentDraft)); + } catch (error) { + this.agentLocalError = errorMessage(error); + } + } + + private updateAgentDraft(patch: Partial): void { + this.agentDraft = { ...this.agentDraft, ...patch }; + this.agentDraftDirty = true; + this.agentLocalError = ""; } private async toggleSpawnSessions(event: Event): Promise { @@ -162,9 +216,13 @@ export class SettingsSessiondPanel extends LitElement { button, input { font: inherit; } button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } button:disabled { opacity: .55; cursor: not-allowed; } - .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } + .loading-card, .config-path-card, .effective-card, .profile-support-message { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } .loading-card { color: var(--pi-muted); } .config-path-card { display: grid; gap: 5px; } + .profile-form { display: grid; gap: 14px; } + .profile-support-message { color: var(--pi-muted); line-height: 1.45; } + .form-actions { display: flex; justify-content: flex-end; } + .primary { border-color: var(--pi-accent); background: var(--pi-accent); color: var(--pi-accent-contrast); } .config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } .field { display: grid; gap: 7px; } @@ -201,6 +259,20 @@ export class SettingsSessiondPanel extends LitElement { `; } +function profileActivationLabel(state: ReturnType): string | TemplateResult { + if (state === "active") return "Active"; + if (state === "restart-required") return "Restart required"; + return html`Unavailable`; +} + +function inputValue(event: Event): string { + return event.target instanceof HTMLInputElement ? event.target.value : ""; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function sessiondDescription(targetLabel: string): string { return `These settings affect the long-lived session runtime on ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`; } diff --git a/src/client/src/components/settings/settingsConfigDraft.test.ts b/src/client/src/components/settings/settingsConfigDraft.test.ts index 4fa2947..3990105 100644 --- a/src/client/src/components/settings/settingsConfigDraft.test.ts +++ b/src/client/src/components/settings/settingsConfigDraft.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; import { + agentProfileConfigPatchFromDraft, + agentProfileDraftFromConfig, + agentProfileDraftMatchesConfig, gatewayServerConfigFromDraft, gatewayServerDraftFromConfig, machineAccessConfigPatchFromDraft, @@ -29,6 +32,22 @@ describe("settings config drafts", () => { expect(gatewayServerDraftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all"); }); + it("builds one atomic agent profile patch from both draft fields", () => { + expect(agentProfileDraftFromConfig({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } })).toEqual({ + command: "agent-lab", + dir: "/srv/agent-lab", + }); + expect(agentProfileConfigPatchFromDraft({ command: " alternate-agent ", dir: " /srv/alternate-agent " })).toEqual({ + agent: { command: "alternate-agent", dir: "/srv/alternate-agent" }, + }); + expect(agentProfileConfigPatchFromDraft({ command: " ", dir: " " })).toEqual({ agent: {} }); + expect(agentProfileConfigPatchFromDraft({ command: " C:\\tools\\pi.exe ", dir: " C:\\agent-profiles\\work " })).toEqual({ + agent: { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\work" }, + }); + expect(agentProfileDraftMatchesConfig({ command: " agent-lab ", dir: " /srv/agent-lab " }, { agent: { command: "agent-lab", dir: "/srv/agent-lab" } })).toBe(true); + expect(agentProfileDraftMatchesConfig({ command: "agent-lab", dir: "/draft" }, { agent: { command: "agent-lab", dir: "/saved" } })).toBe(false); + }); + it("builds gateway server saves without dropping preserved config values", () => { expect(gatewayServerConfigFromDraft({ host: " gateway.local ", diff --git a/src/client/src/components/settings/settingsConfigDraft.ts b/src/client/src/components/settings/settingsConfigDraft.ts index 01ffcef..0afca5d 100644 --- a/src/client/src/components/settings/settingsConfigDraft.ts +++ b/src/client/src/components/settings/settingsConfigDraft.ts @@ -12,6 +12,11 @@ export interface MachineAccessConfigDraft { uploadDefaultFolder: string; } +export interface AgentProfileConfigDraft { + command: string; + dir: string; +} + export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft { return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" }; } @@ -20,6 +25,10 @@ export function emptyMachineAccessConfigDraft(): MachineAccessConfigDraft { return { allowedPathsText: "", uploadDefaultFolder: "" }; } +export function emptyAgentProfileConfigDraft(): AgentProfileConfigDraft { + return { command: "", dir: "" }; +} + export function gatewayServerDraftFromConfig(config: PiWebConfigValues): GatewayServerConfigDraft { return { host: config.host ?? "", @@ -36,6 +45,30 @@ export function machineAccessDraftFromConfig(config: PiWebConfigValues): Machine }; } +export function agentProfileDraftFromConfig(config: PiWebConfigValues): AgentProfileConfigDraft { + return { + command: config.agent?.command ?? "", + dir: config.agent?.dir ?? "", + }; +} + +export function agentProfileConfigPatchFromDraft(draft: AgentProfileConfigDraft): PiWebConfigValues { + const command = draft.command.trim(); + const dir = draft.dir.trim(); + return { + agent: { + ...(command === "" ? {} : { command }), + ...(dir === "" ? {} : { dir }), + }, + }; +} + +export function agentProfileDraftMatchesConfig(draft: AgentProfileConfigDraft, config: PiWebConfigValues): boolean { + const normalizedDraft = agentProfileConfigPatchFromDraft(draft).agent ?? {}; + const configured = config.agent ?? {}; + return normalizedDraft.command === configured.command && normalizedDraft.dir === configured.dir; +} + export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues { const config = preservedGatewayConfigRemainder(baseConfig); const host = draft.host.trim(); diff --git a/src/client/src/components/settings/settingsMachineTarget.test.ts b/src/client/src/components/settings/settingsMachineTarget.test.ts index b4703ed..ea47f16 100644 --- a/src/client/src/components/settings/settingsMachineTarget.test.ts +++ b/src/client/src/components/settings/settingsMachineTarget.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Machine, MachineRuntime } from "../../api"; import { PI_WEB_CAPABILITIES } from "../../../../shared/capabilities"; -import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget"; +import { agentProfileSettingsSupport, friendlySelectedMachineSettingsErrorMessage, isAgentProfileSettingsSupported, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget"; const remoteMachine: Machine = { id: "remote-a", @@ -39,6 +39,27 @@ describe("selected-machine settings target helpers", () => { expect(selectedMachineSettingsSupportKey(unsupported)).toBe(`unsupported:${selectedMachineSettingsUnavailableMessage(target)}`); }); + it("gates remote agent profile edits on their granular capability", () => { + const target = settingsMachineTarget(remoteMachine); + + expect(agentProfileSettingsSupport({ id: "local", name: "local", kind: "local" }, undefined)).toEqual({ state: "supported" }); + expect(agentProfileSettingsSupport(target, undefined)).toEqual({ + state: "unknown", + message: "Agent profile support could not be verified on Lab Mac. Reload machine status before changing the profile.", + }); + expect(agentProfileSettingsSupport(target, { + ok: true, + capabilities: [PI_WEB_CAPABILITIES.agentProfileConfig], + })).toEqual({ state: "supported" }); + + const unsupported = agentProfileSettingsSupport(target, { ok: true, capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings] }); + expect(isAgentProfileSettingsSupported(unsupported)).toBe(false); + expect(unsupported).toEqual({ + state: "unsupported", + message: "Agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.", + }); + }); + it("turns older remote config route failures into selected-machine compatibility guidance", () => { const target = settingsMachineTarget(remoteMachine); diff --git a/src/client/src/components/settings/settingsMachineTarget.ts b/src/client/src/components/settings/settingsMachineTarget.ts index 9974081..f4489e5 100644 --- a/src/client/src/components/settings/settingsMachineTarget.ts +++ b/src/client/src/components/settings/settingsMachineTarget.ts @@ -14,6 +14,8 @@ export interface SelectedMachineSettingsSupport { message?: string; } +export type AgentProfileSettingsSupport = SelectedMachineSettingsSupport; + export function settingsMachineTarget(machine: Pick | undefined): SettingsMachineTarget { if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind }; return { id: "local", name: "local", kind: "local" }; @@ -30,6 +32,21 @@ export function selectedMachineSettingsSupport(target: SettingsMachineTarget, ru return { state: "unsupported", message: selectedMachineSettingsUnavailableMessage(target) }; } +export function agentProfileSettingsSupport(target: SettingsMachineTarget, runtime: Pick | undefined): AgentProfileSettingsSupport { + if (target.kind === "local") return { state: "supported" }; + if (runtime?.ok !== true) { + return { + state: "unknown", + message: `Agent profile support could not be verified on ${target.name}. Reload machine status before changing the profile.`, + }; + } + if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.agentProfileConfig)) return { state: "supported" }; + return { + state: "unsupported", + message: `Agent profile settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`, + }; +} + export function selectedMachineSettingsSupportKey(support: SelectedMachineSettingsSupport): string { return `${support.state}:${support.message ?? ""}`; } @@ -38,6 +55,10 @@ export function isSelectedMachineSettingsUnsupported(support: SelectedMachineSet return support?.state === "unsupported"; } +export function isAgentProfileSettingsSupported(support: AgentProfileSettingsSupport | undefined): boolean { + return support?.state === "supported"; +} + export function selectedMachineSettingsUnavailableMessage(target: SettingsMachineTarget): string { return `Selected-machine settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`; } diff --git a/src/client/src/components/settings/settingsSessiondConfig.test.ts b/src/client/src/components/settings/settingsSessiondConfig.test.ts index 45a5a21..69d1752 100644 --- a/src/client/src/components/settings/settingsSessiondConfig.test.ts +++ b/src/client/src/components/settings/settingsSessiondConfig.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; -import { agentFieldConfigPatch, mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig"; +import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api"; +import { agentDirFieldOverridden, agentProfileActivationState, mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig"; describe("session daemon settings config helpers", () => { it("builds daemon-only save patches for the sessiond toggles", () => { @@ -8,20 +8,37 @@ describe("session daemon settings config helpers", () => { expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true }); }); - it("builds agent-only patches while preserving sibling agent fields", () => { - const base = { - host: "127.0.0.1", - agent: { command: "agent-lab", dir: "/srv/agent-lab" }, - }; + it("compares the desired effective profile with the daemon-owned active profile", () => { + const config = configResponse( + { agent: { command: "configured-agent", dir: "/configured" } }, + {}, + { agent: { command: "effective-agent", dir: "/effective" } }, + ); - expect(agentFieldConfigPatch(base, "command", " alternate-agent ")).toEqual({ - agent: { command: "alternate-agent", dir: "/srv/agent-lab" }, - }); - expect(agentFieldConfigPatch(base, "dir", " ")).toEqual({ - agent: { command: "agent-lab" }, - }); - expect(agentFieldConfigPatch({ agent: { dir: "/srv/agent-lab" } }, "dir", "")).toEqual({ agent: {} }); - expect(agentFieldConfigPatch(base, "command", "agent-lab")).not.toHaveProperty("host"); + expect(agentProfileActivationState(config, activeProfile("effective-agent", "/effective"))).toBe("active"); + expect(agentProfileActivationState(config, activeProfile("other-agent", "/effective"))).toBe("restart-required"); + expect(agentProfileActivationState(config, activeProfile("effective-agent", "/other"))).toBe("restart-required"); + expect(agentProfileActivationState(configResponse({}, {}, { agent: { command: "pi", dir: "/effective" } }), activeProfile("pi", "/effective"))).toBe("restart-required"); + expect(agentProfileActivationState(configResponse({}, {}, { agent: { command: "pi", dir: "/effective" } }), activeProfile("pi", "/effective", ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]))).toBe("active"); + expect(agentProfileActivationState(config, undefined)).toBe("unavailable"); + expect(agentProfileActivationState(undefined, activeProfile("effective-agent", "/effective"))).toBe("unavailable"); + }); + + it("releases only Pi's compatibility directory override when the draft selects an alternate command", () => { + const baseOverrides = configResponse({}).envOverrides; + + expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "pi")).toBe(true); + expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "pi.exe")).toBe(true); + expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "alternate-agent")).toBe(false); + expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-web" }, "alternate-agent")).toBe(true); + expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true }, "alternate-agent")).toBe(true); + }); + + it("does not leak the gateway agent directory source into a selected-machine response", () => { + const gateway = configResponse({}, { agentDir: true, agentDirSource: "pi-web" }); + const selectedMachine = configResponse({}, { agentDir: false }); + + expect(mergeSelectedMachineSessiondConfig(gateway, selectedMachine).envOverrides.agentDirSource).toBeUndefined(); }); it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => { @@ -37,7 +54,7 @@ describe("session daemon settings config helpers", () => { }); const selectedMachine = configResponse( { spawnSessions: true, subsessions: true, agent: { command: "machine-agent", dir: "/srv/machine-agent" } }, - { spawnSessions: true, subsessions: false, agentCommand: true, agentDir: false, agentSessionDir: true }, + { spawnSessions: true, subsessions: false, agentCommand: true, agentDir: false, agentDirSource: "pi-compatibility", agentSessionDir: true }, { spawnSessions: true, subsessions: true, agent: { command: "env-agent", dir: "/srv/machine-agent" } }, ); @@ -71,12 +88,23 @@ describe("session daemon settings config helpers", () => { subsessions: false, agentCommand: true, agentDir: false, + agentDirSource: "pi-compatibility", agentSessionDir: true, }, }); }); }); +function activeProfile(command: string, dir: string, sessionDirEnvKeys: readonly string[] = ["PI_WEB_AGENT_SESSION_DIR"]): ActiveAgentProfileDescriptor { + return { + schemaVersion: 1, + revision: `sha256:${"a".repeat(64)}`, + command, + dir, + sessionDirEnvKeys, + }; +} + function configResponse( config: PiWebConfigValues, overrides: Partial = {}, diff --git a/src/client/src/components/settings/settingsSessiondConfig.ts b/src/client/src/components/settings/settingsSessiondConfig.ts index 1fe38d8..f330482 100644 --- a/src/client/src/components/settings/settingsSessiondConfig.ts +++ b/src/client/src/components/settings/settingsSessiondConfig.ts @@ -1,4 +1,7 @@ -import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; +import { usesPiCodingAgentStateCompatibility } from "../../../../shared/activeAgentProfile"; +import type { ActiveAgentProfileDescriptor, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../../api"; + +export type AgentProfileActivationState = "active" | "restart-required" | "unavailable"; export function spawnSessionsConfigPatch(enabled: boolean): PiWebConfigValues { return { spawnSessions: enabled }; @@ -8,36 +11,51 @@ export function subsessionsConfigPatch(enabled: boolean): PiWebConfigValues { return { subsessions: enabled }; } -export function agentFieldConfigPatch( - baseConfig: PiWebConfigValues, - field: "command" | "dir", - rawValue: string, -): PiWebConfigValues { - const value = rawValue.trim(); - const agent: NonNullable = { ...(baseConfig.agent ?? {}) }; - if (field === "command") { - if (value === "") delete agent.command; - else agent.command = value; - } else if (value === "") { - delete agent.dir; - } else { - agent.dir = value; - } - return { agent }; +export function agentProfileActivationState( + config: PiWebConfigResponse | undefined, + activeProfile: ActiveAgentProfileDescriptor | undefined, +): AgentProfileActivationState { + const desiredProfile = config?.effectiveConfig.agent; + if (desiredProfile?.command === undefined || desiredProfile.dir === undefined || activeProfile === undefined) return "unavailable"; + const desiredSessionDirEnvKeys = [ + "PI_WEB_AGENT_SESSION_DIR", + ...(usesPiCodingAgentStateCompatibility(desiredProfile.command) ? ["PI_CODING_AGENT_SESSION_DIR"] : []), + ]; + return desiredProfile.command === activeProfile.command + && desiredProfile.dir === activeProfile.dir + && sameStrings(activeProfile.sessionDirEnvKeys, desiredSessionDirEnvKeys) + ? "active" + : "restart-required"; +} + +export function agentDirFieldOverridden(envOverrides: PiWebConfigEnvOverrides | undefined, draftCommand: string): boolean { + if (envOverrides?.agentDirSource === "pi-web") return true; + if (envOverrides?.agentDirSource === "pi-compatibility") return usesPiCodingAgentStateCompatibility(draftCommand.trim() || "pi"); + // Older remote responses do not identify the source. Keep their override + // read-only rather than incorrectly treating a PI_WEB_AGENT_DIR as conditional. + return envOverrides?.agentDir === true; } export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse { + const envOverrides: PiWebConfigEnvOverrides = { + ...base.envOverrides, + spawnSessions: selectedMachine.envOverrides.spawnSessions, + subsessions: selectedMachine.envOverrides.subsessions, + agentCommand: selectedMachine.envOverrides.agentCommand, + agentDir: selectedMachine.envOverrides.agentDir, + agentSessionDir: selectedMachine.envOverrides.agentSessionDir, + }; + if (selectedMachine.envOverrides.agentDirSource === undefined) delete envOverrides.agentDirSource; + else envOverrides.agentDirSource = selectedMachine.envOverrides.agentDirSource; + return { ...base, config: { ...base.config, ...selectedMachine.config }, effectiveConfig: { ...base.effectiveConfig, ...selectedMachine.effectiveConfig }, - envOverrides: { - ...base.envOverrides, - spawnSessions: selectedMachine.envOverrides.spawnSessions, - subsessions: selectedMachine.envOverrides.subsessions, - agentCommand: selectedMachine.envOverrides.agentCommand, - agentDir: selectedMachine.envOverrides.agentDir, - agentSessionDir: selectedMachine.envOverrides.agentSessionDir, - }, + envOverrides, }; } + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/src/client/src/controllers/machineController.test.ts b/src/client/src/controllers/machineController.test.ts index 775d62d..759df89 100644 --- a/src/client/src/controllers/machineController.test.ts +++ b/src/client/src/controllers/machineController.test.ts @@ -93,7 +93,7 @@ describe("MachineController", () => { expect(projects.loadProjects).toHaveBeenCalledOnce(); expect(updateUrl).toHaveBeenCalledOnce(); expect(health).toHaveBeenCalledWith(addedMachine.id); - expect(runtime).toHaveBeenCalledWith(addedMachine.id); + expect(runtime).toHaveBeenCalledWith(addedMachine.id, true); }); it("preserves the current machine state when adding a machine fails", async () => { diff --git a/src/client/src/controllers/machineController.ts b/src/client/src/controllers/machineController.ts index b508315..0378173 100644 --- a/src/client/src/controllers/machineController.ts +++ b/src/client/src/controllers/machineController.ts @@ -102,7 +102,7 @@ export class MachineController { async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise { try { - const runtime = await api.runtime(machineId); + const runtime = await api.runtime(machineId, true); this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, [runtime.machineId]: runtime } }); } catch (error) { this.setState({ error: String(error) }); diff --git a/src/config.test.ts b/src/config.test.ts index 431ecf1..be5c0d1 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentSessionDirEnvKeys, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; +import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; let tempDir: string; let configPath: string; @@ -135,22 +135,30 @@ describe("PI WEB config persistence", () => { }); it("uses explicit PI WEB agent directory env precedence", () => { - expect(effectiveAgentConfig({ + const env = { PI_WEB_AGENT_COMMAND: "acme-agent", PI_WEB_AGENT_DIR: join(tempDir, "web-env-agent"), PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"), - }, { agent: { command: "pi", dir: join(tempDir, "config-agent") } })).toMatchObject({ + }; + expect(effectiveAgentConfig(env, { agent: { command: "pi", dir: join(tempDir, "config-agent") } })).toMatchObject({ command: "acme-agent", dir: join(tempDir, "web-env-agent"), }); + expect(agentDirEnvSource(env)).toBe("pi-web"); }); it("keeps legacy Pi env directory overrides scoped to the canonical Pi command", () => { const legacyDir = join(tempDir, "pi-env-agent"); - expect(effectiveAgentConfig({ PI_CODING_AGENT_DIR: legacyDir }, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({ dir: legacyDir }); + const alternateDir = join(tempDir, "alternate-agent"); + const env = { PI_CODING_AGENT_DIR: legacyDir }; + expect(effectiveAgentConfig(env, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({ dir: legacyDir }); + expect(effectiveAgentConfig(env, { agent: { command: "acme-agent", dir: alternateDir } })).toMatchObject({ command: "acme-agent", dir: alternateDir }); + expect(agentDirEnvSource(env)).toBe("pi-compatibility"); + expect(hasAgentDirEnvOverride(env, "pi")).toBe(true); + expect(hasAgentDirEnvOverride(env, "acme-agent")).toBe(false); for (const command of ["acme-agent", join(tempDir, "bin", "pi")]) { - expect(() => effectiveAgentConfig({ PI_CODING_AGENT_DIR: legacyDir }, { agent: { command } })) + expect(() => effectiveAgentConfig(env, { agent: { command } })) .toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`); } }); diff --git a/src/config.ts b/src/config.ts index 21286cd..8ad0920 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, normalize, resolve } from "node:path"; -import type { PiWebConfigValues } from "./shared/apiTypes.js"; +import type { PiWebAgentDirEnvSource, PiWebConfigValues } from "./shared/apiTypes.js"; +import { isPiCompanionCommand, usesPiCodingAgentStateCompatibility } from "./shared/activeAgentProfile.js"; import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js"; +export { isPiCompanionCommand }; + export type PiWebConfig = PiWebConfigValues; export interface LoadedPiWebConfig { @@ -61,7 +64,7 @@ export interface EffectivePiWebAgentConfig { export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick = {}): EffectivePiWebAgentConfig { const command = parseAgentCommand(envValue(env, PI_WEB_AGENT_COMMAND_ENV) ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment", "current"); - const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? (usesDefaultPiStatePolicy(command) ? envValue(env, PI_CODING_AGENT_DIR_ENV) : undefined) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env); + const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? (usesPiCodingAgentStateCompatibility(command) ? envValue(env, PI_CODING_AGENT_DIR_ENV) : undefined) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env); return { command, dir: resolveAgentDirPath(configuredDir, env, "agent.dir", "environment"), @@ -72,12 +75,19 @@ export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, confi export function agentSessionDirEnvKeys(command = DEFAULT_AGENT_COMMAND): string[] { return uniqueStrings([ PI_WEB_AGENT_SESSION_DIR_ENV, - ...(usesDefaultPiStatePolicy(command) ? [PI_CODING_AGENT_SESSION_DIR_ENV] : []), + ...(usesPiCodingAgentStateCompatibility(command) ? [PI_CODING_AGENT_SESSION_DIR_ENV] : []), ]); } +export function agentDirEnvSource(env: NodeJS.ProcessEnv): PiWebAgentDirEnvSource | undefined { + if (isEnvSet(env[PI_WEB_AGENT_DIR_ENV])) return "pi-web"; + if (isEnvSet(env[PI_CODING_AGENT_DIR_ENV])) return "pi-compatibility"; + return undefined; +} + export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { - return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || (usesDefaultPiStatePolicy(command) && isEnvSet(env[PI_CODING_AGENT_DIR_ENV])); + const source = agentDirEnvSource(env); + return source === "pi-web" || (source === "pi-compatibility" && usesPiCodingAgentStateCompatibility(command)); } export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { @@ -391,19 +401,10 @@ function expandHomePath(value: string, env: NodeJS.ProcessEnv): string { } function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string { - if (usesDefaultPiStatePolicy(command)) return expandHomePath("~/.pi/agent", env); + if (usesPiCodingAgentStateCompatibility(command)) return expandHomePath("~/.pi/agent", env); throw new Error(`PI WEB config agent.dir or ${PI_WEB_AGENT_DIR_ENV} is required when agent.command is ${JSON.stringify(command)}`); } -function usesDefaultPiStatePolicy(command: string): boolean { - return !command.includes("/") && !command.includes("\\") && isPiCompanionCommand(command); -} - -export function isPiCompanionCommand(command: string): boolean { - const name = command.split(/[\\/]/u).at(-1)?.toLowerCase() ?? command.toLowerCase(); - return name.replace(/(?:\.[cm]?js|\.exe|\.cmd)$/iu, "") === DEFAULT_AGENT_COMMAND; -} - function envValue(env: NodeJS.ProcessEnv, key: string): string | undefined { const value = env[key]; return value !== undefined && value !== "" ? value : undefined; diff --git a/src/server/app.machines.test.ts b/src/server/app.machines.test.ts index 6bdf2d1..d0d2698 100644 --- a/src/server/app.machines.test.ts +++ b/src/server/app.machines.test.ts @@ -61,18 +61,39 @@ describe("buildApp machine routes", () => { packageName: "@jmfederico/pi-web", generatedAt: "2026-05-25T00:00:00.000Z", components: { - web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] }, - sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }, + web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"] }, + sessiond: { + component: "sessiond", + label: "Remote Sessiond", + runtimeVersion: "1.0.0", + available: true, + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived], + activeAgentProfile: { + schemaVersion: 1, + revision: `sha256:${"a".repeat(64)}`, + command: "remote-agent", + dir: "/srv/remote-agent", + sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"], + }, + }, }, - capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"], + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"], }, })); appTestContext.remoteClient = fakeRemoteClient({ requestJson }); const runtime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` }); + const refreshedRuntime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime?refresh=1` }); expect(runtime.statusCode).toBe(200); - expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] }); + expect(refreshedRuntime.statusCode).toBe(200); + expect(runtime.json()).toMatchObject({ + machineId: remote.id, + ok: true, + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig], + components: { sessiond: { activeAgentProfile: { command: "remote-agent", dir: "/srv/remote-agent" } } }, + }); + expect(requestJson).toHaveBeenCalledTimes(2); expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 }); }); @@ -101,9 +122,11 @@ describe("buildApp machine routes", () => { it("merges remote selected-machine config updates into the target machine config", async () => { const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const remote = addResponse.json<{ id: string }>(); + let persistedConfig = fullPiWebConfig(); const requestJson = vi.fn((method, _path, body) => { - if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(fullPiWebConfig()) }); - return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(configFromMachineConfigWriteBody(body)) }); + if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(persistedConfig) }); + persistedConfig = configFromMachineConfigWriteBody(body); + return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(persistedConfig) }); }); appTestContext.remoteClient = fakeRemoteClient({ requestJson }); @@ -136,6 +159,102 @@ describe("buildApp machine routes", () => { }); }); + it("rejects a false-success agent profile write from an older remote machine", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const legacyConfig = fullPiWebConfig(); + delete legacyConfig.agent; + const requestJson = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: piWebConfigResponse(legacyConfig), + })); + appTestContext.remoteClient = fakeRemoteClient({ requestJson }); + + const response = await appTestContext.app.inject({ + method: "PUT", + url: `/api/machines/${remote.id}/config`, + payload: { config: { agent: { command: "remote-agent", dir: "/srv/remote-agent" } } }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ + error: "Remote machine did not persist the requested agent profile", + machineId: remote.id, + }); + expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config"); + expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", { + config: { ...legacyConfig, agent: { command: "remote-agent", dir: "/srv/remote-agent" } }, + }); + }); + + it("verifies an explicit remote profile reset instead of treating an empty profile as no patch", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const requestJson = vi.fn((method) => { + const config = fullPiWebConfig(); + if (method === "PUT") delete config.agent; + return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) }); + }); + appTestContext.remoteClient = fakeRemoteClient({ requestJson }); + + const response = await appTestContext.app.inject({ + method: "PUT", + url: `/api/machines/${remote.id}/config`, + payload: { config: { agent: {} } }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ error: "Remote machine did not persist the requested agent profile" }); + expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", { + config: { ...fullPiWebConfig(), agent: {} }, + }); + }); + + it("keeps non-profile selected-machine saves compatible with older remote machines", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const legacyConfig = fullPiWebConfig(); + delete legacyConfig.agent; + const requestJson = vi.fn((method, _path, body) => { + const config = method === "PUT" ? configFromMachineConfigWriteBody(body) : legacyConfig; + return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) }); + }); + appTestContext.remoteClient = fakeRemoteClient({ requestJson }); + + const response = await appTestContext.app.inject({ + method: "PUT", + url: `/api/machines/${remote.id}/config`, + payload: { config: { spawnSessions: true } }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().config.spawnSessions).toBe(true); + }); + + it("preserves foreign-platform agent paths while the target verifies persistence", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const windowsAgent = { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\work" }; + const requestJson = vi.fn((method, _path, body) => { + const config = method === "PUT" ? configFromMachineConfigWriteBody(body) : fullPiWebConfig(); + return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) }); + }); + appTestContext.remoteClient = fakeRemoteClient({ requestJson }); + + const response = await appTestContext.app.inject({ + method: "PUT", + url: `/api/machines/${remote.id}/config`, + payload: { config: { agent: windowsAgent } }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().config.agent).toEqual(windowsAgent); + expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", { + config: { ...fullPiWebConfig(), agent: windowsAgent }, + }); + }); + it("rejects unsafe remote selected-machine config keys before proxying", async () => { const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const remote = addResponse.json<{ id: string }>(); diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index 6225d12..3067008 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -202,6 +202,23 @@ describe("config routes", () => { expect(parsed.envOverrides).toMatchObject({ agentCommand: false, agentDir: false, agentSessionDir: false }); }); + it("retains the agent directory environment source across federation responses", () => { + const parsed = parsePiWebConfigResponseBody({ + ...responseFor({}, false), + envOverrides: { + ...responseFor({}, false).envOverrides, + agentDir: true, + agentDirSource: "pi-compatibility", + }, + }); + + expect(parsed.envOverrides).toMatchObject({ agentDir: true, agentDirSource: "pi-compatibility" }); + expect(() => parsePiWebConfigResponseBody({ + ...responseFor({}, false), + envOverrides: { ...responseFor({}, false).envOverrides, agentDirSource: "future-source" }, + })).toThrow("valid agent directory source"); + }); + it("rejects unsafe local selected-machine config keys before writing", async () => { savedConfig = fullConfig(); diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index 5ab96a3..ef3c6b4 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; -import { hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseAgentConfig, parseUploadsConfig, resolveEffectivePiWebConfig, savePiWebConfig, type AgentPathHost, type LoadOptions, type PiWebConfig } from "../config.js"; -import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; +import { agentDirEnvSource, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseAgentConfig, parseUploadsConfig, resolveEffectivePiWebConfig, savePiWebConfig, type AgentPathHost, type LoadOptions, type PiWebConfig } from "../config.js"; +import type { PiWebAgentDirEnvSource, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; import { isPiWebPluginId } from "../shared/pluginIds.js"; export interface PiWebConfigService { @@ -243,6 +243,7 @@ function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): P subsessions: requireResponseBoolean(record, "subsessions", source), agentCommand: optionalResponseBoolean(record, "agentCommand", source) ?? false, agentDir: optionalResponseBoolean(record, "agentDir", source) ?? false, + ...optionalAgentDirSource(record, source), agentSessionDir: optionalResponseBoolean(record, "agentSessionDir", source) ?? false, }; } @@ -271,8 +272,16 @@ function optionalResponseBoolean(record: Record, key: string, s return value; } +function optionalAgentDirSource(record: Record, source: string): { agentDirSource?: PiWebAgentDirEnvSource } { + const value = record["agentDirSource"]; + if (value === undefined) return {}; + if (value !== "pi-web" && value !== "pi-compatibility") throw new Error(`${source} field must be a valid agent directory source: agentDirSource`); + return { agentDirSource: value }; +} + function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {}): PiWebConfigEnvOverrides { const command = config.agent?.command; + const dirEnvSource = agentDirEnvSource(env); return { host: isEnvSet(env["PI_WEB_HOST"]), port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]), @@ -281,6 +290,7 @@ function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = { subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]), agentCommand: isEnvSet(env["PI_WEB_AGENT_COMMAND"]), agentDir: hasAgentDirEnvOverride(env, command), + ...(dirEnvSource === undefined ? {} : { agentDirSource: dirEnvSource }), agentSessionDir: hasAgentSessionDirEnvOverride(env, command), }; } diff --git a/src/server/machines/machineProxyRoutes.ts b/src/server/machines/machineProxyRoutes.ts index e9d85d0..31620dd 100644 --- a/src/server/machines/machineProxyRoutes.ts +++ b/src/server/machines/machineProxyRoutes.ts @@ -1,5 +1,6 @@ import type { FastifyInstance, FastifyReply } from "fastify"; import type { WebSocket } from "ws"; +import type { PiWebAgentConfig } from "../../shared/apiTypes.js"; import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js"; import { mergeSelectedMachineConfig, parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, selectedMachineConfigResponse } from "../configRoutes.js"; import { bridgeSockets } from "../webSocketBridge.js"; @@ -75,7 +76,8 @@ async function proxySelectedMachineConfigRequest(client: MachineClient, machineI const current = parsePiWebConfigResponseBody(currentResponse.body, "Remote machine config response"); const merged = mergeSelectedMachineConfig(current.config, patch); - return sendSelectedMachineConfigResponse(reply, await client.requestJson("PUT", remotePath, { config: merged }), machineId); + const updateResponse = await client.requestJson("PUT", remotePath, { config: merged }); + return sendSelectedMachineConfigResponse(reply, updateResponse, machineId, patch.agent); } return reply.code(405).send({ error: "Method not allowed" }); @@ -85,11 +87,23 @@ function configPayload(body: unknown): unknown { return isRecord(body) ? body["config"] : undefined; } -function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply { +function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string, expectedAgentProfile?: PiWebAgentConfig): FastifyReply { if (!isSuccessfulStatus(upstream.statusCode)) return sendUpstreamJsonResponse(reply, upstream, machineId); + const response = parsePiWebConfigResponseBody(upstream.body, "Remote machine config response"); + if (expectedAgentProfile !== undefined && !sameAgentProfile(response.config.agent, expectedAgentProfile)) { + return reply.code(409).send({ + error: "Remote machine did not persist the requested agent profile", + machineId, + detail: "Update and restart PI WEB on the remote machine before changing its agent profile.", + }); + } reply.code(upstream.statusCode); applySafeHeaders(reply, upstream.headers); - return reply.send(selectedMachineConfigResponse(parsePiWebConfigResponseBody(upstream.body, "Remote machine config response"))); + return reply.send(selectedMachineConfigResponse(response)); +} + +function sameAgentProfile(actual: PiWebAgentConfig | undefined, expected: PiWebAgentConfig): boolean { + return actual !== undefined && actual.command === expected.command && actual.dir === expected.dir; } function sendUpstreamJsonResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply { diff --git a/src/server/machines/machineRoutes.ts b/src/server/machines/machineRoutes.ts index 28a2802..3e6a861 100644 --- a/src/server/machines/machineRoutes.ts +++ b/src/server/machines/machineRoutes.ts @@ -18,8 +18,8 @@ export function registerMachineRoutes(app: FastifyInstance, machines = new Machi return health; }); - app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/runtime", async (request, reply) => { - const runtime = await machines.runtime(request.params.machineId); + app.get<{ Params: { machineId: string }; Querystring: { refresh?: string } }>("/api/machines/:machineId/runtime", async (request, reply) => { + const runtime = await machines.runtime(request.params.machineId, request.query.refresh === "1"); if (runtime === undefined) return reply.code(404).send({ error: "Machine not found" }); return runtime; }); diff --git a/src/server/machines/machineService.test.ts b/src/server/machines/machineService.test.ts index 9907a8c..7a0189a 100644 --- a/src/server/machines/machineService.test.ts +++ b/src/server/machines/machineService.test.ts @@ -171,6 +171,7 @@ describe("MachineService", () => { const first = await remoteService.runtime(machine.id); const second = await remoteService.runtime(machine.id); + const forced = await remoteService.runtime(machine.id, true); expect(first).toEqual({ machineId: machine.id, @@ -182,7 +183,8 @@ describe("MachineService", () => { capabilities: body.capabilities, }); expect(second).toEqual(first); - expect(requestJson).toHaveBeenCalledTimes(1); + expect(forced).toEqual(first); + expect(requestJson).toHaveBeenCalledTimes(2); expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 }); expect(factoryMachines).toEqual([ expect.objectContaining({ @@ -192,6 +194,7 @@ describe("MachineService", () => { token: "secret", headers: { "X-Pi-Web-Test": "yes" }, }), + expect.objectContaining({ id: machine.id }), ]); }); diff --git a/src/server/machines/machineService.ts b/src/server/machines/machineService.ts index 2e8b6a3..7896637 100644 --- a/src/server/machines/machineService.ts +++ b/src/server/machines/machineService.ts @@ -93,10 +93,10 @@ export class MachineService { return health; } - async runtime(id: string): Promise { + async runtime(id: string, refresh = false): Promise { const cached = this.runtimeCache.get(id); const now = this.now().getTime(); - if (cached !== undefined && cached.expiresAt > now) return cached.runtime; + if (!refresh && cached !== undefined && cached.expiresAt > now) return cached.runtime; const runtime = id === "local" ? await this.localRuntime() : await this.remoteRuntime(id); if (runtime === undefined) return undefined; diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index f9626ce..0bbd969 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -109,10 +109,11 @@ describe("PI WEB status", () => { const runtime = await getPiWebRuntime(daemon); - expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings])); + expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig])); expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage); expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings); - expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings])); + expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.agentProfileConfig); + expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig])); }); it("carries the daemon-owned active agent profile through the web runtime response", async () => { diff --git a/src/shared/activeAgentProfile.ts b/src/shared/activeAgentProfile.ts index 7bf07cc..8f7a65c 100644 --- a/src/shared/activeAgentProfile.ts +++ b/src/shared/activeAgentProfile.ts @@ -2,6 +2,15 @@ import type { ActiveAgentProfileDescriptor } from "./apiTypes.js"; export const ACTIVE_AGENT_PROFILE_SCHEMA_VERSION = 1 as const; +export function isPiCompanionCommand(command: string): boolean { + const name = command.split(/[\\/]/u).at(-1)?.toLowerCase() ?? command.toLowerCase(); + return name.replace(/(?:\.[cm]?js|\.exe|\.cmd)$/iu, "") === "pi"; +} + +export function usesPiCodingAgentStateCompatibility(command: string): boolean { + return !command.includes("/") && !command.includes("\\") && isPiCompanionCommand(command); +} + const ACTIVE_AGENT_PROFILE_FIELDS = new Set([ "schemaVersion", "revision", diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 2213b77..f839e1e 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -11,6 +11,7 @@ export const PI_WEB_CAPABILITIES = { workspaceFileSuggestions: "workspace.fileSuggestions", piPackagesManage: "piPackages.manage", selectedMachineSettings: "settings.selectedMachine", + agentProfileConfig: "settings.agentProfile", } as const; export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES]; @@ -149,6 +150,8 @@ export interface PiPackageMutationResponse extends PiPackagesResponse { removed?: boolean; } +export type PiWebAgentDirEnvSource = "pi-web" | "pi-compatibility"; + export interface PiWebConfigEnvOverrides { host: boolean; port: boolean; @@ -157,6 +160,8 @@ export interface PiWebConfigEnvOverrides { subsessions: boolean; agentCommand: boolean; agentDir: boolean; + /** The configured directory environment source, even when Pi compatibility is inactive for the desired command. */ + agentDirSource?: PiWebAgentDirEnvSource; agentSessionDir: boolean; } diff --git a/src/shared/capabilities.test.ts b/src/shared/capabilities.test.ts index aa03c5c..6deecd4 100644 --- a/src/shared/capabilities.test.ts +++ b/src/shared/capabilities.test.ts @@ -5,13 +5,15 @@ describe("PI WEB capabilities", () => { it("advertises web-only capabilities without requiring session daemon support", () => { expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.piPackagesManage); expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.selectedMachineSettings); + expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.agentProfileConfig); expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage); expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings); + expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.agentProfileConfig); expect(effectivePiWebCapabilities({ - web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] }, + web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig] }, sessiond: { available: false, capabilities: [] }, - })).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]); + })).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]); }); it("requires web and session daemon support for authoritative session persistence", () => { diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts index 8d00989..7a91f52 100644 --- a/src/shared/capabilities.ts +++ b/src/shared/capabilities.ts @@ -16,6 +16,7 @@ export const WEB_RUNTIME_CAPABILITIES = [ PI_WEB_CAPABILITIES.workspaceFileSuggestions, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, + PI_WEB_CAPABILITIES.agentProfileConfig, ] as const satisfies readonly PiWebCapability[]; export const SESSIOND_RUNTIME_CAPABILITIES = [ @@ -37,6 +38,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = { [PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"], [PI_WEB_CAPABILITIES.piPackagesManage]: ["web"], [PI_WEB_CAPABILITIES.selectedMachineSettings]: ["web"], + [PI_WEB_CAPABILITIES.agentProfileConfig]: ["web"], } as const satisfies Record; export function isPiWebCapability(value: unknown): value is PiWebCapability { diff --git a/src/shared/piWebStatusParsing.test.ts b/src/shared/piWebStatusParsing.test.ts index 1bbb69d..1683b12 100644 --- a/src/shared/piWebStatusParsing.test.ts +++ b/src/shared/piWebStatusParsing.test.ts @@ -8,16 +8,16 @@ describe("PI WEB status parsing", () => { packageName: "@jmfederico/pi-web", generatedAt: "now", components: { - web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"] }, + web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"] }, sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: ["future.sessiondCapability"] }, }, - capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"], + capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"], })).toMatchObject({ components: { - web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] }, + web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig] }, sessiond: { capabilities: [] }, }, - capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings], + capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig], }); });