Archived
feat: expose daemon-owned active agent profile
This commit is contained in:
+13
-2
@@ -12,6 +12,17 @@ export interface LoadedPiWebConfig {
|
|||||||
config: PiWebConfig;
|
config: PiWebConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EffectivePiWebConfig extends Omit<PiWebConfig, "uploads" | "spawnSessions" | "subsessions" | "agent"> {
|
||||||
|
uploads: NonNullable<PiWebConfig["uploads"]>;
|
||||||
|
spawnSessions: boolean;
|
||||||
|
subsessions: boolean;
|
||||||
|
agent: Required<NonNullable<PiWebConfig["agent"]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadedEffectivePiWebConfig extends Omit<LoadedPiWebConfig, "config"> {
|
||||||
|
config: EffectivePiWebConfig;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoadOptions {
|
export interface LoadOptions {
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -110,11 +121,11 @@ export function loadPiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig {
|
|||||||
return { path, exists: true, config: parsePiWebConfig(parsed, path) };
|
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);
|
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 env = options.env ?? process.env;
|
||||||
const host = env["PI_WEB_HOST"];
|
const host = env["PI_WEB_HOST"];
|
||||||
const port = env["PI_WEB_PORT"] ?? env["PORT"];
|
const port = env["PI_WEB_PORT"] ?? env["PORT"];
|
||||||
|
|||||||
@@ -90,6 +90,29 @@ describe("PI WEB status", () => {
|
|||||||
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
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 () => {
|
it("bypasses cached npm release data for a forced check", async () => {
|
||||||
Reflect.deleteProperty(process.env, "PI_WEB_SKIP_VERSION_CHECK");
|
Reflect.deleteProperty(process.env, "PI_WEB_SKIP_VERSION_CHECK");
|
||||||
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
|
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
|
||||||
|
|||||||
+26
-17
@@ -20,11 +20,16 @@ import { TerminalService } from "./terminals/terminalService.js";
|
|||||||
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
|
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
|
||||||
import { getPiWebRuntimeComponent } from "./piWebStatus.js";
|
import { getPiWebRuntimeComponent } from "./piWebStatus.js";
|
||||||
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.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";
|
import { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js";
|
||||||
|
|
||||||
const { config } = effectivePiWebConfig();
|
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) });
|
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(process.env, config) });
|
||||||
await app.register(fastifyWebsocket);
|
await app.register(fastifyWebsocket);
|
||||||
|
|
||||||
@@ -33,46 +38,50 @@ await runSessionDaemonStartup({
|
|||||||
createRuntime() {
|
createRuntime() {
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const workspaceActivity = new WorkspaceActivityService(eventHub);
|
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)
|
const spawnTargets = spawnSessionsEnabled(process.env, config)
|
||||||
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
||||||
: undefined;
|
: undefined;
|
||||||
const sessions = new PiSessionService(eventHub, {
|
const sessions = new PiSessionService(eventHub, {
|
||||||
modelRegistry: auth.modelRegistry,
|
modelRegistry: auth.modelRegistry,
|
||||||
agentDir: agent.dir,
|
agentDir: activeAgentProfile.dir,
|
||||||
workspaceActivity,
|
workspaceActivity,
|
||||||
logger: app.log,
|
logger: app.log,
|
||||||
...(spawnTargets === undefined ? {} : { spawnTargets }),
|
...(spawnTargets === undefined ? {} : { spawnTargets }),
|
||||||
subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config),
|
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); });
|
auth.subscribe((change) => { sessions.applyAuthChange(change); });
|
||||||
const terminals = new TerminalService(eventHub, workspaceActivity);
|
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);
|
registerWorkspaceActivityRoutes(app, workspaceActivity);
|
||||||
registerAuthRoutes(app, auth);
|
registerAuthRoutes(app, auth);
|
||||||
registerSessionRoutes(app, sessions, eventHub);
|
registerSessionRoutes(app, sessions, eventHub);
|
||||||
registerTerminalRoutes(app, terminals);
|
registerTerminalRoutes(app, terminals);
|
||||||
|
|
||||||
app.get("/health", () => {
|
app.get("/health", () => ({
|
||||||
const runtime = getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES);
|
|
||||||
return {
|
|
||||||
ok: true,
|
ok: true,
|
||||||
activeSessions: sessions.activeCount(),
|
activeSessions: sessions.activeCount(),
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
version: {
|
version: {
|
||||||
component: runtime.component,
|
component: runtimeComponent.component,
|
||||||
label: runtime.label,
|
label: runtimeComponent.label,
|
||||||
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
|
...(runtimeComponent.runtimeVersion === undefined ? {} : { runtimeVersion: runtimeComponent.runtimeVersion }),
|
||||||
stale: false,
|
stale: false,
|
||||||
available: runtime.available,
|
available: runtimeComponent.available,
|
||||||
},
|
},
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/runtime", () => getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES));
|
app.get("/runtime", () => runtimeComponent);
|
||||||
},
|
},
|
||||||
async listen({ auth, sessions, terminals }) {
|
async listen({ auth, sessions, terminals }) {
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
|
import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js";
|
||||||
|
import { parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
|
||||||
import { sessiondHttpUrl, sessiondSocketPath } from "./config.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 {
|
export class SessionDaemonClient {
|
||||||
private readonly baseUrl = sessiondHttpUrl();
|
private readonly baseUrl = sessiondHttpUrl();
|
||||||
private readonly socketPath = sessiondSocketPath();
|
private readonly socketPath = sessiondSocketPath();
|
||||||
@@ -12,6 +19,35 @@ export class SessionDaemonClient {
|
|||||||
return this.requestSocket(method, path, payload);
|
return this.requestSocket(method, path, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getActiveAgentProfile(): Promise<SessionDaemonAgentProfileResult> {
|
||||||
|
let response: Awaited<ReturnType<SessionDaemonClient["request"]>>;
|
||||||
|
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 {
|
connectWebSocket(path: string): WebSocket {
|
||||||
if (this.baseUrl !== undefined && this.baseUrl !== "") {
|
if (this.baseUrl !== undefined && this.baseUrl !== "") {
|
||||||
const url = new URL(path, 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -593,12 +593,23 @@ export interface PiWebComponentStatus {
|
|||||||
error?: string;
|
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 {
|
export interface PiWebRuntimeComponent {
|
||||||
component: PiWebServiceComponent;
|
component: PiWebServiceComponent;
|
||||||
label: string;
|
label: string;
|
||||||
runtimeVersion?: string;
|
runtimeVersion?: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
capabilities: PiWebCapability[];
|
capabilities: PiWebCapability[];
|
||||||
|
/** Present only for a session daemon that supports active-profile reporting. */
|
||||||
|
activeAgentProfile?: ActiveAgentProfileDescriptor;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,56 @@ describe("PI WEB status parsing", () => {
|
|||||||
})).toBeUndefined();
|
})).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", () => {
|
it("parses Docker installation metadata", () => {
|
||||||
expect(parsePiWebInstallationInfo({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" })).toEqual({
|
expect(parsePiWebInstallationInfo({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" })).toEqual({
|
||||||
kind: "docker",
|
kind: "docker",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebVersionResponse } from "./apiTypes.js";
|
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebVersionResponse } from "./apiTypes.js";
|
||||||
|
import { parseActiveAgentProfileDescriptor } from "./activeAgentProfile.js";
|
||||||
import { parseKnownPiWebCapabilities } from "./capabilities.js";
|
import { parseKnownPiWebCapabilities } from "./capabilities.js";
|
||||||
|
|
||||||
export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined {
|
export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined {
|
||||||
@@ -33,15 +34,19 @@ export function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponen
|
|||||||
const runtimeVersion = value["runtimeVersion"];
|
const runtimeVersion = value["runtimeVersion"];
|
||||||
const available = value["available"];
|
const available = value["available"];
|
||||||
const capabilities = parseKnownPiWebCapabilities(value["capabilities"]);
|
const capabilities = parseKnownPiWebCapabilities(value["capabilities"]);
|
||||||
|
const activeAgentProfileValue = value["activeAgentProfile"];
|
||||||
|
const activeAgentProfile = activeAgentProfileValue === undefined ? undefined : parseActiveAgentProfileDescriptor(activeAgentProfileValue);
|
||||||
const error = value["error"];
|
const error = value["error"];
|
||||||
if (component !== "web" && component !== "sessiond") return undefined;
|
if (component !== "web" && component !== "sessiond") return undefined;
|
||||||
if (typeof label !== "string" || label === "" || typeof available !== "boolean" || capabilities === undefined) return undefined;
|
if (typeof label !== "string" || label === "" || typeof available !== "boolean" || capabilities === undefined) return undefined;
|
||||||
|
if (activeAgentProfileValue !== undefined && (component !== "sessiond" || activeAgentProfile === undefined)) return undefined;
|
||||||
return {
|
return {
|
||||||
component,
|
component,
|
||||||
label,
|
label,
|
||||||
...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}),
|
...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}),
|
||||||
available,
|
available,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
...(activeAgentProfile === undefined ? {} : { activeAgentProfile }),
|
||||||
...(typeof error === "string" ? { error } : {}),
|
...(typeof error === "string" ? { error } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user