fix(plugins): an agent.open.before result may omit metadata

The result schema required metadata, so a plugin written before metadata
existed that returns its own object instead of spreading the request
failed validation and the session open failed, even for agents with no
metadata. An omitted metadata now means unchanged and is filled back in
from the request so later plugins in the chain still see it; a present
value that differs is still rejected.

The restart test now sets metadata through the PaseoApi handles and
checks that agent.open.before sees the patched value after the restart.
This commit is contained in:
Mohamed Boudra
2026-11-02 03:27:23 +01:00
parent 6bf7a47a96
commit f4dea7413e
3 changed files with 98 additions and 11 deletions
@@ -8,6 +8,7 @@ import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { resolveDaemonVersion } from "../daemon-version.js";
import { BuiltinPluginLoader } from "./builtin/index.js";
import { createPaseoApi } from "@getpaseo/client";
test("a plugin transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-"));
@@ -325,14 +326,39 @@ export default function contribute(server) {
test("metadata set at creation and through setMetadata survives a daemon restart", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-metadata-restart-"));
const directory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-cwd-"));
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-plugin-"));
try {
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({
id: "metadata-restart",
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
}),
);
// Returns an explicit object without `metadata`, as a plugin written before metadata existed.
await writeFile(
path.join(pluginDirectory, "index.server.ts"),
`
export default function contribute(server) {
server.addHook("agent.open.before", async ({ request }) => {
console.log(JSON.stringify({ hook: "agent.open.before", metadata: request.metadata }));
const { metadata, ...old } = request;
return { ...old, env: { ...old.env, HOOK_OPEN: "opened" } };
});
return () => {};
}
`,
);
const first = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
const firstClient = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws` });
const firstApi = createPaseoApi(firstClient);
let agentId: string;
let workspaceId: string;
try {
await firstClient.connect();
await firstClient.fetchAgents({ subscribe: {} });
await firstClient.patchDaemonConfig({ pluginsEnabled: true });
await firstClient.installDirectoryPlugin(pluginDirectory);
const workspace = await firstClient.createWorkspace({
source: { kind: "directory", path: directory },
metadata: { "a.workspace": 1, "b.workspace": "to remove" },
@@ -345,19 +371,18 @@ test("metadata set at creation and through setMetadata survives a daemon restart
metadata: { "a.agent": { nested: [1, 2] }, "b.agent": "to remove" },
});
agentId = agent.id;
expect(
await firstClient.setAgentMetadata(agentId, { "b.agent": null, "c.agent": true }),
).toEqual({
const agentHandle = firstApi.agents.ref(agentId);
await agentHandle.setMetadata({ "b.agent": null, "c.agent": true });
await firstApi.workspaces.ref(workspaceId).setMetadata({
"b.workspace": null,
"c.workspace": [],
});
expect((await firstClient.fetchAgent({ agentId }))?.agent.metadata).toEqual({
"a.agent": { nested: [1, 2] },
"c.agent": true,
});
expect(
await firstClient.setWorkspaceMetadata(workspaceId, {
"b.workspace": null,
"c.workspace": [],
}),
).toEqual({ "a.workspace": 1, "c.workspace": [] });
} finally {
await firstApi.dispose();
await firstClient.close();
await first.close();
}
@@ -373,6 +398,25 @@ test("metadata set at creation and through setMetadata survives a daemon restart
"a.workspace": 1,
"c.workspace": [],
});
await secondClient.fetchAgents({ subscribe: {} });
await secondClient.patchDaemonConfig({ pluginsEnabled: true });
await secondClient.installDirectoryPlugin(pluginDirectory);
await secondClient.refreshAgent(agentId);
await expect
.poll(async () => {
const logs = await secondClient.getPluginLogs("metadata-restart");
return logs
.filter((entry) => entry.message.startsWith('{"hook":'))
.map((entry) => JSON.parse(entry.message));
})
.toContainEqual({
hook: "agent.open.before",
metadata: { "a.agent": { nested: [1, 2] }, "c.agent": true },
});
expect((await secondClient.fetchAgent({ agentId }))?.agent.metadata).toEqual({
"a.agent": { nested: [1, 2] },
"c.agent": true,
});
} finally {
await secondClient.close();
await second.close();
@@ -380,6 +424,7 @@ test("metadata set at creation and through setMetadata survives a daemon restart
} finally {
await rm(root, { recursive: true, force: true });
await rm(directory, { recursive: true, force: true });
await rm(pluginDirectory, { recursive: true, force: true });
}
}, 60_000);
@@ -101,6 +101,46 @@ test("session-open hooks reject changes to session identity instead of silently
).rejects.toThrow("agent.open.before hooks can only change env");
});
test("a session-open hook that returns its own object without metadata leaves metadata unchanged", async () => {
const hooks = new PluginHookHandlers(() => {});
hooks.addHook("agent.open.before", async ({ request }) => ({
agentId: request.agentId,
workspaceId: request.workspaceId,
provider: request.provider,
cwd: request.cwd,
reason: request.reason,
purpose: request.purpose,
title: request.title,
labels: request.labels,
tools: request.tools,
env: { HOOK: "1" },
mcpServers: request.mcpServers,
}));
const result = await hooks.invoke(
"operation",
"agent.open.before",
{
agentId: "agent",
workspaceId: null,
provider: "codex",
cwd: "/project",
title: null,
labels: {},
metadata: { "linear.issue": { id: "ENG-1" } },
tools: [],
mcpServers: {},
env: {},
reason: "resume",
purpose: "interactive",
},
paseo,
);
expect(result).toMatchObject({
env: { HOOK: "1" },
metadata: { "linear.issue": { id: "ENG-1" } },
});
});
test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
"session-open targeting metadata is read-only: %j",
async (patch) => {
@@ -104,7 +104,8 @@ const beforeSchemas = {
purpose: z.enum(["interactive", "history"]),
title: z.string().nullable(),
labels: z.record(z.string(), z.string()),
metadata: z.record(z.string(), z.custom<JsonValue>()),
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
metadata: z.record(z.string(), z.custom<JsonValue>()).optional(),
tools: z.array(agentToolMetadataSchema),
env: z.record(z.string(), z.string()),
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
@@ -236,10 +237,11 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
previous.purpose !== next.purpose ||
previous.title !== next.title ||
!isDeepStrictEqual(previous.labels, next.labels) ||
!isDeepStrictEqual(previous.metadata, next.metadata)
(next.metadata !== undefined && !isDeepStrictEqual(previous.metadata, next.metadata))
) {
throw new Error("agent.open.before hooks can only change env, tools, and mcpServers");
}
return { ...result, metadata: previous.metadata };
}
if (name === "agent.create.before") {
const previous = beforeSchemas["agent.create.before"].parse(input);