diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index b75a361fad..fddd83b626 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -3,7 +3,11 @@ import { executableExists, findExecutable, } from "../../executable-resolution/executable-resolution.js"; -import { createExternalProcessEnv, type ProcessEnvRecord } from "../paseo-env.js"; +import { + createExternalProcessEnv, + type ProcessEnvRecord, + type ExternalProcessEnv, +} from "../paseo-env.js"; export { AgentProviderRuntimeSettingsMapSchema, ProviderCommandSchema, @@ -242,7 +246,7 @@ export function createProviderEnvSpec(options: ProviderEnvOptions = {}): Provide }; } -export function createProviderEnv(options: ProviderEnvOptions = {}): NodeJS.ProcessEnv { +export function createProviderEnv(options: ProviderEnvOptions = {}): ExternalProcessEnv { const spec = createProviderEnvSpec(options); return createExternalProcessEnv(spec.baseEnv ?? process.env, spec.envOverlay); } diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index fd3ad3c25e..9a0e696e32 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -7,6 +7,83 @@ import { OpenCodeV2AgentClient } from "./agent.js"; import { V2Harness } from "../test-utils/v2-harness.js"; describe("OpenCode v2 session lifecycle", () => { + test("restores each same-directory agent environment on reconnect and resume", async () => { + const first = new V2Harness(); + const second = new V2Harness(); + first.info.id = "session-first"; + second.info.id = "session-second"; + const environments = new Map>(); + const bind = async (input: { sessionID: string; variables: Record }) => { + environments.set(input.sessionID, { ...input.variables }); + }; + first.api.session.environment = bind; + second.api.session.environment = bind; + const clients = [first, second].map( + (harness) => + new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + settings: { + env: { + PASEO_ENV_TEST: "configured", + PASEO_AGENT_ID: "configured", + ELECTRON_RUN_AS_NODE: "1", + PASEO_SUPERVISED: "1", + CLAUDECODE: "1", + }, + }, + }), + ); + const config = { provider: "opencode" as const, cwd: "/tmp/project" }; + const launches = ["first", "second"].map((id) => ({ + env: { PASEO_AGENT_ID: id, PASEO_AGENT_CWD: config.cwd }, + })); + const assertEnvironment = (index: number) => { + const env = environments.get([first, second][index].info.id); + expect(env?.PATH).toBe(process.env.PATH); + expect(env).toMatchObject({ ...launches[index].env, PASEO_ENV_TEST: "configured" }); + expect(env?.USER).toBe(process.env.USER); + for (const key of [ + "ELECTRON_RUN_AS_NODE", + "PASEO_SUPERVISED", + "CLAUDECODE", + "ESBUILD_BINARY_PATH", + "PASEO_NODE_ENV", + ]) { + expect(env).not.toHaveProperty(key); + } + }; + const sessions = await Promise.all( + clients.map((client, i) => client.createSession(config, launches[i])), + ); + try { + assertEnvironment(0); + assertEnvironment(1); + environments.clear(); + first.push({ id: "reconnected-first", created: 2, type: "server.connected", data: {} }); + second.push({ id: "reconnected-second", created: 2, type: "server.connected", data: {} }); + await expect.poll(() => environments.size).toBe(2); + assertEnvironment(0); + assertEnvironment(1); + } finally { + await Promise.all(sessions.map((session) => session.close())); + } + environments.clear(); + const resumed = await Promise.all( + clients.map((client, i) => + client.resumeSession(sessions[i].describePersistence(), undefined, launches[i]), + ), + ); + try { + assertEnvironment(0); + assertEnvironment(1); + expect(first.prompts).toEqual([]); + expect(second.prompts).toEqual([]); + } finally { + await Promise.all(resumed.map((session) => session.close())); + } + }); + test("reconnects after a helper exits and restores session configuration on the next turn", async () => { const first = new V2Harness(); const second = new V2Harness(); @@ -54,7 +131,10 @@ describe("OpenCode v2 session lifecycle", () => { expect((await session.run("after exit")).finalText).toBe("recovered"); expect(second.prompts).toEqual(["after exit"]); expect(second.mcpAdds).toEqual(["tools"]); - expect(second.environments).toEqual([{ sessionID: "session", variables: { TOKEN: "test" } }]); + expect(second.environments).toMatchObject([ + { sessionID: "session", variables: { TOKEN: "test", PATH: process.env.PATH } }, + ]); + expect(second.environments).toEqual(first.environments); expect(first.releases).toBe(1); } finally { await session.close(); @@ -384,7 +464,7 @@ describe("OpenCode v2 session lifecycle", () => { ); try { expect(acquires).toEqual([{}]); - expect(harness.environments).toEqual([ + expect(harness.environments).toMatchObject([ { sessionID: "session", variables: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" }, diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.ts index 34ed21bd6c..2e50e759bd 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.ts @@ -29,7 +29,10 @@ import type { ListImportableSessionsOptions, ProviderRefreshContext, } from "../../../agent-sdk-types.js"; -import type { ProviderRuntimeSettings } from "../../../provider-launch-config.js"; +import { + createProviderEnv, + type ProviderRuntimeSettings, +} from "../../../provider-launch-config.js"; import type { ManagedProcessRegistry } from "../../../../managed-processes/managed-processes.js"; import { importSessionFromPersistence } from "../../../provider-session-import.js"; @@ -205,7 +208,11 @@ export class OpenCodeV2AgentClient implements AgentClient { const location = { directory: config.cwd }; await awaitPaseoPlugin({ client: connection.client, location }); } - await session.initialize(launch); + await session.initialize( + launch?.env + ? createProviderEnv({ runtimeSettings: this.options.settings, overlays: [launch.env] }) + : undefined, + ); return session; } catch (error) { await session.close(); diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index 27ed2974c4..315e75410c 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -8,7 +8,6 @@ import type { SessionInfo, SessionMessageInfo } from "@opencode/client"; import { setTimeout as delay } from "node:timers/promises"; import type { Logger } from "pino"; import type { - AgentLaunchContext, AgentMode, AgentPermissionResponse, AgentPersistenceHandle, @@ -36,6 +35,7 @@ import { features } from "./configuration.js"; import { commands } from "./commands.js"; import { messages } from "./history.js"; import { SessionPermissions } from "./permissions.js"; + export class OpenCodeV2Session implements AgentSession { readonly provider = "opencode"; readonly capabilities = V2_CAPABILITIES; @@ -106,8 +106,9 @@ export class OpenCodeV2Session implements AgentSession { private get client() { return this.connection.client; } - async initialize(launch?: AgentLaunchContext) { - this.launchEnv = launch?.env; + async initialize(environment?: Record) { + // OpenCode replaces the whole local shell environment, rather than overlaying it. + this.launchEnv = environment; this.watchExit(this.connection); await this.configureConnection(); const location = { directory: this.config.cwd }; @@ -130,8 +131,6 @@ export class OpenCodeV2Session implements AgentSession { await waitForLocationReady({ client: this.client, location, signal: this.abort.signal }); if (this.requiresPaseoPlugin) await awaitPaseoPlugin({ client: this.client, location, signal: this.abort.signal }); - if (this.launchEnv) - await this.client.session.environment({ sessionID: this.id, variables: this.launchEnv }); for (const [server, config] of Object.entries(this.config.mcpServers ?? {})) { await this.client.mcp.add({ server, @@ -389,6 +388,10 @@ export class OpenCodeV2Session implements AgentSession { } } private async reconcileConnection() { + // Session environments are process-local. Reapply this agent's snapshot before + // reconciling a new connection, including event-stream reconnections. + if (this.launchEnv) + await this.client.session.environment({ sessionID: this.id, variables: this.launchEnv }); await this.reconcile(); await this.children.reconcile(this.id); const active = await this.client.session.active();