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 } : {}), }; }