From f4dea7413ea4e09beb6ab4def65b8c3683dc311d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 03:27:23 +0100 Subject: [PATCH] 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. --- .../src/server/plugins/lifecycle.e2e.test.ts | 63 ++++++++++++++++--- .../server/plugins/lifecycle/handlers.test.ts | 40 ++++++++++++ .../src/server/plugins/lifecycle/index.ts | 6 +- 3 files changed, 98 insertions(+), 11 deletions(-) diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts index 951d59b5fa..7d5ffe3082 100644 --- a/packages/server/src/server/plugins/lifecycle.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -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); diff --git a/packages/server/src/server/plugins/lifecycle/handlers.test.ts b/packages/server/src/server/plugins/lifecycle/handlers.test.ts index 8c57598f92..5b9996d4af 100644 --- a/packages/server/src/server/plugins/lifecycle/handlers.test.ts +++ b/packages/server/src/server/plugins/lifecycle/handlers.test.ts @@ -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) => { diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts index a55b61f6d6..e78226f61b 100644 --- a/packages/server/src/server/plugins/lifecycle/index.ts +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -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()), + // Optional so a plugin that returns its own object keeps working; omitted means unchanged. + metadata: z.record(z.string(), z.custom()).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