mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
co-authored by
김태겸
Vitalii Kulish
parent
55fb5584c0
commit
bfa941aa06
@@ -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 } : {})
|
||||
})
|
||||
|
||||
+26
@@ -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)
|
||||
})
|
||||
+20
-2
@@ -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 } }
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user