diff --git a/packages/server/src/server/agent/providers/claude/agent.env.test.ts b/packages/server/src/server/agent/providers/claude/agent.env.test.ts index 0806a0dbc6..6d268e932c 100644 --- a/packages/server/src/server/agent/providers/claude/agent.env.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.env.test.ts @@ -30,16 +30,20 @@ function createQueryMock(events: unknown[]): Query { } describe("Claude SDK env", () => { - test.each(["node", "bun", process.execPath])( - "identifies the spawned %s process as Paseo after clearing parent session env", - async (command) => { + test.each( + ["node", "bun", process.execPath].flatMap((command) => + [undefined, "Paseo", "custom-client"].map((entrypoint) => ({ command, entrypoint })), + ), + )( + "preserves explicit launch identity $entrypoint in $command while clearing inherited session env", + async ({ command, entrypoint }) => { let output = ""; let exited = Promise.resolve([]); const query = claudeQuery( { prompt: "", options: {} }, { runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } }, - launchEnv: { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" }, + launchEnv: entrypoint === undefined ? {} : { CLAUDE_CODE_ENTRYPOINT: entrypoint }, queryFactory: ({ options }) => { if (!options.spawnClaudeCodeProcess) { throw new Error("Missing Claude process launcher"); @@ -65,7 +69,10 @@ describe("Claude SDK env", () => { try { expect(await exited).toEqual([0, null]); - expect(JSON.parse(output)).toEqual({ entrypoint: "Paseo", parentSession: null }); + expect(JSON.parse(output)).toEqual({ + ...(entrypoint === undefined ? {} : { entrypoint }), + parentSession: null, + }); } finally { query.close(); } diff --git a/packages/server/src/server/agent/providers/claude/query.ts b/packages/server/src/server/agent/providers/claude/query.ts index c8fe4ba036..0f9525d5b1 100644 --- a/packages/server/src/server/agent/providers/claude/query.ts +++ b/packages/server/src/server/agent/providers/claude/query.ts @@ -73,8 +73,9 @@ function applyRuntimeSettingsToClaudeOptions( runtimeSettings, overlays: [launchEnv], }); - // Set our identity after sanitization clears the parent session's entrypoint. - providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = "Paseo"; + // Session-open hooks may explicitly supply a client identity. Keep inherited + // SDK/parent identities cleared while honoring this launch-only contribution. + providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = launchEnv?.CLAUDE_CODE_ENTRYPOINT; const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay); const selfNodeCommand = isDefaultRuntime ? buildSelfNodeCommand(resolved.args, providerEnv) diff --git a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts index 9f62075125..a3e4e273a4 100644 --- a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts @@ -1,10 +1,10 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; -import type { AgentSessionConfig } from "../agent/agent-sdk-types.js"; +import type { AgentClient, AgentSessionConfig } from "../agent/agent-sdk-types.js"; import { createTestAgentClient } from "../test-utils/fake-agent-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; @@ -72,3 +72,71 @@ test("the configuration example saves Codex options and injects launch-only MCP await rm(directory, { recursive: true, force: true }); } }, 60_000); + +test("session-open entrypoint contributions are optional and rederived for existing agents", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-entrypoint-hook-")); + const launches: { provider: string; env?: Record }[] = []; + function recordingProvider(provider: "claude" | "codex"): AgentClient { + const inner = createTestAgentClient(provider); + return { + provider: inner.provider, + capabilities: inner.capabilities, + isAvailable: () => inner.isAvailable(), + fetchCatalog: (options) => inner.fetchCatalog(options), + createSession: async (...args: Parameters) => { + launches.push({ provider, env: args[1]?.env }); + return inner.createSession(...args); + }, + resumeSession: async (...args: Parameters) => { + launches.push({ provider, env: args[2]?.env }); + return inner.resumeSession(...args); + }, + }; + } + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.14.0-beta.2", + agentClients: { claude: recordingProvider("claude"), codex: recordingProvider("codex") }, + mcpEnabled: false, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.14.0-beta.2", + }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "entrypoint-test", requirements: { paseo: ">=0.14.0-beta.2" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + return server.before("agent.session_open", ({ request }) => { + if (request.provider !== "claude") return; + return { ...request, env: { ...request.env, CLAUDE_CODE_ENTRYPOINT: "custom-client" } }; + }); +} +`, + ); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const existing = await client.createAgent({ provider: "claude", cwd: directory }); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + await daemon.daemon.agentManager.reloadAgentSession(existing.id); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBe("custom-client"); + await client.createAgent({ provider: "claude", cwd: directory }); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBe("custom-client"); + await client.createAgent({ provider: "codex", cwd: directory }); + expect(launches.at(-1)).toMatchObject({ provider: "codex" }); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + await client.disablePlugin("entrypoint-test"); + await daemon.daemon.agentManager.reloadAgentSession(existing.id); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000);