Give daemon-spawned ACP terminals the agent's identity (#5248)

* fix(acp): give daemon-spawned terminals the agent's launch environment

A terminal the daemon opens for an ACP agent is a sibling of the agent
process, not a child, so it inherits nothing from it. createTerminal built
its environment from the requested terminal env alone, while the transport
spawn a hundred lines down already overlays the session's launchEnv. The
terminal therefore ran without PASEO_AGENT_ID or PASEO_AGENT_CWD, so
`paseo heartbeat create` refused to run and `paseo run` lost the caller,
creating child agents with no parent. When the daemon itself was started
from inside another Paseo agent, the terminal saw that agent's id instead.

Overlay launchEnv beneath the requested terminal env in both the argv and
the single-string shell branch, matching the transport spawn's precedence.

Fixes #5016

* test(acp): take createSession options as an object

The helper had grown a second optional positional parameter, making
callers read createSession(undefined, launchEnv).

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-09-23 09:44:02 +02:00
committed by GitHub
parent d161b5987d
commit 94ab368e4e
2 changed files with 128 additions and 7 deletions
@@ -8,6 +8,7 @@ import {
RequestError,
ndJsonStream,
type Agent,
type CreateTerminalRequest,
PermissionOption,
PromptResponse,
RequestPermissionRequest,
@@ -128,7 +129,13 @@ interface ACPConfiguredOverrideInternals {
applyConfiguredOverrides(): Promise<void>;
}
function createSession(terminateProcess?: ProcessTerminator): ACPAgentSession {
function createSession(
options: {
terminateProcess?: ProcessTerminator;
launchEnv?: Record<string, string>;
} = {},
): 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<CreateTerminalRequest> = {},
): 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<ACPCloseInternals>(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);
@@ -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({