Archived
feat: expose daemon-owned active agent profile
This commit is contained in:
@@ -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 { 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<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 {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user