diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 237e98892c..07d65a8c86 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -8,6 +8,7 @@ import { RequestError, ndJsonStream, type Agent, + type CreateTerminalRequest, PermissionOption, PromptResponse, RequestPermissionRequest, @@ -128,7 +129,13 @@ interface ACPConfiguredOverrideInternals { applyConfiguredOverrides(): Promise; } -function createSession(terminateProcess?: ProcessTerminator): ACPAgentSession { +function createSession( + options: { + terminateProcess?: ProcessTerminator; + launchEnv?: Record; + } = {}, +): ACPAgentSession { + const { terminateProcess, launchEnv } = options; return new ACPAgentSession( { provider: "claude-acp", @@ -148,6 +155,7 @@ function createSession(terminateProcess?: ProcessTerminator): ACPAgentSession { supportsToolInvocations: true, }, ...(terminateProcess ? { terminateProcess } : {}), + ...(launchEnv ? { launchEnv } : {}), }, ); } @@ -660,6 +668,114 @@ describe("ACPAgentSession terminal tools", () => { ); }); + // Terminals the daemon opens for an agent are siblings of the agent process, so + // they inherit nothing from it. They carry the agent's identity only if the + // session puts its launch environment on them, the way its transport spawn does. + describe("agent launch environment", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + const readLaunchIdentityArgs = [ + "-e", + "process.stdout.write(JSON.stringify({ id: process.env.PASEO_AGENT_ID ?? null, cwd: process.env.PASEO_AGENT_CWD ?? null, extra: process.env.PASEO_TEST_EXTRA ?? null }))", + ]; + + async function readLaunchIdentity( + session: ACPAgentSession, + params: Partial = {}, + ): Promise<{ id: string | null; cwd: string | null; extra: string | null }> { + const terminal = await session.createTerminal({ + sessionId: "session-1", + command: process.execPath, + args: readLaunchIdentityArgs, + cwd: process.cwd(), + ...params, + }); + await session.waitForTerminalExit({ + sessionId: "session-1", + terminalId: terminal.terminalId, + }); + const { output } = await session.terminalOutput({ + sessionId: "session-1", + terminalId: terminal.terminalId, + }); + return JSON.parse(output.trim()); + } + + test("gives the terminal the agent's identity", async () => { + // The daemon itself does not run inside a Paseo agent. + vi.stubEnv("PASEO_AGENT_ID", undefined); + vi.stubEnv("PASEO_AGENT_CWD", undefined); + const session = createSession({ + launchEnv: { + PASEO_AGENT_ID: "agent-1", + PASEO_AGENT_CWD: "/repo", + }, + }); + + await expect(readLaunchIdentity(session)).resolves.toMatchObject({ + id: "agent-1", + cwd: "/repo", + }); + }); + + test("prefers the agent's identity over the daemon's own environment", async () => { + // A daemon started from inside another Paseo agent carries that agent's id. + vi.stubEnv("PASEO_AGENT_ID", "daemon-host-agent"); + vi.stubEnv("PASEO_AGENT_CWD", "/elsewhere"); + const session = createSession({ + launchEnv: { + PASEO_AGENT_ID: "agent-1", + PASEO_AGENT_CWD: "/repo", + }, + }); + + await expect(readLaunchIdentity(session)).resolves.toMatchObject({ + id: "agent-1", + cwd: "/repo", + }); + }); + + test("lets the requested terminal environment win over the launch environment", async () => { + const session = createSession({ + launchEnv: { + PASEO_AGENT_ID: "agent-1", + PASEO_TEST_EXTRA: "from-launch", + }, + }); + + await expect( + readLaunchIdentity(session, { + env: [{ name: "PASEO_TEST_EXTRA", value: "from-request" }], + }), + ).resolves.toMatchObject({ id: "agent-1", extra: "from-request" }); + }); + + test("carries the agent's identity into single-string shell commands", async () => { + const child = createTerminalChildStub(); + const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child); + const session = createSession({ launchEnv: { PASEO_AGENT_ID: "agent-1" } }); + + await session.createTerminal({ + sessionId: "session-1", + command: "paseo heartbeat create --every 5m", + cwd: "/repo", + }); + + expect(spawn).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + envOverlay: expect.objectContaining({ + PASEO_AGENT_ID: "agent-1", + BASH_ENV: undefined, + }), + }), + ); + }); + }); + test("surfaces spawn errors through terminal output and waitForTerminalExit", async () => { const child = createTerminalChildStub(); vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child); @@ -3327,7 +3443,7 @@ describe("ACPAgentSession close() tree-kill", () => { test("close() terminates the main child process via the process tree", async () => { const terminator = new FakeTerminator(); - const session = createSession(terminator.terminate); + const session = createSession({ terminateProcess: terminator.terminate }); const internals = asInternals(session); const child = createTerminalChildStub(); @@ -3345,7 +3461,7 @@ describe("ACPAgentSession close() tree-kill", () => { test("close() terminates running terminal child processes", async () => { const terminator = new FakeTerminator(); - const session = createSession(terminator.terminate); + const session = createSession({ terminateProcess: terminator.terminate }); const terminalChild = createTerminalChildStub(); await startTerminal(session, terminalChild); @@ -3358,7 +3474,7 @@ describe("ACPAgentSession close() tree-kill", () => { test("close() terminates terminal child processes in parallel", async () => { const terminator = new FakeTerminator("deferred"); - const session = createSession(terminator.terminate); + const session = createSession({ terminateProcess: terminator.terminate }); const firstChild = createTerminalChildStub(); const secondChild = createTerminalChildStub(); @@ -3376,7 +3492,7 @@ describe("ACPAgentSession close() tree-kill", () => { test("killTerminal terminates the terminal process tree without a direct SIGTERM", async () => { const terminator = new FakeTerminator(); - const session = createSession(terminator.terminate); + const session = createSession({ terminateProcess: terminator.terminate }); const child = createTerminalChildStub(); const terminalId = await startTerminal(session, child); @@ -3389,7 +3505,7 @@ describe("ACPAgentSession close() tree-kill", () => { test("releaseTerminal terminates and removes a running terminal", async () => { const terminator = new FakeTerminator(); - const session = createSession(terminator.terminate); + const session = createSession({ terminateProcess: terminator.terminate }); const child = createTerminalChildStub(); const terminalId = await startTerminal(session, child); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 4b14f924ef..94ba6b9b6b 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -2614,8 +2614,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { (params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]), ); const terminalCommand = resolveTerminalCommand(params.command, params.args); + // The terminal is a sibling of the agent process, not a child, so it inherits + // nothing from it. Carry the agent's launch environment the way spawnProcess + // does, keeping the requested terminal environment on top. const commandEnvOverlays = - terminalCommand.shell === false ? [env, createStringCommandShellEnvOverlay()] : [env]; + terminalCommand.shell === false + ? [this.launchEnv, env, createStringCommandShellEnvOverlay()] + : [this.launchEnv, env]; const child = spawnProcess(terminalCommand.command, terminalCommand.args, { cwd: params.cwd ?? this.config.cwd, ...createProviderEnvSpec({