fix(native-chat): honor Windows environment overrides consistently (#40301)

* fix(native-chat): apply Windows environment overrides consistently

Use the same case-aware composition for native chat launches, preflight,
model discovery and naming. Canonicalize configured Windows names before
account readers and preserve account pins and POSIX case distinctions.

Tracked in #39075.

Co-authored-by: 김태겸 <oharapass@gmail.com>
Co-authored-by: Vitalii Kulish <mancunianez@gmail.com>

* test(native-chat): cover effective Windows catalog changes

* test(native-chat): retain child environment test import after sync

* test(native-chat): verify Windows child environment precedence

---------

Co-authored-by: 김태겸 <oharapass@gmail.com>
Co-authored-by: Vitalii Kulish <mancunianez@gmail.com>
This commit is contained in:
Brennan Benson
2026-11-09 12:04:26 -08:00
committed by GitHub
co-authored by 김태겸 Vitalii Kulish
parent 55fb5584c0
commit bfa941aa06
18 changed files with 368 additions and 55 deletions
@@ -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',
@@ -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' })
})
@@ -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<string, string> = spec.account.environment(accountHome, {
...base,
...deps.resolveLaunchEnv?.(spec.agent)
})
const env: Record<string, string> = 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?.()
@@ -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')
}
)
@@ -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<string, string>): Record<string, string> {
const next: Record<string, string> = {}
@@ -54,7 +55,8 @@ export function resolveClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise<Claud
() => 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<Claud
async function readClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise<ClaudeChildEnvSources> {
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<string, string>): Record<string, string> =>
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.
@@ -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",
@@ -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 } : {})
})
@@ -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)
})
@@ -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<string, string>) {
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({
@@ -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<string, string> {
return defined
}
/** Overlays shell variables on Orca's env; win32 names are case-insensitive, so drop the base spelling. */
function overlayShellVariables(
base: Record<string, string>,
shellVariables: Record<string, string>,
platform: NodeJS.Platform
): Record<string, string> {
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
)
}
}
@@ -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<string, string> = {}
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')
})
@@ -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<GlobalSettings, 'agentDefaultEnv'>
) {
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 {
@@ -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<typeof Environment>()),
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' })
})
@@ -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<Record<string, string>> {
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. */
@@ -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'
})
})
@@ -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 } }
+27
View File
@@ -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'
})
})
+22
View File
@@ -0,0 +1,22 @@
/** Windows children select the first sorted alias, so configured names must replace every alias. */
export function overlayEnvironmentVariables<T extends string | undefined>(
base: Readonly<Record<string, T>>,
overrides: Readonly<Record<string, T>> | undefined,
platform: NodeJS.Platform
): Record<string, T> {
if (platform !== 'win32') {
return { ...base, ...overrides }
}
const normalized: Record<string, T> = {}
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 }
}