From 71ad410c7819dce8f622a23e442e4cc24b7994c5 Mon Sep 17 00:00:00 2001 From: "Keith Kowal (orch-dev)" Date: Tue, 29 Sep 2026 23:02:30 -0700 Subject: [PATCH 1/3] feat(client): add authenticated per-agent config readback Expose a negotiated, read-only agent config fingerprint through the public client and session protocol. Bind every response to agent ID and config revision; omit raw launch environment, MCP config, and prompt data. Add protocol, client, and server coverage for old-daemon capability rejection, wrong agent identity, and secret-free canonical fingerprints. --- packages/client/src/daemon-client.test.ts | 61 ++++++++++++++ packages/client/src/daemon-client.ts | 30 +++++++ packages/client/src/index.ts | 3 + .../messages.agent-config-readback.test.ts | 53 ++++++++++++ packages/protocol/src/messages.ts | 46 ++++++++++ .../server/src/server/agent/agent-manager.ts | 16 +++- .../src/server/agent/agent-projections.ts | 1 + .../server/src/server/agent/agent-storage.ts | 2 + .../authorization/operation-permissions.ts | 2 + packages/server/src/server/session.ts | 8 ++ .../agent-config-readback.test.ts | 84 +++++++++++++++++++ .../agent-config/agent-config-readback.ts | 48 +++++++++++ .../agent-config/agent-config-session.test.ts | 72 ++++++++++++++++ .../agent-config/agent-config-session.ts | 24 +++++- .../server/src/server/websocket-server.ts | 1 + public-docs/sdk/reference.md | 13 +++ 16 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 packages/protocol/src/messages.agent-config-readback.test.ts create mode 100644 packages/server/src/server/session/agent-config/agent-config-readback.test.ts create mode 100644 packages/server/src/server/session/agent-config/agent-config-readback.ts diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index bf41e069612..5eca8eadb9d 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -345,6 +345,67 @@ test("owned timeline observation requires the host feature", async () => { expect(mock.sent).toEqual([]); }); +test("agent config readback fails closed on an old daemon before sending a request", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "config_readback_legacy", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen({ features: {} }); + await connecting; + await expect(client.getAgentConfig("agent-a")).rejects.toThrow("does not support authenticated per-agent config readback"); + expect(mock.sent).toEqual([]); +}); + +test("agent config readback verifies the requested agent identity", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "config_readback_identity", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen({ features: { agentConfigReadback: true } }); + await connecting; + const reading = client.getAgentConfig("agent-a"); + await Promise.resolve(); + const request = parseSentFrame(mock.sent.at(-1)); + expect(request.type).toBe("agent.config.read.request"); + mock.triggerMessage(wrapSessionMessage({ + type: "agent.config.read.response", + payload: { + requestId: request.requestId, + agentId: "agent-b", + accepted: true, + error: null, + readback: { + contract: "paseo.agent-config-readback.v1", + agentId: "agent-b", + revision: "2026-09-29T12:00:00.000Z", + provider: "codex", + cwd: "/workspace", + model: "gpt-test", + modeId: null, + thinkingOptionId: null, + featureValuesSha256: "a".repeat(64), + envSha256: "b".repeat(64), + providerOptionsSha256: "c".repeat(64), + toolPolicySha256: "d".repeat(64), + mcpServersSha256: "e".repeat(64), + systemPromptSha256: null, + configSha256: "0".repeat(64), + }, + }, + })); + await expect(reading).rejects.toThrow("different agent identity"); +}); + test("normalizes legacy and dedicated agent attention notifications", async () => { const mock = createMockTransport(); const client = new DaemonClient({ diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 042541f5cab..614b38ccb75 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -130,6 +130,7 @@ import type { AgentSessionConfig, } from "@getpaseo/protocol/agent-types"; import type { + AgentConfigReadback, AgentConfigApply, MutableDaemonConfig, MutableDaemonConfigPatch, @@ -3482,6 +3483,35 @@ export class DaemonClient { return payload.notice ?? null; } + /** Reads hashes of persisted per-agent config without returning env, MCP secrets, or prompt text. */ + async getAgentConfig(agentId: string): Promise { + if (this.lastServerInfoMessage?.features?.agentConfigReadback !== true) { + throw new DaemonConnectionError("The connected daemon does not support authenticated per-agent config readback"); + } + const requestId = this.createRequestId(); + const message = SessionInboundMessageSchema.parse({ + type: "agent.config.read.request", + agentId, + requestId, + }); + const payload = await this.sendRequest({ + requestId, + message, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "agent.config.read.response" || msg.payload.requestId !== requestId) return null; + return msg.payload; + }, + }); + if (!payload.accepted || !payload.readback) { + throw new Error(payload.error ?? "agent config readback was rejected"); + } + if (payload.agentId !== agentId || payload.readback.agentId !== agentId) { + throw new Error("agent config readback returned a different agent identity"); + } + return payload.readback; + } + async restartServer( reason?: string, requestId?: string, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index fb67a4228a7..9d0989894a2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -352,6 +352,8 @@ export interface PaseoAgentHandle { readonly timeline: PaseoAgentTimelineHandle; current(): PaseoAgent | null; refresh(requestId?: string): Promise; + /** Reads exact persisted config fingerprints; never returns secret env/MCP values or prompt text. */ + config(): Promise; send(text: string, options?: PaseoAgentSendOptions): Promise; respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise; /** Sends a prompt and resolves when that turn finishes or needs attention. */ @@ -926,6 +928,7 @@ function createAgentHandleFactory( current = result?.agent ?? null; return result; }, + config: () => daemonClient.getAgentConfig(id), send: async (text, options) => { await daemonClient.sendAgentMessage(id, text, options); }, diff --git a/packages/protocol/src/messages.agent-config-readback.test.ts b/packages/protocol/src/messages.agent-config-readback.test.ts new file mode 100644 index 00000000000..6bb66a79605 --- /dev/null +++ b/packages/protocol/src/messages.agent-config-readback.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest"; +import { + AgentConfigReadRequestMessageSchema, + AgentConfigReadResponseMessageSchema, + ServerInfoStatusPayloadSchema, +} from "./messages.js"; + +const readback = { + contract: "paseo.agent-config-readback.v1", + agentId: "agent-a", + revision: "2026-09-29T12:00:00.000Z", + provider: "codex", + cwd: "/workspace", + model: "gpt-test", + modeId: "default", + thinkingOptionId: null, + featureValuesSha256: "a".repeat(64), + envSha256: "b".repeat(64), + providerOptionsSha256: "c".repeat(64), + toolPolicySha256: "d".repeat(64), + mcpServersSha256: "e".repeat(64), + systemPromptSha256: null, + configSha256: "f".repeat(64), +}; + +describe("authenticated per-agent config readback wire contract", () => { + test("requires exact agent/request identity and fingerprints, without raw config fields", () => { + expect(AgentConfigReadRequestMessageSchema.parse({ + type: "agent.config.read.request", agentId: "agent-a", requestId: "read-1", + })).toBeTruthy(); + expect(AgentConfigReadResponseMessageSchema.parse({ + type: "agent.config.read.response", + payload: { requestId: "read-1", agentId: "agent-a", accepted: true, error: null, readback }, + })).toBeTruthy(); + expect(AgentConfigReadResponseMessageSchema.safeParse({ + type: "agent.config.read.response", + payload: { requestId: "read-1", agentId: "agent-a", accepted: true, error: null, + readback: { ...readback, mcpServersSha256: "not-a-digest" } }, + }).success).toBe(false); + expect(readback).not.toHaveProperty("env"); + expect(readback).not.toHaveProperty("mcpServers"); + expect(readback).not.toHaveProperty("systemPrompt"); + expect(JSON.stringify(readback)).not.toContain("secret-value"); + expect(JSON.stringify(readback)).not.toContain("prompt contents"); + }); + + test("new daemon advertises capability while old server-info remains valid", () => { + const base = { status: "server_info", serverId: "server-a" }; + expect(ServerInfoStatusPayloadSchema.parse({ ...base, features: { agentConfigReadback: true } }) + .features?.agentConfigReadback).toBe(true); + expect(ServerInfoStatusPayloadSchema.parse(base).features?.agentConfigReadback).toBeUndefined(); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index b8d0e56cc2e..0e7edb64391 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1938,6 +1938,47 @@ export const AgentConfigApplyResponseMessageSchema = z.object({ payload: AgentActionResponsePayloadSchema, }); +const AgentConfigDigestSchema = z.string().regex(/^[0-9a-f]{64}$/); +export const AgentConfigReadbackSchema = z.object({ + contract: z.literal("paseo.agent-config-readback.v1"), + agentId: z.string().min(1), + /** AgentManager updatedAt, used as an optimistic readback revision. */ + revision: z.string().datetime(), + provider: z.string().min(1), + cwd: z.string(), + model: z.string().nullable(), + modeId: z.string().nullable(), + thinkingOptionId: z.string().nullable(), + featureValuesSha256: AgentConfigDigestSchema, + /** Hash of configured launch environment; raw values can contain credentials. */ + /** Null means the launch environment was not observed (legacy/imported agent); consumers must fail closed. */ + envSha256: AgentConfigDigestSchema.nullable(), + providerOptionsSha256: AgentConfigDigestSchema, + toolPolicySha256: AgentConfigDigestSchema, + mcpServersSha256: AgentConfigDigestSchema, + /** Null means no prompt was configured; prompt contents are never returned. */ + systemPromptSha256: AgentConfigDigestSchema.nullable(), + configSha256: AgentConfigDigestSchema, +}); +export type AgentConfigReadback = z.infer; + +export const AgentConfigReadRequestMessageSchema = z.object({ + type: z.literal("agent.config.read.request"), + agentId: z.string().min(1), + requestId: z.string().min(1), +}); + +export const AgentConfigReadResponseMessageSchema = z.object({ + type: z.literal("agent.config.read.response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + accepted: z.boolean(), + error: z.string().nullable(), + readback: AgentConfigReadbackSchema.nullable(), + }), +}); + export const AgentDetachRequestMessageSchema = z.object({ type: z.literal("agent.detach.request"), agentId: z.string(), @@ -3206,6 +3247,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetAgentThinkingRequestMessageSchema, SetAgentFeatureRequestMessageSchema, AgentConfigApplyRequestMessageSchema, + AgentConfigReadRequestMessageSchema, AgentDetachRequestMessageSchema, AgentRewindRequestMessageSchema, AgentPermissionResponseMessageSchema, @@ -3637,6 +3679,7 @@ export const ServerInfoStatusPayloadSchema = z agentProfiles: z.boolean().optional(), // COMPAT(agentConfigApply): added in v0.3.2, remove gate after 2027-02-11. agentConfigApply: z.boolean().optional(), + agentConfigReadback: z.boolean().optional(), }) .optional(), }) @@ -6655,6 +6698,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ SetAgentThinkingResponseMessageSchema, SetAgentFeatureResponseMessageSchema, AgentConfigApplyResponseMessageSchema, + AgentConfigReadResponseMessageSchema, AgentDetachResponseMessageSchema, AgentRewindResponseMessageSchema, UpdateAgentResponseMessageSchema, @@ -6854,6 +6898,8 @@ export type SetAgentModelResponseMessage = z.infer; export type SetAgentFeatureResponseMessage = z.infer; export type AgentConfigApplyResponseMessage = z.infer; +export type AgentConfigReadRequestMessage = z.infer; +export type AgentConfigReadResponseMessage = z.infer; export type AgentDetachResponseMessage = z.infer; export type AgentRewindResponseMessage = z.infer; export type UpdateAgentResponseMessage = z.infer; diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 70b4e62dbcd..43a93cba561 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,7 +1,7 @@ import type { PluginLifecycle } from "../plugins/lifecycle/index.js"; import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js"; import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { basename, resolve } from "node:path"; import { stat } from "node:fs/promises"; import { @@ -185,6 +185,11 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig { return stripInternalPaseoMcpServer(config); } +function hashLaunchEnvironment(env: Record | undefined): string { + const canonical = JSON.stringify(Object.fromEntries(Object.entries(env ?? {}).sort(([a], [b]) => a.localeCompare(b)))); + return createHash("sha256").update(canonical, "utf8").digest("hex"); +} + export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus }; export type { AgentTimelineCursor, @@ -382,6 +387,8 @@ interface ManagedAgentBase { owner?: AgentOwner; capabilities: AgentCapabilityFlags; config: AgentSessionConfig; + /** Fingerprint of the final launch-context environment; raw values are never persisted. */ + launchEnvironmentSha256?: string; runtimeInfo?: AgentRuntimeInfo; createdAt: Date; updatedAt: Date; @@ -1249,6 +1256,7 @@ export class AgentManager { workspaceId: options.workspaceId, owner: options.owner, historyPrimed: true, + launchEnvironmentSha256: hashLaunchEnvironment(launchContext.env), }); if (!agent.internal) { this.pluginLifecycle?.emit("agent.created", { @@ -1348,6 +1356,7 @@ export class AgentManager { return this.registerSession(session, storedConfig, resolvedAgentId, { ...options, persistence: handle, + launchEnvironmentSha256: hashLaunchEnvironment(launchContext.env), }); } @@ -1537,6 +1546,7 @@ export class AgentManager { lastUsage: preservedLastUsage, lastError: preservedLastError, attention: preservedAttention, + launchEnvironmentSha256: hashLaunchEnvironment(launchContext.env), }); } catch (error) { if (closedExisting) { @@ -1836,6 +1846,7 @@ export class AgentManager { session: null, capabilities: STORED_AGENT_CAPABILITIES, config: buildStoredAgentConfig(record), + launchEnvironmentSha256: record.launchEnvironmentSha256, runtimeInfo: undefined, lifecycle: "closed", createdAt: new Date(record.createdAt), @@ -3382,6 +3393,7 @@ export class AgentManager { publishWhenReady?: boolean; workspaceId?: string; owner?: AgentOwner; + launchEnvironmentSha256?: string; }, ): Promise { let registered = false; @@ -3533,6 +3545,7 @@ export class AgentManager { persistence?: AgentPersistenceHandle; workspaceId?: string; owner?: AgentOwner; + launchEnvironmentSha256?: string; } | undefined; }): ActiveManagedAgent { @@ -3546,6 +3559,7 @@ export class AgentManager { session, capabilities: session.capabilities, config, + launchEnvironmentSha256: options?.launchEnvironmentSha256, runtimeInfo: undefined, lifecycle: "initializing", createdAt: options?.createdAt ?? now, diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index bd4042796aa..f197c16b98a 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -83,6 +83,7 @@ export function toStoredAgentRecord( lastStatus: agent.lifecycle, lastModeId: agent.currentModeId ?? config?.modeId ?? null, config: config ?? null, + ...(agent.launchEnvironmentSha256 ? { launchEnvironmentSha256: agent.launchEnvironmentSha256 } : {}), runtimeInfo, features: normalizeFeatures(agent.features), persistence, diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index b41bc75cfc0..8a7bc80cffe 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -56,6 +56,8 @@ const STORED_AGENT_SCHEMA = z.object({ lastStatus: AgentStatusSchema.default("closed"), lastModeId: z.string().nullable().optional(), config: SERIALIZABLE_CONFIG_SCHEMA, + /** Hash only: the launch environment can contain credentials and is never stored verbatim. */ + launchEnvironmentSha256: z.string().regex(/^[0-9a-f]{64}$/).optional(), runtimeInfo: z .object({ provider: z.string(), diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 6a6a1e78ece..35426bb0069 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -8,6 +8,7 @@ export type PermissionRequirement = DaemonPermission | readonly DaemonPermission const INBOUND_PERMISSION = { abort_request: "workspace.write", "agent.config.apply.request": ["workspace.write", "hub.execute"], + "agent.config.read.request": ["workspace.read", "hub.execute"], "agent.detach.request": "workspace.write", "agent.fork_context.request": "workspace.read", "agent.provider_subagents.list.request": "workspace.read", @@ -211,6 +212,7 @@ const INBOUND_PERMISSION = { const OUTBOUND_PERMISSION = { activity_log: "workspace.read", "agent.config.apply.response": ["workspace.write", "hub.execute"], + "agent.config.read.response": ["workspace.read", "hub.execute"], "agent.detach.response": "workspace.write", "agent.fork_context.response": "workspace.read", "agent.provider_subagents.list.response": "workspace.read", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 20f4143edfd..c0b25a2fda0 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -181,6 +181,7 @@ import { ScheduleSession } from "./session/schedule/schedule-session.js"; import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js"; import { WorkspaceFilesSession } from "./session/files/workspace-files-session.js"; import { AgentConfigSession } from "./session/agent-config/agent-config-session.js"; +import { createAgentConfigReadback } from "./session/agent-config/agent-config-readback.js"; import { ProjectConfigSession } from "./session/project-config/project-config-session.js"; import { DaemonSession, type DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; import type { DaemonWebSocketRuntimeDiagnosticSnapshot } from "./session/daemon/diagnostics.js"; @@ -985,6 +986,11 @@ export class Session { emit: (msg) => this.emit(msg), }, operations: { + readConfig: (agentId) => { + const agent = agentManager.getAgent(agentId); + if (!agent) throw new Error("agent is not present in the current daemon snapshot"); + return createAgentConfigReadback(agent); + }, ensureLoaded: async (agentId) => { await ensureUnarchivedAgentLoaded(agentId, { agentManager, @@ -2692,6 +2698,8 @@ export class Session { return this.agentConfigSession.handleSetAgentThinkingRequest(msg); case "agent.config.apply.request": return this.agentConfigSession.handleAgentConfigApplyRequest(msg); + case "agent.config.read.request": + return this.agentConfigSession.handleAgentConfigReadRequest(msg); case "get_daemon_config_request": this.emit({ type: "get_daemon_config_response", diff --git a/packages/server/src/server/session/agent-config/agent-config-readback.test.ts b/packages/server/src/server/session/agent-config/agent-config-readback.test.ts new file mode 100644 index 00000000000..316079408ec --- /dev/null +++ b/packages/server/src/server/session/agent-config/agent-config-readback.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "vitest"; +import { createHash } from "node:crypto"; +import { AgentConfigReadbackSchema } from "@getpaseo/protocol/messages"; +import type { ManagedAgent } from "../../agent/agent-manager.js"; +import { createAgentConfigReadback } from "./agent-config-readback.js"; + +function managedAgent(overrides: Partial = {}): ManagedAgent { + return { + id: "agent-a", + provider: "codex", + cwd: "/work/project", + capabilities: {} as ManagedAgent["capabilities"], + config: { + provider: "codex", + cwd: "/work/project", + model: "gpt-test", + env: { TOKEN: "secret-value" }, + mcpServers: { obsidian: { command: "obsidian-mcp", args: [] } }, + toolPolicy: { mode: "auto", preapproved: [] }, + systemPrompt: "private role prompt", + }, + launchEnvironmentSha256: createHash("sha256").update(JSON.stringify({ TOKEN: "secret-value" })).digest("hex"), + createdAt: new Date("2026-09-29T11:00:00.000Z"), + updatedAt: new Date("2026-09-29T12:00:00.000Z"), + availableModes: [], + currentModeId: "default", + pendingPermissions: new Map(), + bufferedPermissionResolutions: new Map(), + inFlightPermissionResponses: new Set(), + pendingReplacement: false, + persistence: null, + historyPrimed: true, + lastUserMessageAt: null, + activeTurnId: null, + activeTurnStartedAt: null, + attention: {} as ManagedAgent["attention"], + foregroundTurnWaiters: new Set(), + finalizedForegroundTurnIds: new Set(), + unsubscribeSession: null, + labels: { ORCH_AGENT: "aries-eval", ORCH_PRINCIPAL: "fleet:aries-eval" }, + lifecycle: "closed", + session: null, + activeForegroundTurnId: null, + ...overrides, + } as ManagedAgent; +} + +describe("agent config readback", () => { + test("binds exact agent/revision and all configured profile fields while returning no raw values", () => { + const result = createAgentConfigReadback(managedAgent()); + expect(AgentConfigReadbackSchema.parse(result)).toEqual(result); + expect(result.agentId).toBe("agent-a"); + expect(result.revision).toBe("2026-09-29T12:00:00.000Z"); + expect(result.mcpServersSha256).toMatch(/^[0-9a-f]{64}$/); + expect(result.toolPolicySha256).toMatch(/^[0-9a-f]{64}$/); + expect(result.envSha256).toMatch(/^[0-9a-f]{64}$/); + expect(result.systemPromptSha256).toMatch(/^[0-9a-f]{64}$/); + expect(JSON.stringify(result)).not.toContain("secret-value"); + expect(JSON.stringify(result)).not.toContain("private role prompt"); + }); + + test("canonical digest is stable for key order and changes for revision or config changes", () => { + const original = managedAgent(); + const reordered = managedAgent({ + config: { + ...original.config, + mcpServers: { obsidian: { args: [], command: "obsidian-mcp" } }, + env: { TOKEN: "secret-value" }, + }, + }); + expect(createAgentConfigReadback(reordered).configSha256).toBe(createAgentConfigReadback(original).configSha256); + expect(createAgentConfigReadback(managedAgent({ updatedAt: new Date("2026-09-29T12:00:01.000Z") })).configSha256) + .not.toBe(createAgentConfigReadback(original).configSha256); + expect(createAgentConfigReadback(managedAgent({ + config: { ...original.config, mcpServers: { other: { command: "other", args: [] } } }, + })).mcpServersSha256).not.toBe(createAgentConfigReadback(original).mcpServersSha256); + }); + + test("legacy/imported records report unknown launch environment instead of fabricating a hash", () => { + const result = createAgentConfigReadback(managedAgent({ launchEnvironmentSha256: undefined })); + expect(result.envSha256).toBeNull(); + expect(AgentConfigReadbackSchema.parse(result).envSha256).toBeNull(); + }); +}); diff --git a/packages/server/src/server/session/agent-config/agent-config-readback.ts b/packages/server/src/server/session/agent-config/agent-config-readback.ts new file mode 100644 index 00000000000..37b77a4e5d9 --- /dev/null +++ b/packages/server/src/server/session/agent-config/agent-config-readback.ts @@ -0,0 +1,48 @@ +import { createHash } from "node:crypto"; +import type { AgentConfigReadback } from "@getpaseo/protocol/messages"; +import type { ManagedAgent } from "../../agent/agent-manager.js"; + +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null"; + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; +} + +function sha256(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function valueDigest(value: unknown): string { + return sha256(canonicalJson(value ?? null)); +} + +/** + * Read-only and privacy preserving: no launch env, MCP config, provider options, + * or system-prompt bytes leave the daemon. Fingerprints use sorted-key canonical + * JSON so a caller can compare a frozen launch projection without receiving it. + */ +export function createAgentConfigReadback(agent: ManagedAgent): AgentConfigReadback { + const revision = agent.updatedAt.toISOString(); + const config = agent.config; + const fields = { + featureValuesSha256: valueDigest(config.featureValues), + envSha256: agent.launchEnvironmentSha256 ?? null, + providerOptionsSha256: valueDigest(config.providerOptions), + toolPolicySha256: valueDigest(config.toolPolicy), + mcpServersSha256: valueDigest(config.mcpServers), + systemPromptSha256: config.systemPrompt === undefined ? null : sha256(canonicalJson(config.systemPrompt)), + }; + const identity = { + contract: "paseo.agent-config-readback.v1" as const, + agentId: agent.id, + revision, + provider: agent.provider, + cwd: agent.cwd, + model: config.model ?? null, + modeId: config.modeId ?? agent.currentModeId ?? null, + thinkingOptionId: config.thinkingOptionId ?? null, + ...fields, + }; + return { ...identity, configSha256: sha256(canonicalJson(identity)) }; +} diff --git a/packages/server/src/server/session/agent-config/agent-config-session.test.ts b/packages/server/src/server/session/agent-config/agent-config-session.test.ts index 1592c5bb3d3..b2d9749c3c0 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.test.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.test.ts @@ -7,6 +7,7 @@ import { } from "./agent-config-session.js"; import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js"; import type { SessionOutboundMessage } from "../../messages.js"; +import type { AgentConfigReadback } from "@getpaseo/protocol/messages"; class FakeAgentConfigOperations implements AgentConfigOperations { readonly loadedAgentIds: string[] = []; @@ -20,6 +21,29 @@ class FakeAgentConfigOperations implements AgentConfigOperations { thinkingNotice: AgentProviderNotice | null = null; loadFailure: Error | null = null; failWith: Error | null = null; + readback: AgentConfigReadback = { + contract: "paseo.agent-config-readback.v1", + agentId: "agent-1", + revision: "2026-09-29T12:00:00.000Z", + provider: "codex", + cwd: "/workspace", + model: "gpt-test", + modeId: null, + thinkingOptionId: null, + featureValuesSha256: "a".repeat(64), + envSha256: "b".repeat(64), + providerOptionsSha256: "c".repeat(64), + toolPolicySha256: "d".repeat(64), + mcpServersSha256: "e".repeat(64), + systemPromptSha256: "f".repeat(64), + configSha256: "0".repeat(64), + }; + + readConfig(agentId: string): AgentConfigReadback { + this.callLog.push(`read:${agentId}`); + if (this.failWith) throw this.failWith; + return this.readback; + } async ensureLoaded(agentId: string): Promise { this.loadedAgentIds.push(agentId); @@ -69,6 +93,54 @@ function makeSubsystem() { } describe("AgentConfigSession", () => { + test("config read returns exact identity and fingerprints without config values", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + operations.readback.agentId = "agent-1"; + await subsystem.handleAgentConfigReadRequest({ + type: "agent.config.read.request", + agentId: "agent-1", + requestId: "read-1", + }); + expect(operations.callLog).toEqual(["read:agent-1"]); + expect(emitted).toEqual([{ + type: "agent.config.read.response", + payload: { requestId: "read-1", agentId: "agent-1", accepted: true, error: null, readback: operations.readback }, + }]); + expect(JSON.stringify(emitted)).not.toContain("secret"); + }); + + test("config read rejects a mismatched agent and never returns provider diagnostics", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + operations.readback.agentId = "different-agent"; + await subsystem.handleAgentConfigReadRequest({ + type: "agent.config.read.request", + agentId: "agent-1", + requestId: "read-2", + }); + expect(emitted).toEqual([{ + type: "agent.config.read.response", + payload: { + requestId: "read-2", agentId: "agent-1", accepted: false, + error: "Agent config readback is unavailable for this exact agent", readback: null, + }, + }]); + expect(JSON.stringify(emitted)).not.toContain("secret-provider-details"); + }); + + test("config read hides read failures rather than returning provider details", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + operations.failWith = new Error("secret-provider-details"); + await subsystem.handleAgentConfigReadRequest({ + type: "agent.config.read.request", + agentId: "agent-1", + requestId: "read-3", + }); + expect(emitted[0]).toMatchObject({ + type: "agent.config.read.response", + payload: { accepted: false, readback: null, error: "Agent config readback is unavailable for this exact agent" }, + }); + expect(JSON.stringify(emitted)).not.toContain("secret-provider-details"); + }); test("set mode: forwards the args and emits an accepted response carrying the notice", async () => { const { subsystem, emitted, operations } = makeSubsystem(); operations.modeNotice = { type: "info", message: "Switched to plan mode" }; diff --git a/packages/server/src/server/session/agent-config/agent-config-session.ts b/packages/server/src/server/session/agent-config/agent-config-session.ts index 8cf23941921..878b71709b5 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.ts @@ -1,7 +1,7 @@ import type pino from "pino"; import { v4 as uuidv4 } from "uuid"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; -import type { AgentConfigApply } from "@getpaseo/protocol/messages"; +import type { AgentConfigApply, AgentConfigReadback } from "@getpaseo/protocol/messages"; import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; @@ -25,6 +25,7 @@ export interface AgentConfigSessionHost { * thinking yield a provider notice; model and feature do not. */ export interface AgentConfigOperations { + readConfig(agentId: string): Promise | AgentConfigReadback; ensureLoaded(agentId: string): Promise; setMode(agentId: string, modeId: string): Promise; setModel(agentId: string, modelId: string | null): Promise; @@ -150,6 +151,27 @@ export class AgentConfigSession { }); } + async handleAgentConfigReadRequest( + msg: Extract, + ): Promise { + const { agentId, requestId } = msg; + this.logger.info({ agentId, requestId }, "session: agent.config.read.request"); + try { + const readback = await this.operations.readConfig(agentId); + if (readback.agentId !== agentId) throw new Error("agent identity changed during config read"); + this.host.emit({ type: "agent.config.read.response", payload: { + requestId, agentId, accepted: true, error: null, readback, + } }); + } catch { + // Do not log or return configuration values or provider diagnostics. + this.logger.warn({ agentId, requestId }, "session: agent.config.read.request unavailable"); + this.host.emit({ type: "agent.config.read.response", payload: { + requestId, agentId, accepted: false, + error: "Agent config readback is unavailable for this exact agent", readback: null, + } }); + } + } + /** * Model first, because a thinking option only means anything against the model * that offers it; features last so they settle on top of the rest. An omitted diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 8b0ab3879fe..76212862f91 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1789,6 +1789,7 @@ export class VoiceAssistantWebSocketServer { agentProfiles: true, // COMPAT(agentConfigApply): added in v0.3.2, remove gate after 2027-02-11. agentConfigApply: true, + agentConfigReadback: true, }, }; } diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 960404c58a0..3cae63d9259 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -72,6 +72,18 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o | `mcpServers` | MCP server map | Session-scoped MCP servers. | | `toolPolicy` | MCP tool policy | Exact preapproval rules for MCP tools. | +### Authenticated daemon config readback + +`agent.config()` returns a versioned readback for that exact agent ID. The daemon reports its +managed config revision, provider, cwd, model, mode, thinking option, and SHA-256 fingerprints +for configured environment, feature values, provider options, MCP servers, tool policy, and +system prompt. Raw environment values, MCP credentials, and prompt text are not returned or +logged. The fingerprint is over the daemon's managed/resolved launch configuration, not proof +that the provider session actually applied or exposed those settings; verify runtime behavior +separately. The method fails closed unless the connected daemon advertises +`features.agentConfigReadback` and returns the exact requested agent identity. Older daemons do +not receive the request. + ### Agent handle | Member | Result | Behavior | @@ -91,6 +103,7 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o | `archivedAt` | `string \| null` | Archive timestamp; `null` while the agent is active. | | `current()` | `PaseoAgent \| null` | Current detailed value observed by this handle; never fetches. | | `refresh(requestId?)` | `PaseoAgentRefetchResult \| null` | Fetches the current agent and project placement. | +| `config()` | `Promise` | Reads versioned managed-config fingerprints; requires daemon readback capability. | | `send(text, options?)` | `Promise` | Resolves when the daemon accepts the prompt. | | `respondToPermission(options)` | `Promise` | Answers a pending permission by `requestId` with an allow or deny `response`. | | `run(text, options?)` | `PaseoAgentRunResult` | Sends a prompt and waits for that turn. `timeoutMs` controls the wait; it defaults to 10 minutes. | From 897aae697aac59b45d3e1e05c44f4b654d9a9670 Mon Sep 17 00:00:00 2001 From: "Keith Kowal (orch-dev)" Date: Tue, 29 Sep 2026 23:12:09 -0700 Subject: [PATCH 2/3] style: format agent config readback changes Apply the repository formatter to the public readback source, tests, and SDK documentation so the draft passes the pinned format gate. --- packages/client/src/daemon-client.test.ts | 54 ++++++++++--------- packages/client/src/daemon-client.ts | 7 ++- .../messages.agent-config-readback.test.ts | 43 ++++++++++----- .../server/src/server/agent/agent-manager.ts | 4 +- .../src/server/agent/agent-projections.ts | 4 +- .../server/src/server/agent/agent-storage.ts | 5 +- .../agent-config-readback.test.ts | 24 ++++++--- .../agent-config/agent-config-readback.ts | 8 ++- .../agent-config/agent-config-session.test.ts | 39 ++++++++++---- .../agent-config/agent-config-session.ts | 30 ++++++++--- public-docs/sdk/reference.md | 2 +- 11 files changed, 147 insertions(+), 73 deletions(-) diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 5eca8eadb9d..cbf1ad34002 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -357,7 +357,9 @@ test("agent config readback fails closed on an old daemon before sending a reque const connecting = client.connect(); mock.triggerOpen({ features: {} }); await connecting; - await expect(client.getAgentConfig("agent-a")).rejects.toThrow("does not support authenticated per-agent config readback"); + await expect(client.getAgentConfig("agent-a")).rejects.toThrow( + "does not support authenticated per-agent config readback", + ); expect(mock.sent).toEqual([]); }); @@ -377,32 +379,34 @@ test("agent config readback verifies the requested agent identity", async () => await Promise.resolve(); const request = parseSentFrame(mock.sent.at(-1)); expect(request.type).toBe("agent.config.read.request"); - mock.triggerMessage(wrapSessionMessage({ - type: "agent.config.read.response", - payload: { - requestId: request.requestId, - agentId: "agent-b", - accepted: true, - error: null, - readback: { - contract: "paseo.agent-config-readback.v1", + mock.triggerMessage( + wrapSessionMessage({ + type: "agent.config.read.response", + payload: { + requestId: request.requestId, agentId: "agent-b", - revision: "2026-09-29T12:00:00.000Z", - provider: "codex", - cwd: "/workspace", - model: "gpt-test", - modeId: null, - thinkingOptionId: null, - featureValuesSha256: "a".repeat(64), - envSha256: "b".repeat(64), - providerOptionsSha256: "c".repeat(64), - toolPolicySha256: "d".repeat(64), - mcpServersSha256: "e".repeat(64), - systemPromptSha256: null, - configSha256: "0".repeat(64), + accepted: true, + error: null, + readback: { + contract: "paseo.agent-config-readback.v1", + agentId: "agent-b", + revision: "2026-09-29T12:00:00.000Z", + provider: "codex", + cwd: "/workspace", + model: "gpt-test", + modeId: null, + thinkingOptionId: null, + featureValuesSha256: "a".repeat(64), + envSha256: "b".repeat(64), + providerOptionsSha256: "c".repeat(64), + toolPolicySha256: "d".repeat(64), + mcpServersSha256: "e".repeat(64), + systemPromptSha256: null, + configSha256: "0".repeat(64), + }, }, - }, - })); + }), + ); await expect(reading).rejects.toThrow("different agent identity"); }); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 614b38ccb75..db93de1a19c 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -3486,7 +3486,9 @@ export class DaemonClient { /** Reads hashes of persisted per-agent config without returning env, MCP secrets, or prompt text. */ async getAgentConfig(agentId: string): Promise { if (this.lastServerInfoMessage?.features?.agentConfigReadback !== true) { - throw new DaemonConnectionError("The connected daemon does not support authenticated per-agent config readback"); + throw new DaemonConnectionError( + "The connected daemon does not support authenticated per-agent config readback", + ); } const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ @@ -3499,7 +3501,8 @@ export class DaemonClient { message, options: { skipQueue: true }, select: (msg) => { - if (msg.type !== "agent.config.read.response" || msg.payload.requestId !== requestId) return null; + if (msg.type !== "agent.config.read.response" || msg.payload.requestId !== requestId) + return null; return msg.payload; }, }); diff --git a/packages/protocol/src/messages.agent-config-readback.test.ts b/packages/protocol/src/messages.agent-config-readback.test.ts index 6bb66a79605..5b98b374de3 100644 --- a/packages/protocol/src/messages.agent-config-readback.test.ts +++ b/packages/protocol/src/messages.agent-config-readback.test.ts @@ -25,18 +25,31 @@ const readback = { describe("authenticated per-agent config readback wire contract", () => { test("requires exact agent/request identity and fingerprints, without raw config fields", () => { - expect(AgentConfigReadRequestMessageSchema.parse({ - type: "agent.config.read.request", agentId: "agent-a", requestId: "read-1", - })).toBeTruthy(); - expect(AgentConfigReadResponseMessageSchema.parse({ - type: "agent.config.read.response", - payload: { requestId: "read-1", agentId: "agent-a", accepted: true, error: null, readback }, - })).toBeTruthy(); - expect(AgentConfigReadResponseMessageSchema.safeParse({ - type: "agent.config.read.response", - payload: { requestId: "read-1", agentId: "agent-a", accepted: true, error: null, - readback: { ...readback, mcpServersSha256: "not-a-digest" } }, - }).success).toBe(false); + expect( + AgentConfigReadRequestMessageSchema.parse({ + type: "agent.config.read.request", + agentId: "agent-a", + requestId: "read-1", + }), + ).toBeTruthy(); + expect( + AgentConfigReadResponseMessageSchema.parse({ + type: "agent.config.read.response", + payload: { requestId: "read-1", agentId: "agent-a", accepted: true, error: null, readback }, + }), + ).toBeTruthy(); + expect( + AgentConfigReadResponseMessageSchema.safeParse({ + type: "agent.config.read.response", + payload: { + requestId: "read-1", + agentId: "agent-a", + accepted: true, + error: null, + readback: { ...readback, mcpServersSha256: "not-a-digest" }, + }, + }).success, + ).toBe(false); expect(readback).not.toHaveProperty("env"); expect(readback).not.toHaveProperty("mcpServers"); expect(readback).not.toHaveProperty("systemPrompt"); @@ -46,8 +59,10 @@ describe("authenticated per-agent config readback wire contract", () => { test("new daemon advertises capability while old server-info remains valid", () => { const base = { status: "server_info", serverId: "server-a" }; - expect(ServerInfoStatusPayloadSchema.parse({ ...base, features: { agentConfigReadback: true } }) - .features?.agentConfigReadback).toBe(true); + expect( + ServerInfoStatusPayloadSchema.parse({ ...base, features: { agentConfigReadback: true } }) + .features?.agentConfigReadback, + ).toBe(true); expect(ServerInfoStatusPayloadSchema.parse(base).features?.agentConfigReadback).toBeUndefined(); }); }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 43a93cba561..7acb71af88e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -186,7 +186,9 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig { } function hashLaunchEnvironment(env: Record | undefined): string { - const canonical = JSON.stringify(Object.fromEntries(Object.entries(env ?? {}).sort(([a], [b]) => a.localeCompare(b)))); + const canonical = JSON.stringify( + Object.fromEntries(Object.entries(env ?? {}).sort(([a], [b]) => a.localeCompare(b))), + ); return createHash("sha256").update(canonical, "utf8").digest("hex"); } diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index f197c16b98a..1162094bb07 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -83,7 +83,9 @@ export function toStoredAgentRecord( lastStatus: agent.lifecycle, lastModeId: agent.currentModeId ?? config?.modeId ?? null, config: config ?? null, - ...(agent.launchEnvironmentSha256 ? { launchEnvironmentSha256: agent.launchEnvironmentSha256 } : {}), + ...(agent.launchEnvironmentSha256 + ? { launchEnvironmentSha256: agent.launchEnvironmentSha256 } + : {}), runtimeInfo, features: normalizeFeatures(agent.features), persistence, diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 8a7bc80cffe..6cec153ef4a 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -57,7 +57,10 @@ const STORED_AGENT_SCHEMA = z.object({ lastModeId: z.string().nullable().optional(), config: SERIALIZABLE_CONFIG_SCHEMA, /** Hash only: the launch environment can contain credentials and is never stored verbatim. */ - launchEnvironmentSha256: z.string().regex(/^[0-9a-f]{64}$/).optional(), + launchEnvironmentSha256: z + .string() + .regex(/^[0-9a-f]{64}$/) + .optional(), runtimeInfo: z .object({ provider: z.string(), diff --git a/packages/server/src/server/session/agent-config/agent-config-readback.test.ts b/packages/server/src/server/session/agent-config/agent-config-readback.test.ts index 316079408ec..b0261537aac 100644 --- a/packages/server/src/server/session/agent-config/agent-config-readback.test.ts +++ b/packages/server/src/server/session/agent-config/agent-config-readback.test.ts @@ -19,7 +19,9 @@ function managedAgent(overrides: Partial = {}): ManagedAgent { toolPolicy: { mode: "auto", preapproved: [] }, systemPrompt: "private role prompt", }, - launchEnvironmentSha256: createHash("sha256").update(JSON.stringify({ TOKEN: "secret-value" })).digest("hex"), + launchEnvironmentSha256: createHash("sha256") + .update(JSON.stringify({ TOKEN: "secret-value" })) + .digest("hex"), createdAt: new Date("2026-09-29T11:00:00.000Z"), updatedAt: new Date("2026-09-29T12:00:00.000Z"), availableModes: [], @@ -68,12 +70,20 @@ describe("agent config readback", () => { env: { TOKEN: "secret-value" }, }, }); - expect(createAgentConfigReadback(reordered).configSha256).toBe(createAgentConfigReadback(original).configSha256); - expect(createAgentConfigReadback(managedAgent({ updatedAt: new Date("2026-09-29T12:00:01.000Z") })).configSha256) - .not.toBe(createAgentConfigReadback(original).configSha256); - expect(createAgentConfigReadback(managedAgent({ - config: { ...original.config, mcpServers: { other: { command: "other", args: [] } } }, - })).mcpServersSha256).not.toBe(createAgentConfigReadback(original).mcpServersSha256); + expect(createAgentConfigReadback(reordered).configSha256).toBe( + createAgentConfigReadback(original).configSha256, + ); + expect( + createAgentConfigReadback(managedAgent({ updatedAt: new Date("2026-09-29T12:00:01.000Z") })) + .configSha256, + ).not.toBe(createAgentConfigReadback(original).configSha256); + expect( + createAgentConfigReadback( + managedAgent({ + config: { ...original.config, mcpServers: { other: { command: "other", args: [] } } }, + }), + ).mcpServersSha256, + ).not.toBe(createAgentConfigReadback(original).mcpServersSha256); }); test("legacy/imported records report unknown launch environment instead of fabricating a hash", () => { diff --git a/packages/server/src/server/session/agent-config/agent-config-readback.ts b/packages/server/src/server/session/agent-config/agent-config-readback.ts index 37b77a4e5d9..81284d25b78 100644 --- a/packages/server/src/server/session/agent-config/agent-config-readback.ts +++ b/packages/server/src/server/session/agent-config/agent-config-readback.ts @@ -6,7 +6,10 @@ function canonicalJson(value: unknown): string { if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null"; if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; const record = value as Record; - return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(",")}}`; } function sha256(value: string): string { @@ -31,7 +34,8 @@ export function createAgentConfigReadback(agent: ManagedAgent): AgentConfigReadb providerOptionsSha256: valueDigest(config.providerOptions), toolPolicySha256: valueDigest(config.toolPolicy), mcpServersSha256: valueDigest(config.mcpServers), - systemPromptSha256: config.systemPrompt === undefined ? null : sha256(canonicalJson(config.systemPrompt)), + systemPromptSha256: + config.systemPrompt === undefined ? null : sha256(canonicalJson(config.systemPrompt)), }; const identity = { contract: "paseo.agent-config-readback.v1" as const, diff --git a/packages/server/src/server/session/agent-config/agent-config-session.test.ts b/packages/server/src/server/session/agent-config/agent-config-session.test.ts index b2d9749c3c0..d2e9625e310 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.test.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.test.ts @@ -102,10 +102,18 @@ describe("AgentConfigSession", () => { requestId: "read-1", }); expect(operations.callLog).toEqual(["read:agent-1"]); - expect(emitted).toEqual([{ - type: "agent.config.read.response", - payload: { requestId: "read-1", agentId: "agent-1", accepted: true, error: null, readback: operations.readback }, - }]); + expect(emitted).toEqual([ + { + type: "agent.config.read.response", + payload: { + requestId: "read-1", + agentId: "agent-1", + accepted: true, + error: null, + readback: operations.readback, + }, + }, + ]); expect(JSON.stringify(emitted)).not.toContain("secret"); }); @@ -117,13 +125,18 @@ describe("AgentConfigSession", () => { agentId: "agent-1", requestId: "read-2", }); - expect(emitted).toEqual([{ - type: "agent.config.read.response", - payload: { - requestId: "read-2", agentId: "agent-1", accepted: false, - error: "Agent config readback is unavailable for this exact agent", readback: null, + expect(emitted).toEqual([ + { + type: "agent.config.read.response", + payload: { + requestId: "read-2", + agentId: "agent-1", + accepted: false, + error: "Agent config readback is unavailable for this exact agent", + readback: null, + }, }, - }]); + ]); expect(JSON.stringify(emitted)).not.toContain("secret-provider-details"); }); @@ -137,7 +150,11 @@ describe("AgentConfigSession", () => { }); expect(emitted[0]).toMatchObject({ type: "agent.config.read.response", - payload: { accepted: false, readback: null, error: "Agent config readback is unavailable for this exact agent" }, + payload: { + accepted: false, + readback: null, + error: "Agent config readback is unavailable for this exact agent", + }, }); expect(JSON.stringify(emitted)).not.toContain("secret-provider-details"); }); diff --git a/packages/server/src/server/session/agent-config/agent-config-session.ts b/packages/server/src/server/session/agent-config/agent-config-session.ts index 878b71709b5..09c922c2dc4 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.ts @@ -158,17 +158,31 @@ export class AgentConfigSession { this.logger.info({ agentId, requestId }, "session: agent.config.read.request"); try { const readback = await this.operations.readConfig(agentId); - if (readback.agentId !== agentId) throw new Error("agent identity changed during config read"); - this.host.emit({ type: "agent.config.read.response", payload: { - requestId, agentId, accepted: true, error: null, readback, - } }); + if (readback.agentId !== agentId) + throw new Error("agent identity changed during config read"); + this.host.emit({ + type: "agent.config.read.response", + payload: { + requestId, + agentId, + accepted: true, + error: null, + readback, + }, + }); } catch { // Do not log or return configuration values or provider diagnostics. this.logger.warn({ agentId, requestId }, "session: agent.config.read.request unavailable"); - this.host.emit({ type: "agent.config.read.response", payload: { - requestId, agentId, accepted: false, - error: "Agent config readback is unavailable for this exact agent", readback: null, - } }); + this.host.emit({ + type: "agent.config.read.response", + payload: { + requestId, + agentId, + accepted: false, + error: "Agent config readback is unavailable for this exact agent", + readback: null, + }, + }); } } diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 3cae63d9259..001f1eaa5b3 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -103,7 +103,7 @@ not receive the request. | `archivedAt` | `string \| null` | Archive timestamp; `null` while the agent is active. | | `current()` | `PaseoAgent \| null` | Current detailed value observed by this handle; never fetches. | | `refresh(requestId?)` | `PaseoAgentRefetchResult \| null` | Fetches the current agent and project placement. | -| `config()` | `Promise` | Reads versioned managed-config fingerprints; requires daemon readback capability. | +| `config()` | `Promise` | Reads versioned managed-config fingerprints; requires daemon readback capability. | | `send(text, options?)` | `Promise` | Resolves when the daemon accepts the prompt. | | `respondToPermission(options)` | `Promise` | Answers a pending permission by `requestId` with an allow or deny `response`. | | `run(text, options?)` | `PaseoAgentRunResult` | Sends a prompt and waits for that turn. `timeoutMs` controls the wait; it defaults to 10 minutes. | From 9e1705c7a652dfaafee1498379d26a567e8841d9 Mon Sep 17 00:00:00 2001 From: "Keith Kowal (orch-dev)" Date: Tue, 29 Sep 2026 23:32:31 -0700 Subject: [PATCH 3/3] feat(client): add authenticated per-agent config readback Expose a negotiated, read-only agent config fingerprint through the public client and session protocol. Bind every response to agent ID and config revision; omit raw launch environment, MCP config, and prompt data. Add protocol, client, and server coverage for old-daemon capability rejection, wrong agent identity, and secret-free canonical fingerprints. --- packages/client/src/daemon-client.test.ts | 1 + packages/client/src/daemon-client.ts | 2 +- packages/client/src/index.test.ts | 45 +++++++++++++++++++ packages/client/src/index.ts | 2 +- .../messages.agent-config-readback.test.ts | 1 + packages/protocol/src/messages.ts | 10 +++-- .../agent-config-readback.test.ts | 23 ++++++++++ .../agent-config/agent-config-readback.ts | 7 ++- .../agent-config/agent-config-session.test.ts | 1 + public-docs/sdk/reference.md | 17 ++++--- 10 files changed, 95 insertions(+), 14 deletions(-) diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index cbf1ad34002..9466ba3ba75 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -389,6 +389,7 @@ test("agent config readback verifies the requested agent identity", async () => error: null, readback: { contract: "paseo.agent-config-readback.v1", + projection: "agent-manager-stored-config", agentId: "agent-b", revision: "2026-09-29T12:00:00.000Z", provider: "codex", diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index db93de1a19c..f428478b93c 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -3483,7 +3483,7 @@ export class DaemonClient { return payload.notice ?? null; } - /** Reads hashes of persisted per-agent config without returning env, MCP secrets, or prompt text. */ + /** Reads stored AgentManager config hashes, not providerLaunchConfig or live provider behavior. */ async getAgentConfig(agentId: string): Promise { if (this.lastServerInfoMessage?.features?.agentConfigReadback !== true) { throw new DaemonConnectionError( diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 508eba4c611..6279f6dc60c 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1601,6 +1601,51 @@ test("agent config requires provider/model syntax", async () => { await client.close(); }); +test("public agent handle reads the authenticated config-readback protocol", async () => { + const { client, ws } = await connectClient({ agentConfigReadback: true }); + const reading = client.agents.ref("agent_public").config(); + const request = parseSentSessionMessage(ws.sent.at(-1)); + + expect(request).toMatchObject({ + type: "agent.config.read.request", + agentId: "agent_public", + }); + + const readback = { + contract: "paseo.agent-config-readback.v1", + projection: "agent-manager-stored-config", + agentId: "agent_public", + revision: "2026-09-29T12:00:00.000Z", + provider: "claude", + cwd: "/repo/sdk", + model: "claude-sonnet-5", + modeId: "auto", + thinkingOptionId: "high", + featureValuesSha256: "a".repeat(64), + envSha256: "b".repeat(64), + providerOptionsSha256: "c".repeat(64), + toolPolicySha256: "d".repeat(64), + mcpServersSha256: "e".repeat(64), + systemPromptSha256: "f".repeat(64), + configSha256: "0".repeat(64), + }; + ws.message( + sessionMessage({ + type: "agent.config.read.response", + payload: { + requestId: request.requestId, + agentId: "agent_public", + accepted: true, + error: null, + readback, + }, + }), + ); + + await expect(reading).resolves.toEqual(readback); + await client.close(); +}); + test("canceled timeline handles and captured state are collectible while their API stays alive", async () => { const source = ` import assert from "node:assert/strict"; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 9d0989894a2..44de9b0b242 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -352,7 +352,7 @@ export interface PaseoAgentHandle { readonly timeline: PaseoAgentTimelineHandle; current(): PaseoAgent | null; refresh(requestId?: string): Promise; - /** Reads exact persisted config fingerprints; never returns secret env/MCP values or prompt text. */ + /** Reads stored AgentManager config fingerprints, not providerLaunchConfig or live session behavior. */ config(): Promise; send(text: string, options?: PaseoAgentSendOptions): Promise; respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise; diff --git a/packages/protocol/src/messages.agent-config-readback.test.ts b/packages/protocol/src/messages.agent-config-readback.test.ts index 5b98b374de3..68f60c4574e 100644 --- a/packages/protocol/src/messages.agent-config-readback.test.ts +++ b/packages/protocol/src/messages.agent-config-readback.test.ts @@ -7,6 +7,7 @@ import { const readback = { contract: "paseo.agent-config-readback.v1", + projection: "agent-manager-stored-config", agentId: "agent-a", revision: "2026-09-29T12:00:00.000Z", provider: "codex", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 0e7edb64391..b6b117e972f 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1941,6 +1941,8 @@ export const AgentConfigApplyResponseMessageSchema = z.object({ const AgentConfigDigestSchema = z.string().regex(/^[0-9a-f]{64}$/); export const AgentConfigReadbackSchema = z.object({ contract: z.literal("paseo.agent-config-readback.v1"), + /** This is the stored AgentManager projection, not providerLaunchConfig. */ + projection: z.literal("agent-manager-stored-config"), agentId: z.string().min(1), /** AgentManager updatedAt, used as an optimistic readback revision. */ revision: z.string().datetime(), @@ -1950,13 +1952,15 @@ export const AgentConfigReadbackSchema = z.object({ modeId: z.string().nullable(), thinkingOptionId: z.string().nullable(), featureValuesSha256: AgentConfigDigestSchema, - /** Hash of configured launch environment; raw values can contain credentials. */ - /** Null means the launch environment was not observed (legacy/imported agent); consumers must fail closed. */ + /** Hash of the resolved environment recorded at the last session open; raw values may be secret. */ + /** Null means the environment was not recorded (legacy/imported agent); consumers must fail closed. */ envSha256: AgentConfigDigestSchema.nullable(), providerOptionsSha256: AgentConfigDigestSchema, toolPolicySha256: AgentConfigDigestSchema, + /** Hash of ManagedAgent.config.mcpServers, excluding runtime-injected Paseo MCP servers. */ mcpServersSha256: AgentConfigDigestSchema, - /** Null means no prompt was configured; prompt contents are never returned. */ + /** Hash of ManagedAgent.config.systemPrompt; excludes daemonAppendSystemPrompt. */ + /** Null means no stored prompt was configured; prompt contents are never returned. */ systemPromptSha256: AgentConfigDigestSchema.nullable(), configSha256: AgentConfigDigestSchema, }); diff --git a/packages/server/src/server/session/agent-config/agent-config-readback.test.ts b/packages/server/src/server/session/agent-config/agent-config-readback.test.ts index b0261537aac..45f73a8f40b 100644 --- a/packages/server/src/server/session/agent-config/agent-config-readback.test.ts +++ b/packages/server/src/server/session/agent-config/agent-config-readback.test.ts @@ -52,6 +52,7 @@ describe("agent config readback", () => { const result = createAgentConfigReadback(managedAgent()); expect(AgentConfigReadbackSchema.parse(result)).toEqual(result); expect(result.agentId).toBe("agent-a"); + expect(result.projection).toBe("agent-manager-stored-config"); expect(result.revision).toBe("2026-09-29T12:00:00.000Z"); expect(result.mcpServersSha256).toMatch(/^[0-9a-f]{64}$/); expect(result.toolPolicySha256).toMatch(/^[0-9a-f]{64}$/); @@ -61,6 +62,28 @@ describe("agent config readback", () => { expect(JSON.stringify(result)).not.toContain("private role prompt"); }); + test("labels hashes as stored config and excludes daemon launch overlays", () => { + const stored = managedAgent(); + const readback = createAgentConfigReadback(stored); + const launchConfig = { + ...stored.config, + mcpServers: { + ...stored.config.mcpServers, + paseo: { type: "http", url: "http://runtime-injected.invalid/mcp" }, + }, + systemPrompt: `${stored.config.systemPrompt}\n\nDaemon-only launch instructions`, + }; + + expect(readback.projection).toBe("agent-manager-stored-config"); + expect(readback.mcpServersSha256).toBe(createAgentConfigReadback(stored).mcpServersSha256); + expect(readback.mcpServersSha256).not.toBe( + createAgentConfigReadback({ ...stored, config: launchConfig }).mcpServersSha256, + ); + expect(readback.systemPromptSha256).not.toBe( + createAgentConfigReadback({ ...stored, config: launchConfig }).systemPromptSha256, + ); + }); + test("canonical digest is stable for key order and changes for revision or config changes", () => { const original = managedAgent(); const reordered = managedAgent({ diff --git a/packages/server/src/server/session/agent-config/agent-config-readback.ts b/packages/server/src/server/session/agent-config/agent-config-readback.ts index 81284d25b78..3fdf43c6620 100644 --- a/packages/server/src/server/session/agent-config/agent-config-readback.ts +++ b/packages/server/src/server/session/agent-config/agent-config-readback.ts @@ -21,9 +21,11 @@ function valueDigest(value: unknown): string { } /** - * Read-only and privacy preserving: no launch env, MCP config, provider options, + * Read-only and privacy preserving: no env values, MCP config, provider options, * or system-prompt bytes leave the daemon. Fingerprints use sorted-key canonical - * JSON so a caller can compare a frozen launch projection without receiving it. + * JSON and describe the persisted ManagedAgent projection. They deliberately do + * not attest providerLaunchConfig overlays such as daemonAppendSystemPrompt or + * the runtime-injected Paseo MCP server; provider/session behavior is separate. */ export function createAgentConfigReadback(agent: ManagedAgent): AgentConfigReadback { const revision = agent.updatedAt.toISOString(); @@ -39,6 +41,7 @@ export function createAgentConfigReadback(agent: ManagedAgent): AgentConfigReadb }; const identity = { contract: "paseo.agent-config-readback.v1" as const, + projection: "agent-manager-stored-config" as const, agentId: agent.id, revision, provider: agent.provider, diff --git a/packages/server/src/server/session/agent-config/agent-config-session.test.ts b/packages/server/src/server/session/agent-config/agent-config-session.test.ts index d2e9625e310..ed8d6228e9d 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.test.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.test.ts @@ -23,6 +23,7 @@ class FakeAgentConfigOperations implements AgentConfigOperations { failWith: Error | null = null; readback: AgentConfigReadback = { contract: "paseo.agent-config-readback.v1", + projection: "agent-manager-stored-config", agentId: "agent-1", revision: "2026-09-29T12:00:00.000Z", provider: "codex", diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 001f1eaa5b3..2f59927aa48 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -74,13 +74,16 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o ### Authenticated daemon config readback -`agent.config()` returns a versioned readback for that exact agent ID. The daemon reports its -managed config revision, provider, cwd, model, mode, thinking option, and SHA-256 fingerprints -for configured environment, feature values, provider options, MCP servers, tool policy, and -system prompt. Raw environment values, MCP credentials, and prompt text are not returned or -logged. The fingerprint is over the daemon's managed/resolved launch configuration, not proof -that the provider session actually applied or exposed those settings; verify runtime behavior -separately. The method fails closed unless the connected daemon advertises +`agent.config()` returns a versioned `agent-manager-stored-config` projection for that exact +agent ID. The daemon reports the stored `ManagedAgent.config` revision, provider, cwd, model, +mode, thinking option, and SHA-256 fingerprints for its stored feature values, provider options, +MCP servers, tool policy, and system prompt. The environment fingerprint is the value recorded +for the most recent session open. These fields do **not** include daemon launch overlays such as +`daemonAppendSystemPrompt` or Paseo's runtime-injected MCP server, and do not describe the final +`providerLaunchConfig` or prove what a live provider session applied/exposed. Raw environment +values, MCP credentials, and prompt text are not returned or logged. Consumers requiring launch +or session guarantees must check those through a separate launch/runtime proof. The method fails +closed unless the connected daemon advertises `features.agentConfigReadback` and returns the exact requested agent identity. Older daemons do not receive the request.