diff --git a/config/scripts/workflow/source-routing/pr-package-test-paths.mjs b/config/scripts/workflow/source-routing/pr-package-test-paths.mjs index c0333d0e1cd..2bae139d3bb 100644 --- a/config/scripts/workflow/source-routing/pr-package-test-paths.mjs +++ b/config/scripts/workflow/source-routing/pr-package-test-paths.mjs @@ -170,6 +170,7 @@ export const WINDOWS_BOUNDARY_TESTS = [ 'src/shared/__tests__/process-host/windows-tree-kill-late-taskkill.win32.test.ts', 'src/shared/agent-launch/startup/omp-fresh-launch-cmd.win32.test.ts', 'src/shared/windows-batch-spawn.win32.test.ts', + 'src/main/claude/structured/launch/claude-structured-child-env.test.ts', 'src/main/agent-hooks/installation/commands/windows-hook-payload-delivery.test.ts', 'src/main/agent-hooks/installation/scripts/managed-hook-stdin-lifecycle.test.ts', 'src/main/jcode/hook-gate-script.test.ts', diff --git a/src/main/acp/launch/acp-structured-launch-env.test.ts b/src/main/acp/launch/acp-structured-launch-env.test.ts new file mode 100644 index 00000000000..7406980a391 --- /dev/null +++ b/src/main/acp/launch/acp-structured-launch-env.test.ts @@ -0,0 +1,32 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { acpLaunchSpecFor } from './acp-launch-registry' +import { resolveAcpLaunchInvocation } from './acp-structured-launch-resolution' + +afterEach(() => vi.unstubAllGlobals()) + +it('composes configured Windows values before ACP command lookup and account pinning', async () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }) + const spec = acpLaunchSpecFor('grok') + if (!spec) { + throw new Error('Missing Grok spec') + } + const launch = await resolveAcpLaunchInvocation( + { ...spec, installDirectories: () => [] }, + { variable: 'GROK_HOME', path: '/pinned' }, + { + dataDirectory: '/isolated', + inheritedEnv: {}, + resolveEnvironment: async () => ({ PATH: '/inherited/bin', TOKEN: 'inherited' }), + resolveLaunchEnv: () => ({ + path: '/configured/bin', + tOKEN: 'configured', + grok_home: '/configured/home' + }), + resolveCommand: (_command, options) => { + expect(options?.pathEnv).toBe('/configured/bin') + return '/configured/bin/grok.exe' + } + } + ) + expect(launch.env).toEqual({ PATH: '/configured/bin', TOKEN: 'configured', GROK_HOME: '/pinned' }) +}) diff --git a/src/main/acp/launch/acp-structured-launch-resolution.ts b/src/main/acp/launch/acp-structured-launch-resolution.ts index bbb290fe79e..b0e0cae43b7 100644 --- a/src/main/acp/launch/acp-structured-launch-resolution.ts +++ b/src/main/acp/launch/acp-structured-launch-resolution.ts @@ -1,4 +1,5 @@ import { checkAcpLaunchVersion } from './acp-launch-version' +import { overlayEnvironmentVariables } from '../../../shared/environment-overlay' import { assertStructuredAgentResolutionCurrent, @@ -116,10 +117,10 @@ async function readAcpLaunchInvocation( assertStructuredAgentResolutionCurrent(deps.operation) const base = await deps.resolveEnvironment() assertStructuredAgentResolutionCurrent(deps.operation) - const env: Record = spec.account.environment(accountHome, { - ...base, - ...deps.resolveLaunchEnv?.(spec.agent) - }) + const env: Record = spec.account.environment( + accountHome, + overlayEnvironmentVariables(base, deps.resolveLaunchEnv?.(spec.agent), process.platform) + ) const envToDelete = spec.scrubEnvironment?.(env, deps.inheritedEnv ?? process.env) ?? [] Object.assign(env, spec.env) const commandSettings = deps.resolveCommandSettings?.() diff --git a/src/main/claude/structured/launch/claude-structured-child-env.test.ts b/src/main/claude/structured/launch/claude-structured-child-env.test.ts index 18c73fc32e0..1db318f15ed 100644 --- a/src/main/claude/structured/launch/claude-structured-child-env.test.ts +++ b/src/main/claude/structured/launch/claude-structured-child-env.test.ts @@ -2,13 +2,18 @@ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' +import { runProcess } from '@orca/process-host' import { + claudeChildEnv, resolveClaudeChildEnvSources, withOrcaClaudeEntrypoint } from './claude-structured-child-env' import { resolveStructuredAgentCommand } from '../../../native-chat/structured-agent-command-resolution' -afterEach(() => vi.unstubAllEnvs()) +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) it('resolves a custom Claude command against the login shell PATH', async () => { const root = mkdtempSync(join(tmpdir(), 'orca-shell-only-cli-')) @@ -40,4 +45,59 @@ it('keeps a differently cased Windows launch value over the Orca default', () => PATH: 'C:\\bin', CLAUDE_CODE_ENTRYPOINT: 'orca' }) + expect( + withOrcaClaudeEntrypoint( + inherited, + { + CLAUDE_CODE_ENTRYPOINT: 'first', + claude_code_entrypoint: 'last' + }, + 'win32' + ) + ).toEqual({ PATH: 'C:\\bin', CLAUDE_CODE_ENTRYPOINT: 'last' }) }) + +it('uses configured Windows values for both Claude lookup and its final child', async () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }) + const sources = await resolveClaudeChildEnvSources({ + resolveInheritedEnv: async () => ({ + PATH: '/inherited/bin', + TOKEN: 'inherited', + CLAUDE_CONFIG_DIR: '/ambient' + }), + resolveEnv: () => ({ + path: '/configured/bin', + tOKEN: 'configured', + claude_config_dir: '/configured/home' + }), + resolveCommand: (options) => { + expect(options?.pathEnv).toBe('/configured/bin') + return { command: '/configured/bin/claude.exe', prefixArgs: [] } + } + }) + expect(claudeChildEnv(sources, undefined, { runtimeOnPath: false })).toEqual({ + PATH: '/configured/bin', + TOKEN: 'configured', + CLAUDE_CONFIG_DIR: '/configured/home', + CLAUDE_CODE_ENTRYPOINT: 'orca' + }) +}) + +it.skipIf(process.platform !== 'win32')( + 'passes configured values through a real Windows child despite inherited casing', + async () => { + vi.stubEnv('ORCA_NATIVE_ENV_CASE_PROBE', 'inherited') + const sources = await resolveClaudeChildEnvSources({ + resolveEnv: () => ({ orca_native_env_case_probe: 'configured' }), + resolveCommand: () => ({ command: process.execPath, prefixArgs: [] }) + }) + const result = await runProcess({ + program: sources.command, + args: ['--eval', 'process.stdout.write(process.env.ORCA_NATIVE_ENV_CASE_PROBE ?? "")'], + env: claudeChildEnv(sources, undefined, { runtimeOnPath: false }), + timeoutMs: 30_000 + }) + expect(result.code).toBe(0) + expect(result.stdout).toBe('configured') + } +) diff --git a/src/main/claude/structured/launch/claude-structured-child-env.ts b/src/main/claude/structured/launch/claude-structured-child-env.ts index 7348c90c323..c63824523ba 100644 --- a/src/main/claude/structured/launch/claude-structured-child-env.ts +++ b/src/main/claude/structured/launch/claude-structured-child-env.ts @@ -12,6 +12,7 @@ import { withCliRuntimeOnPath } from '../../../../shared/node-cli-command-resolu import { applyClaudeEnvPatch } from '../../../claude-accounts/environment' import { resolveClaudeCommand } from '../../../codex-cli/command' import { withoutInheritedClaudeConfigDir } from '../../claude-config-dir-pin' +import { overlayEnvironmentVariables } from '../../../../shared/environment-overlay' function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): Record { const next: Record = {} @@ -54,7 +55,8 @@ export function resolveClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise readClaudeChildEnvSources(deps), deps.refreshEnvironmentAfterMiss, { - env: ({ inheritedEnv, overlay }) => ({ ...inheritedEnv, ...overlay }), + env: ({ inheritedEnv, overlay }) => + overlayEnvironmentVariables(inheritedEnv, overlay, process.platform), cwd: deps.resolveWorkingDirectory, signal: deps.signal }, @@ -64,14 +66,20 @@ export function resolveClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise { assertStructuredAgentResolutionCurrent(deps.operation) - const overlay = await waitForPromiseWithSignal(Promise.resolve(deps.resolveEnv?.()), deps.signal) + const configured = await waitForPromiseWithSignal( + Promise.resolve(deps.resolveEnv?.()), + deps.signal + ) + const overlay = configured + ? overlayEnvironmentVariables({}, cloneDefinedEnv(configured), process.platform) + : undefined assertStructuredAgentResolutionCurrent(deps.operation) const inheritedEnv = deps.resolveInheritedEnv ? await waitForPromiseWithSignal(deps.resolveInheritedEnv(), deps.signal) : cloneDefinedEnv(process.env) assertStructuredAgentResolutionCurrent(deps.operation) deps.signal?.throwIfAborted() - const effectiveEnv = { ...inheritedEnv, ...overlay } + const effectiveEnv = overlayEnvironmentVariables(inheritedEnv, overlay, process.platform) const options = { pathEnv: effectiveEnv.PATH ?? effectiveEnv.Path ?? null, homePath: effectiveEnv.HOME ?? effectiveEnv.USERPROFILE @@ -95,13 +103,12 @@ export function withOrcaClaudeEntrypoint( (platform === 'win32' ? key.toUpperCase() : key) === CLAUDE_ENTRYPOINT_ENV const withoutEntrypoint = (source: Record): Record => Object.fromEntries(Object.entries(source).filter(([key]) => !isEntrypointKey(key))) - const override = Object.entries(overlay ?? {}).find(([key]) => isEntrypointKey(key))?.[1] // The SDK reads only the exact name and otherwise adds its own default, which Windows would pick. - return { - ...withoutEntrypoint(inheritedEnv), - ...withoutEntrypoint(overlay ?? {}), - [CLAUDE_ENTRYPOINT_ENV]: override ?? 'orca' - } + return overlayEnvironmentVariables( + { ...withoutEntrypoint(inheritedEnv), [CLAUDE_ENTRYPOINT_ENV]: 'orca' }, + overlay, + platform + ) } // Merge onto the shell environment to retain PATH and ambient Anthropic auth. diff --git a/src/main/codex/structured/catalog/codex-model-catalog-probe.test.ts b/src/main/codex/structured/catalog/codex-model-catalog-probe.test.ts index 47779af453c..3fe0e337ea9 100644 --- a/src/main/codex/structured/catalog/codex-model-catalog-probe.test.ts +++ b/src/main/codex/structured/catalog/codex-model-catalog-probe.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createCodexModelCatalogProbe } from './codex-model-catalog-probe' import { runCodexAppServerSession, @@ -14,6 +14,46 @@ const MODEL_ROW = { isDefault: true } +afterEach(() => vi.unstubAllGlobals()) + +it.each(['saved', 'recover'] as const)( + 'composes the Windows %s catalog environment before lookup and listing', + async (lookup) => { + vi.stubGlobal('process', { ...process, platform: 'win32' }) + const probe = createCodexModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: '/inherited/bin', TOKEN: 'inherited' }), + resolveSavedEnvironment: async () => ({ PATH: '/saved/bin', TOKEN: 'saved' }), + resolveEnv: () => ({ + path: '/configured/bin', + tOKEN: 'configured', + codex_home: '/configured/home' + }), + resolveCommand: (options) => { + expect(options?.pathEnv).toBe('/configured/bin') + return { command: '/configured/bin/codex.exe', prefixArgs: [] } + }, + runSession: async (invocation, body) => { + expect(invocation.env).toEqual({ + PATH: '/configured/bin', + TOKEN: 'configured', + CODEX_HOME: '/pinned' + }) + return body({ + request: async (method: string) => + method === 'config/read' ? { config: {} } : { data: [MODEL_ROW], nextCursor: null }, + notify: () => {} + }) + } + }) + const resolved = await probe.resolve?.( + { variable: 'CODEX_HOME', path: '/pinned' }, + '/probe', + lookup + ) + expect((await resolved?.run())?.models[0]?.id).toBe('gpt-live') + } +) + describe('codex model catalog probe', () => { it.each([[[]]])( "lists a command line with arguments %j from Orca's folder for every chat", diff --git a/src/main/codex/structured/catalog/codex-model-catalog-probe.ts b/src/main/codex/structured/catalog/codex-model-catalog-probe.ts index dc72b22ea32..fe4b82a24a2 100644 --- a/src/main/codex/structured/catalog/codex-model-catalog-probe.ts +++ b/src/main/codex/structured/catalog/codex-model-catalog-probe.ts @@ -3,6 +3,7 @@ import { type StructuredAgentResolutionOperation } from '../../../native-chat/structured-agent-command-resolution' import { codexStructuredLaunchArgs } from '../launch/codex-structured-launch-args' +import { overlayEnvironmentVariables } from '../../../../shared/environment-overlay' import { envWithPathRelativeTo } from '../../../../shared/node-cli-command-resolution' import type { AgentSessionAccountKind } from '../../../../shared/agent-session/agent-session-availability' import { AgentModelCatalogUnavailableError } from '../../../native-chat/agent-model-catalog/agent-model-catalog-unavailable' @@ -57,13 +58,14 @@ export function createCodexModelCatalogProbe( ) => { assertStructuredAgentResolutionCurrent(operation) const accountHomePath = requireLegacyAgentSessionAccountHome(accountHome).path - const overlay = { ...deps.resolveEnv?.() } + const overlay = overlayEnvironmentVariables({}, deps.resolveEnv?.(), process.platform) const readEnvironment = lookup === 'saved' ? deps.resolveSavedEnvironment : deps.resolveEnvironment const { command, prefixArgs, environment } = await resolveCodexStructuredInvocation({ ...deps, operation, - resolveEnvironment: async () => ({ ...(await readEnvironment?.()), ...overlay }), + resolveEnvironment: async () => + overlayEnvironmentVariables((await readEnvironment?.()) ?? {}, overlay, process.platform), ...(lookup === 'recover' ? {} : { refreshEnvironmentAfterMiss: undefined }), ...(launchDirectory ? { resolveWorkingDirectory: () => launchDirectory } : {}) }) diff --git a/src/main/runtime/agent-session/environment/structured-agent-environment-overrides.test.ts b/src/main/runtime/agent-session/environment/structured-agent-environment-overrides.test.ts new file mode 100644 index 00000000000..1a31295d11e --- /dev/null +++ b/src/main/runtime/agent-session/environment/structured-agent-environment-overrides.test.ts @@ -0,0 +1,26 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' + +afterEach(() => vi.unstubAllGlobals()) + +it.each([ + ['win32', { TOKEN: 'configured', PATH: '/configured/bin', KEEP: 'keep' }], + [ + 'linux', + { + TOKEN: 'inherited', + PATH: '/inherited/bin', + tOKEN: 'configured', + path: '/configured/bin', + KEEP: 'keep' + } + ] +] as const)('composes the effective agent environment on %s', async (platform, expected) => { + vi.stubGlobal('process', { ...process, platform }) + const environment = createStructuredAgentEnvironmentResolvers({ + resolveEnvironment: async () => ({ TOKEN: 'inherited', PATH: '/inherited/bin', KEEP: 'keep' }), + resolveAgentLaunchEnv: () => ({ tOKEN: 'configured', path: '/configured/bin' }) + }) + expect(await environment.resolveAgentEnvironment('claude')).toEqual(expected) + expect(await environment.resolveAgentEnvironment('codex')).toEqual(expected) +}) diff --git a/src/main/runtime/agent-session/environment/structured-agent-shell-environment-catalog.test.ts b/src/main/runtime/agent-session/environment/structured-agent-shell-environment-catalog.test.ts index 1c0a13e22b6..87bd9cad5e9 100644 --- a/src/main/runtime/agent-session/environment/structured-agent-shell-environment-catalog.test.ts +++ b/src/main/runtime/agent-session/environment/structured-agent-shell-environment-catalog.test.ts @@ -40,13 +40,14 @@ const cleanups: (() => void)[] = [] afterEach(() => { cleanups.splice(0).forEach((stop) => stop()) vi.restoreAllMocks() + vi.unstubAllGlobals() shell.current = { PATH: '/a', IGNORED: 'a' } shell.next = { ...shell.current } shell.saved = [] shell.nextSaved = [] }) -function rig() { +function rig(overlay?: Record) { const record = { ...agentSessionRecordFixture(), provider: 'grok', @@ -67,7 +68,8 @@ function rig() { workspaceMayOverrideDefaultModel: defaults }) const environment = createStructuredAgentEnvironmentResolvers({ - resolveShellEnvironmentPolicy: () => ({ inheritAll: false, names: [] }) + resolveShellEnvironmentPolicy: () => ({ inheritAll: false, names: [] }), + resolveAgentLaunchEnv: () => overlay ?? {} }) cleanups.push(service.stop, environment.bindCatalogSources(['grok'])) const listing = (id: string) => ({ @@ -84,6 +86,22 @@ function rig() { } } +it('keeps a Windows catalog source while only a shadowed inherited path changes', async () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }) + const overlay = { path: '/configured/bin' } + const h = rig(overlay) + const source = h.service.captureSource(h.record) + cleanups.push(() => source.release?.()) + await captureHostShellEnvironment(async () => { + shell.next = { PATH: '/changed', IGNORED: 'a' } + }) + expect(source()).toBe(true) + await captureHostShellEnvironment(async () => { + overlay.path = '/new-configured/bin' + }) + expect(source()).toBe(false) +}) + it('retires a producer on effective Refresh before new publication and fences late defaults and new old reports', async () => { const h = rig() await agentModelCatalogStore.attachPersistence({ diff --git a/src/main/runtime/agent-session/environment/structured-agent-shell-environment.ts b/src/main/runtime/agent-session/environment/structured-agent-shell-environment.ts index b4996cff6ff..e050391680d 100644 --- a/src/main/runtime/agent-session/environment/structured-agent-shell-environment.ts +++ b/src/main/runtime/agent-session/environment/structured-agent-shell-environment.ts @@ -11,6 +11,7 @@ import { resolvePathEnvKey } from '../../../pty/environment/windows-path-segment-merge' import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { overlayEnvironmentVariables } from '../../../../shared/environment-overlay' import { nativeChatShellEnvironmentPolicy, type NativeChatShellEnvironmentPolicy @@ -29,24 +30,6 @@ function definedEnv(env: NodeJS.ProcessEnv): Record { return defined } -/** Overlays shell variables on Orca's env; win32 names are case-insensitive, so drop the base spelling. */ -function overlayShellVariables( - base: Record, - shellVariables: Record, - platform: NodeJS.Platform -): Record { - const merged = { ...base } - if (platform === 'win32') { - const overlaid = new Set(Object.keys(shellVariables).map((key) => key.toUpperCase())) - for (const key of Object.keys(merged)) { - if (overlaid.has(key.toUpperCase())) { - delete merged[key] - } - } - } - return { ...merged, ...shellVariables } -} - function pickShellVariables( shellEnv: NodeJS.ProcessEnv, names: readonly string[], @@ -78,7 +61,7 @@ export function structuredAgentBaseEnvironment(input: { return definedEnv(input.shellEnv) } const platform = input.platform ?? process.platform - return overlayShellVariables( + return overlayEnvironmentVariables( definedEnv(input.processEnv ?? process.env), pickShellVariables(input.shellEnv, input.policy.names, platform), platform @@ -246,7 +229,11 @@ export function createStructuredAgentEnvironmentResolvers( const base = structuredAgentBaseEnvironment({ shellEnv, policy, processEnv }) return new Map( agents.map((agent) => { - const effective = { ...base, ...overlays.get(agent) } + const effective = overlayEnvironmentVariables( + base, + overlays.get(agent), + process.platform + ) return [agent, structuredAgentEnvironmentIdentity(effective)] }) ) @@ -258,10 +245,12 @@ export function createStructuredAgentEnvironmentResolvers( } }, resolveBaseEnvironment: resolveBase, - resolveAgentEnvironment: async (agent) => ({ - ...(await resolveBase()), - ...sources.resolveAgentLaunchEnv?.(agent) - }) + resolveAgentEnvironment: async (agent) => + overlayEnvironmentVariables( + await resolveBase(), + sources.resolveAgentLaunchEnv?.(agent), + process.platform + ) } } diff --git a/src/main/runtime/agent-session/installation/runtime-structured-agent-host-env.test.ts b/src/main/runtime/agent-session/installation/runtime-structured-agent-host-env.test.ts new file mode 100644 index 00000000000..744e51d8736 --- /dev/null +++ b/src/main/runtime/agent-session/installation/runtime-structured-agent-host-env.test.ts @@ -0,0 +1,34 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { RuntimeStructuredAgentHost } from './runtime-structured-agent-host' +import { resolveStructuredClaudeAccountHomePath } from '../environment/structured-agent-account-home' + +vi.mock('../../terminal/wait/terminal-wait-visible-probe', () => ({ + OrcaRuntimeWithStartTuiIdleVisibleReadProbe: class {} +})) +afterEach(() => vi.unstubAllGlobals()) + +abstract class LaunchRuntime extends RuntimeStructuredAgentHost { + configuredEnvironment() { + return this.structuredAgentLaunchEnv('claude', { + agentDefaultEnv: { claude: { claude_config_dir: '/configured/home' } } + }) + } +} + +it('uses the same configured Windows home for account identity, then honors the host account', async () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }) + let host: Record = {} + const runtime: LaunchRuntime = Object.assign(Object.create(LaunchRuntime.prototype), { + accounts: { hostLaunchEnvFor: () => host } + }) + const accountHome = () => + resolveStructuredClaudeAccountHomePath({ + launchEnv: runtime.configuredEnvironment(), + wslDistro: null, + getClaudeConfigDirectory: () => '/fallback/home' + }) + expect(await accountHome()).toBe('/configured/home') + host = { CLAUDE_CONFIG_DIR: '/host/home' } + expect(runtime.configuredEnvironment()).toEqual({ CLAUDE_CONFIG_DIR: '/host/home' }) + expect(await accountHome()).toBe('/host/home') +}) diff --git a/src/main/runtime/agent-session/installation/runtime-structured-agent-host.ts b/src/main/runtime/agent-session/installation/runtime-structured-agent-host.ts index faea7c1089d..4bc70ab1968 100644 --- a/src/main/runtime/agent-session/installation/runtime-structured-agent-host.ts +++ b/src/main/runtime/agent-session/installation/runtime-structured-agent-host.ts @@ -8,6 +8,7 @@ import { structuredAgentConfiguredArgs } from '../../../native-chat/structured-a import { getProfileUserDataPath } from '../../../orca-profiles/profile-storage-paths' import { isTuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent-config' import { resolveTuiAgentLaunchEnv } from '../../../../shared/agent-launch/choices/tui-agent-launch-defaults' +import { overlayEnvironmentVariables } from '../../../../shared/environment-overlay' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { StructuredAgentId } from '../../../../shared/agent-session/lifecycle/agent-session-provider-handle' import type { RuntimeWorkspaceCatalog } from '../../workspaces/catalog/workspace-catalog' @@ -25,10 +26,11 @@ export abstract class RuntimeStructuredAgentHost extends OrcaRuntimeWithStartTui agent: StructuredAgentId, settings: Pick ) { - return { - ...resolveTuiAgentLaunchEnv(agent, settings), - ...this.accounts.hostLaunchEnvFor(agent) - } + return overlayEnvironmentVariables( + overlayEnvironmentVariables({}, resolveTuiAgentLaunchEnv(agent, settings), process.platform), + this.accounts.hostLaunchEnvFor(agent), + process.platform + ) } protected structuredAgentCatalogDependencies(): StructuredAgentModelCatalogDeps { diff --git a/src/main/runtime/agent-session/launch/structured-agent-launch-env-preflight.test.ts b/src/main/runtime/agent-session/launch/structured-agent-launch-env-preflight.test.ts new file mode 100644 index 00000000000..41906268caa --- /dev/null +++ b/src/main/runtime/agent-session/launch/structured-agent-launch-env-preflight.test.ts @@ -0,0 +1,21 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { resolveHostStructuredAgentLaunchEnvironment } from './structured-agent-launch-support' +import type * as Environment from '../environment/structured-agent-shell-environment' + +vi.mock('../environment/structured-agent-shell-environment', async (importOriginal) => ({ + ...(await importOriginal()), + resolveHostAgentBaseEnvironment: async () => ({ + TOKEN: 'inherited', + PATH: '/inherited/bin', + KEEP: 'keep' + }) +})) +afterEach(() => vi.unstubAllGlobals()) + +it('uses the configured Windows environment for launch preflight', async () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }) + const env = await resolveHostStructuredAgentLaunchEnvironment('claude', { + agentDefaultEnv: { claude: { tOKEN: 'configured', path: '/configured/bin' } } + }) + expect(env).toEqual({ TOKEN: 'configured', PATH: '/configured/bin', KEEP: 'keep' }) +}) diff --git a/src/main/runtime/agent-session/launch/structured-agent-launch-support.ts b/src/main/runtime/agent-session/launch/structured-agent-launch-support.ts index 16e51d3da17..d6c402cb949 100644 --- a/src/main/runtime/agent-session/launch/structured-agent-launch-support.ts +++ b/src/main/runtime/agent-session/launch/structured-agent-launch-support.ts @@ -1,6 +1,7 @@ import type { AgentSessionExecutionLocation } from '../../../../shared/agent-session/lifecycle/agent-session-record' import type { StructuredAgentId } from '../../../../shared/agent-session/lifecycle/agent-session-provider-handle' import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { overlayEnvironmentVariables } from '../../../../shared/environment-overlay' import { isTuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent-config' import { resolveTuiAgentLaunchEnv, @@ -101,10 +102,11 @@ export async function resolveHostStructuredAgentLaunchEnvironment( agent: string, settings: LaunchEnvironmentSettings ): Promise> { - return { - ...(await resolveHostAgentBaseEnvironment(settings)), - ...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings) : {}) - } + return overlayEnvironmentVariables( + await resolveHostAgentBaseEnvironment(settings), + isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings) : undefined, + process.platform + ) } /** What a chat start of `agent` on this host launches from, for a child that serves no chat. */ diff --git a/src/main/runtime/agent-session/naming/structured-chat-naming-agent-launch.test.ts b/src/main/runtime/agent-session/naming/structured-chat-naming-agent-launch.test.ts new file mode 100644 index 00000000000..4592b0590c1 --- /dev/null +++ b/src/main/runtime/agent-session/naming/structured-chat-naming-agent-launch.test.ts @@ -0,0 +1,27 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../../../shared/agent-session/lifecycle/agent-session-record.test-fixture' +import { chatNamingAgentTarget } from './structured-chat-naming-agent-launch' + +afterEach(() => vi.unstubAllGlobals()) + +it('reapplies configured Windows naming variables and keeps the recorded account pin', async () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }) + const target = await chatNamingAgentTarget( + agentSessionRecordFixture(), + '/workspace', + async () => ({ + TOKEN: 'inherited', + PATH: '/inherited/bin', + CLAUDE_CONFIG_DIR: '/ambient/home' + }), + { agentDefaultEnv: { claude: { tOKEN: 'configured', path: '/configured/bin' } } } + ) + if (target.kind !== 'local') { + throw new Error('Naming did not resolve a local launch') + } + expect(target.env).toEqual({ + TOKEN: 'configured', + PATH: '/configured/bin', + CLAUDE_CONFIG_DIR: '/home/user/.claude' + }) +}) diff --git a/src/main/runtime/agent-session/naming/structured-chat-naming-agent-launch.ts b/src/main/runtime/agent-session/naming/structured-chat-naming-agent-launch.ts index 56c13c34fd8..24f0049e695 100644 --- a/src/main/runtime/agent-session/naming/structured-chat-naming-agent-launch.ts +++ b/src/main/runtime/agent-session/naming/structured-chat-naming-agent-launch.ts @@ -1,6 +1,7 @@ import type { AgentSessionRecord } from '../../../../shared/agent-session/lifecycle/agent-session-record' import { requireLegacyAgentSessionAccountHome } from '../../../../shared/agent-session/lifecycle/agent-session-account-home' import { resolveTuiAgentLaunchEnv } from '../../../../shared/agent-launch/choices/tui-agent-launch-defaults' +import { overlayEnvironmentVariables } from '../../../../shared/environment-overlay' import { acpLaunchSpecFor } from '../../../acp/launch/acp-launch-registry' import { getClaudeProfileRouter } from '../../../claude-accounts/claude-profile-installed-router' import { @@ -30,10 +31,11 @@ export async function chatNamingAgentTarget( } const home = requireLegacyAgentSessionAccountHome(record.accountHome) if (record.provider === 'claude') { - env = { - ...withoutInheritedClaudeConfigDir(env), - ...resolveTuiAgentLaunchEnv('claude', settings) - } + env = overlayEnvironmentVariables( + withoutInheritedClaudeConfigDir(env), + resolveTuiAgentLaunchEnv('claude', settings), + process.platform + ) const path = await resolveClaudeStructuredLaunchHome(getClaudeProfileRouter(), env, home.path) const patch = claudeConfigDirEnvPatch(path, { env }) return { kind: 'local', cwd, env: { CLAUDE_CONFIG_DIR: undefined, ...env, ...patch } } diff --git a/src/shared/environment-overlay.test.ts b/src/shared/environment-overlay.test.ts new file mode 100644 index 00000000000..fa0522547c7 --- /dev/null +++ b/src/shared/environment-overlay.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest' +import { overlayEnvironmentVariables } from './environment-overlay' + +it('replaces Windows aliases in configured order while preserving untouched inherited names', () => { + const base = { TOKEN: 'old', Token: 'also old', PATH: 'first', Path: 'second', KEEP: 'keep' } + const configured = { token: 'first override', tOKEN: '', Custom: 'configured' } + expect(overlayEnvironmentVariables(base, configured, 'win32')).toEqual({ + PATH: 'first', + Path: 'second', + KEEP: 'keep', + TOKEN: '', + CUSTOM: 'configured' + }) + expect(Object.entries(base)).toEqual([ + ['TOKEN', 'old'], + ['Token', 'also old'], + ['PATH', 'first'], + ['Path', 'second'], + ['KEEP', 'keep'] + ]) + expect(configured).toEqual({ token: 'first override', tOKEN: '', Custom: 'configured' }) + expect(overlayEnvironmentVariables(base, undefined, 'win32')).toEqual(base) + expect(overlayEnvironmentVariables({ TOKEN: 'old' }, { token: 'new' }, 'linux')).toEqual({ + TOKEN: 'old', + token: 'new' + }) +}) diff --git a/src/shared/environment-overlay.ts b/src/shared/environment-overlay.ts new file mode 100644 index 00000000000..29ef2a637ce --- /dev/null +++ b/src/shared/environment-overlay.ts @@ -0,0 +1,22 @@ +/** Windows children select the first sorted alias, so configured names must replace every alias. */ +export function overlayEnvironmentVariables( + base: Readonly>, + overrides: Readonly> | undefined, + platform: NodeJS.Platform +): Record { + if (platform !== 'win32') { + return { ...base, ...overrides } + } + const normalized: Record = {} + for (const [key, value] of Object.entries(overrides ?? {})) { + normalized[key.toUpperCase()] = value + } + const overlaid = new Set(Object.keys(normalized)) + const merged = { ...base } + for (const key of Object.keys(merged)) { + if (overlaid.has(key.toUpperCase())) { + delete merged[key] + } + } + return { ...merged, ...normalized } +}