Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions packages/client/src/daemon-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,72 @@ 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",
projection: "agent-manager-stored-config",
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({
Expand Down
33 changes: 33 additions & 0 deletions packages/client/src/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ import type {
AgentSessionConfig,
} from "@getpaseo/protocol/agent-types";
import type {
AgentConfigReadback,
AgentConfigApply,
MutableDaemonConfig,
MutableDaemonConfigPatch,
Expand Down Expand Up @@ -3482,6 +3483,38 @@ export class DaemonClient {
return payload.notice ?? null;
}

/** Reads stored AgentManager config hashes, not providerLaunchConfig or live provider behavior. */
async getAgentConfig(agentId: string): Promise<AgentConfigReadback> {
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,
Expand Down
45 changes: 45 additions & 0 deletions packages/client/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,8 @@ export interface PaseoAgentHandle {
readonly timeline: PaseoAgentTimelineHandle;
current(): PaseoAgent | null;
refresh(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
/** Reads stored AgentManager config fingerprints, not providerLaunchConfig or live session behavior. */
config(): Promise<import("@getpaseo/protocol/messages").AgentConfigReadback>;
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>;
/** Sends a prompt and resolves when that turn finishes or needs attention. */
Expand Down Expand Up @@ -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);
},
Expand Down
69 changes: 69 additions & 0 deletions packages/protocol/src/messages.agent-config-readback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, test } from "vitest";
import {
AgentConfigReadRequestMessageSchema,
AgentConfigReadResponseMessageSchema,
ServerInfoStatusPayloadSchema,
} from "./messages.js";

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",
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();
});
});
50 changes: 50 additions & 0 deletions packages/protocol/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1938,6 +1938,51 @@ 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"),
/** 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(),
provider: z.string().min(1),
cwd: z.string(),
model: z.string().nullable(),
modeId: z.string().nullable(),
thinkingOptionId: z.string().nullable(),
featureValuesSha256: AgentConfigDigestSchema,
/** 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,
/** Hash of ManagedAgent.config.systemPrompt; excludes daemonAppendSystemPrompt. */
/** Null means no stored prompt was configured; prompt contents are never returned. */
systemPromptSha256: AgentConfigDigestSchema.nullable(),
configSha256: AgentConfigDigestSchema,
});
export type AgentConfigReadback = z.infer<typeof AgentConfigReadbackSchema>;

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(),
Expand Down Expand Up @@ -3206,6 +3251,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentThinkingRequestMessageSchema,
SetAgentFeatureRequestMessageSchema,
AgentConfigApplyRequestMessageSchema,
AgentConfigReadRequestMessageSchema,
AgentDetachRequestMessageSchema,
AgentRewindRequestMessageSchema,
AgentPermissionResponseMessageSchema,
Expand Down Expand Up @@ -3637,6 +3683,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(),
})
Expand Down Expand Up @@ -6655,6 +6702,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SetAgentThinkingResponseMessageSchema,
SetAgentFeatureResponseMessageSchema,
AgentConfigApplyResponseMessageSchema,
AgentConfigReadResponseMessageSchema,
AgentDetachResponseMessageSchema,
AgentRewindResponseMessageSchema,
UpdateAgentResponseMessageSchema,
Expand Down Expand Up @@ -6854,6 +6902,8 @@ export type SetAgentModelResponseMessage = z.infer<typeof SetAgentModelResponseM
export type SetAgentThinkingResponseMessage = z.infer<typeof SetAgentThinkingResponseMessageSchema>;
export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureResponseMessageSchema>;
export type AgentConfigApplyResponseMessage = z.infer<typeof AgentConfigApplyResponseMessageSchema>;
export type AgentConfigReadRequestMessage = z.infer<typeof AgentConfigReadRequestMessageSchema>;
export type AgentConfigReadResponseMessage = z.infer<typeof AgentConfigReadResponseMessageSchema>;
export type AgentDetachResponseMessage = z.infer<typeof AgentDetachResponseMessageSchema>;
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
Expand Down
Loading
Loading