diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index 761f1008bd..ba444a394d 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -135,6 +135,7 @@ export async function ensureAgentLoaded( labels: record.labels, workspaceId: record.workspaceId, owner: record.owner, + env: record.launchEnv, }); deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config"); } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index c01d477004..24b672a3cc 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2868,6 +2868,134 @@ test("createAgent passes daemon launch env through the provider launch context", }); }); +test.each([ + { provider: "codex", hasHandle: true }, + { provider: "opencode", hasHandle: true }, + { provider: "codex", hasHandle: false }, + { provider: "opencode", hasHandle: false }, +])( + "$provider preserves explicit launch env across reloads and storage-backed resumes (handle: $hasHandle)", + async ({ provider, hasHandle }) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-launch-env-")); + const storagePath = join(workdir, "agents"); + class LaunchEnvClient extends TestAgentClient { + lastLaunchContext: AgentLaunchContext | undefined; + + override async createSession( + config: AgentSessionConfig, + context?: AgentLaunchContext, + ): Promise { + this.lastLaunchContext = context; + const session = await super.createSession(config); + session.describePersistence = () => ({ provider, sessionId: session.id }); + return session; + } + + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + context?: AgentLaunchContext, + ): Promise { + this.lastLaunchContext = context; + const session = await super.resumeSession(handle, config, context); + session.describePersistence = () => ({ provider, sessionId: handle.sessionId }); + return session; + } + } + + const client = new LaunchEnvClient(provider); + let storage = new AgentStorage(storagePath, logger); + let manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger }); + const env = { XDG_DATA_HOME: join(workdir, "agent-data"), CUSTOM_EMPTY: "" }; + const expectedEnv = { ...env }; + const agent = await manager.createAgent({ provider, cwd: workdir }, undefined, { + workspaceId: undefined, + env, + }); + onTestFinished(async () => { + await manager.closeAgent(agent.id); + await manager.flush(); + rmSync(workdir, { recursive: true, force: true }); + }); + env.XDG_DATA_HOME = "caller-mutated"; + + await manager.closeAgent(agent.id); + await manager.flush(); + + if (!hasHandle) { + const record = await storage.get(agent.id); + if (!record) throw new Error("Created agent was not persisted"); + await storage.upsert({ ...record, persistence: null }); + } + + // Fresh storage and manager instances must read the environment from disk. + for (let restart = 0; restart < 2; restart += 1) { + storage = new AgentStorage(storagePath, logger); + manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger }); + const resumed = await ensureAgentLoaded(agent.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + expect(client.lastLaunchContext).toEqual({ + agentId: agent.id, + env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir }, + }); + await manager.reloadAgentSession(agent.id); + expect(client.lastLaunchContext).toEqual({ + agentId: agent.id, + env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir }, + }); + expect(toAgentPayload(resumed)).not.toHaveProperty("launchEnv"); + expect(toAgentPayload(resumed).persistence?.metadata).not.toHaveProperty("launchEnv"); + await manager.closeAgent(agent.id); + await manager.flush(); + } + }, +); + +test("session-open hooks override persisted launch env without persisting per-opening values", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-launch-env-hook-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const observedEnv: Array | undefined> = []; + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + pluginLifecycle: { + emit() {}, + async before(name, request) { + if (name === "agent.create" && "env" in request) { + request.env = { ...request.env, FROM_CREATE_HOOK: "saved" }; + } + if (name === "agent.session_open" && "env" in request) { + observedEnv.push({ ...request.env }); + request.env = { ...request.env, XDG_DATA_HOME: "current-hook-data" }; + } + return request; + }, + }, + }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + env: { XDG_DATA_HOME: "original-data" }, + }); + onTestFinished(async () => { + await manager.closeAgent(agent.id); + await manager.flush(); + rmSync(workdir, { recursive: true, force: true }); + }); + await manager.reloadAgentSession(agent.id); + expect(observedEnv).toEqual([ + { XDG_DATA_HOME: "original-data", FROM_CREATE_HOOK: "saved" }, + { XDG_DATA_HOME: "original-data", FROM_CREATE_HOOK: "saved" }, + ]); + expect((await storage.get(agent.id))?.launchEnv).toEqual({ + XDG_DATA_HOME: "original-data", + FROM_CREATE_HOOK: "saved", + }); +}); + test("createAgent passes persistSession to provider create options", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index b39fe782b8..6f931a4a4b 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -412,6 +412,8 @@ interface ManagedAgentBase { owner?: AgentOwner; capabilities: AgentCapabilityFlags; config: AgentSessionConfig; + /** Explicit launch overrides, before per-opening hooks and daemon-owned variables. */ + launchEnv?: Record; runtimeInfo?: AgentRuntimeInfo; createdAt: Date; updatedAt: Date; @@ -1319,6 +1321,7 @@ export class AgentManager { initialTitle: options.initialTitle, workspaceId: options.workspaceId, owner: options.owner, + launchEnv: options.env ? { ...options.env } : undefined, historyPrimed: true, }); if (!agent.internal) { @@ -1410,7 +1413,7 @@ export class AgentManager { const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( mergedConfig, resolvedAgentId, - { purpose }, + { purpose, env: record?.launchEnv }, ); const client = this.requireClient(handle.provider); const available = await client.isAvailable(); @@ -1425,7 +1428,7 @@ export class AgentManager { client, storedConfig.cwd, paseoToolPolicy, - undefined, + record?.launchEnv, { reason: "resume", purpose, @@ -1442,6 +1445,7 @@ export class AgentManager { await this.requireExternalMcpSupport(session, storedConfig); return this.registerSession(session, storedConfig, resolvedAgentId, { ...options, + launchEnv: record?.launchEnv, persistence: handle, restoring: true, }); @@ -1573,6 +1577,7 @@ export class AgentManager { const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( refreshConfig, agentId, + { env: existing.launchEnv }, ); const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId); const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId); @@ -1581,7 +1586,7 @@ export class AgentManager { client, storedConfig.cwd, paseoToolPolicy, - undefined, + existing.launchEnv, { reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); @@ -1626,6 +1631,7 @@ export class AgentManager { labels: existing.labels, workspaceId: existing.workspaceId, owner: existing.owner, + launchEnv: existing.launchEnv, createdAt: existing.createdAt, updatedAt: existing.updatedAt, lastUserMessageAt: existing.lastUserMessageAt, @@ -3575,6 +3581,7 @@ export class AgentManager { publishWhenReady?: boolean; workspaceId?: string; owner?: AgentOwner; + launchEnv?: Record; }, ): Promise { let registered = false; @@ -3753,6 +3760,7 @@ export class AgentManager { persistence?: AgentPersistenceHandle; workspaceId?: string; owner?: AgentOwner; + launchEnv?: Record; } | undefined; }): ActiveManagedAgent { @@ -3763,6 +3771,7 @@ export class AgentManager { cwd: config.cwd, workspaceId: options?.workspaceId, owner: options?.owner, + launchEnv: options?.launchEnv, session, capabilities: session.capabilities, config, diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 7d154e9c14..b388143d8e 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -95,6 +95,7 @@ export function toStoredAgentRecord( lastStatus: agent.lifecycle, lastModeId: agent.currentModeId ?? config?.modeId ?? null, config: config ?? null, + launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined, runtimeInfo, features: normalizeFeatures(agent.features), persistence, diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index be94a31d49..f4f14650ba 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -56,6 +56,7 @@ const STORED_AGENT_SCHEMA = z.object({ lastStatus: AgentStatusSchema.default("closed"), lastModeId: z.string().nullable().optional(), config: SERIALIZABLE_CONFIG_SCHEMA, + launchEnv: z.record(z.string(), z.string()).optional(), runtimeInfo: z .object({ provider: z.string(),