diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 3c65796b250..9f9bbae0e43 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -160,7 +160,13 @@ export function buildPtyHostEnv( if (shouldPrepareOmpShadow) { const ompEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingOmpAgentDir, 'omp', { - materializeDefaultHome: explicitPiAgentKind === 'omp' + materializeDefaultHome: explicitPiAgentKind === 'omp', + // WSL loads the host-rooted managed extension through drvfs; guest storage stays separate. + ...(opts.isWsl + ? { configDirName: '.omp' } + : baseEnv.PI_CONFIG_DIR !== undefined + ? { configDirName: baseEnv.PI_CONFIG_DIR } + : {}) }) Object.assign(baseEnv, ompEnv) exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv) diff --git a/src/main/ipc/pty/host-env/omp-launch-environment.test.ts b/src/main/ipc/pty/host-env/omp-launch-environment.test.ts new file mode 100644 index 00000000000..2eeeadc2c56 --- /dev/null +++ b/src/main/ipc/pty/host-env/omp-launch-environment.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { inheritOmpLaunchEnvironment } from './omp-launch-environment' +import { resolveLoginShellEnvironment } from '../../../startup/login-shell-environment' + +vi.mock('../../../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: vi.fn() +})) + +beforeEach(() => { + vi.stubGlobal('process', { ...process, platform: 'darwin', env: {} }) + vi.mocked(resolveLoginShellEnvironment).mockReset().mockResolvedValue({ + XDG_DATA_HOME: '/login/data', + XDG_STATE_HOME: '/login/state', + XDG_CACHE_HOME: '/login/cache', + PI_CONFIG_DIR: '.config/omp', + PI_CODING_AGENT_DIR: '/pi/override', + UNRELATED: 'ignore' + }) +}) +afterEach(() => vi.unstubAllGlobals()) + +describe('OMP launch directory environment', () => { + it.each([{ launchAgent: 'omp' }, { launchCommand: 'omp' }, {}])( + 'inherits login-shell category roots for %j', + async (options) => { + const env = {} + await inheritOmpLaunchEnvironment(env, options) + expect(env).toEqual({ + XDG_DATA_HOME: '/login/data', + XDG_STATE_HOME: '/login/state', + XDG_CACHE_HOME: '/login/cache', + PI_CONFIG_DIR: '.config/omp' + }) + } + ) + + it('uses the same login roots for merged local env and daemon pane deltas', async () => { + const local = { XDG_DATA_HOME: '/old/data', PI_CONFIG_DIR: '.old-omp' } + const daemon = {} + await inheritOmpLaunchEnvironment(local, { launchAgent: 'omp', explicitEnv: {} }) + await inheritOmpLaunchEnvironment(daemon, { launchAgent: 'omp' }) + expect(local).toEqual(daemon) + expect(local.XDG_DATA_HOME).toBe('/login/data') + const overridden = { XDG_DATA_HOME: '/old/data' } + await inheritOmpLaunchEnvironment(overridden, { + launchAgent: 'omp', + explicitEnv: { XDG_DATA_HOME: '/pane/data' } + }) + expect(overridden.XDG_DATA_HOME).toBe('/pane/data') + }) + + it('preserves explicit pane roots and intentionally empty XDG values', async () => { + const env = { XDG_DATA_HOME: '/pane/data', XDG_STATE_HOME: '' } + await inheritOmpLaunchEnvironment(env, { launchAgent: 'omp' }) + expect(env.XDG_DATA_HOME).toBe('/pane/data') + expect(env.XDG_STATE_HOME).toBe('') + }) + + it('keeps an explicitly empty OMP root at the default through profile fallback', async () => { + const env = { PI_CONFIG_DIR: '' } + await inheritOmpLaunchEnvironment(env, { launchAgent: 'omp' }) + expect(env.PI_CONFIG_DIR).toBe('.omp') + }) + + it.each([ + { isWsl: true, launchAgent: 'omp' }, + { launchAgent: 'pi' }, + { launchAgent: 'claude' }, + { launchCommand: 'pi' }, + { launchCommand: 'npm test' } + ])('does not probe a different execution environment for %j', async (options) => { + const env = {} + await inheritOmpLaunchEnvironment(env, options) + expect(env).toEqual({}) + expect(resolveLoginShellEnvironment).not.toHaveBeenCalled() + }) + + it('imports explicit WSL roots without importing ambient Windows roots', async () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }) + const env = { PI_CONFIG_DIR: '.host-root', XDG_DATA_HOME: 'C:/host/data', WSLENV: 'KEEP/u' } + await inheritOmpLaunchEnvironment(env, { + isWsl: true, + launchAgent: 'omp', + explicitEnv: { PI_CONFIG_DIR: '.guest-root', XDG_CACHE_HOME: '/tmp/guest-cache' } + }) + expect(env.PI_CONFIG_DIR).toBe('.guest-root') + expect(env.WSLENV.split(':')).toEqual(['KEEP/u', 'XDG_CACHE_HOME', 'PI_CONFIG_DIR']) + expect(resolveLoginShellEnvironment).not.toHaveBeenCalled() + }) + + it('canonicalizes an explicitly empty WSL config root to the OMP default', async () => { + const env = { PI_CONFIG_DIR: '' } + await inheritOmpLaunchEnvironment(env, { isWsl: true, launchAgent: 'omp' }) + expect(env).toEqual({ PI_CONFIG_DIR: '.omp', WSLENV: 'PI_CONFIG_DIR' }) + expect(resolveLoginShellEnvironment).not.toHaveBeenCalled() + }) + + it('does not import POSIX roots into native Windows', async () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }) + await inheritOmpLaunchEnvironment({}, { launchAgent: 'omp' }) + expect(resolveLoginShellEnvironment).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/host-env/omp-launch-environment.ts b/src/main/ipc/pty/host-env/omp-launch-environment.ts new file mode 100644 index 00000000000..420957aab60 --- /dev/null +++ b/src/main/ipc/pty/host-env/omp-launch-environment.ts @@ -0,0 +1,55 @@ +import { addWslEnvKeys } from '../../../../shared/wsl-env' +import { detectExplicitPiAgentKindFromCommand } from '../../../../shared/pi-agent-kind' +import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' +import { resolveLoginShellEnvironment } from '../../../startup/login-shell-environment' + +const OMP_DIRECTORY_ENV_KEYS = [ + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_STATE_HOME', + 'XDG_CACHE_HOME', + 'PI_CONFIG_DIR' +] as const + +export async function inheritOmpLaunchEnvironment( + env: Record, + options: { + shellPath?: string + isWsl?: boolean + launchAgent?: string + launchCommand?: string + explicitEnv?: Record + } +): Promise { + if (options.isWsl) { + const explicitEnv = options.explicitEnv ?? env + const keys = OMP_DIRECTORY_ENV_KEYS.filter((key) => explicitEnv[key] !== undefined) + if (keys.length > 0) { + // WSL drops pane-provided config roots unless their names cross in WSLENV. + for (const key of keys) { + env[key] = key === 'PI_CONFIG_DIR' && explicitEnv[key] === '' ? '.omp' : explicitEnv[key] + } + addWslEnvKeys(env, keys) + } + return + } + if (process.platform === 'win32') { + return + } + const command = resolveSetupAgentSequenceLaunchCommand(env, options.launchCommand) + const agent = options.launchAgent ?? detectExplicitPiAgentKindFromCommand(command) + if (agent !== 'omp' && (options.launchAgent !== undefined || command?.trim())) { + return + } + const shellPath = + options.shellPath || (options.explicitEnv ?? env).SHELL || process.env.SHELL || '/bin/zsh' + const shellEnv = await resolveLoginShellEnvironment({ shellOverride: shellPath }) + for (const key of OMP_DIRECTORY_ENV_KEYS) { + // Explicit pane values take precedence over the login shell. + const value = (options.explicitEnv ?? env)[key] ?? shellEnv[key] ?? process.env[key] + if (value !== undefined) { + // OMP maps an empty config name to .omp; spell it out before profile defaults run. + env[key] = key === 'PI_CONFIG_DIR' && value === '' ? '.omp' : value + } + } +} diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index b01dfa997e2..e17e21cf5cd 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -1,3 +1,4 @@ +import { inheritOmpLaunchEnvironment } from '../host-env/omp-launch-environment' import { getAppEnvironment } from '../../../../shared/app-environment' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' @@ -133,6 +134,11 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis // Why: clone before mutating so injections don't leak back into args.env (renderer may reuse it). ctx.env = { ...ctx.baseEnv } try { + await inheritOmpLaunchEnvironment(ctx.env, { + isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd), + launchAgent: args.launchAgent, + launchCommand: ctx.launchCommand + }) buildPtyHostEnv(sessionIdForEnv, ctx.env, { isPackaged: getAppEnvironment().isPackaged(), resourcesPath: process.resourcesPath, diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index 68f3b7c0723..fa619328b9c 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -1,3 +1,4 @@ +import { inheritOmpLaunchEnvironment } from '../host-env/omp-launch-environment' import { getAppEnvironment } from '../../../../shared/app-environment' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { GlobalSettings } from '../../../../shared/global-settings-types' @@ -55,6 +56,13 @@ export function configureLocalPtyProvider(args: { ) const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath const ptySettings = getSettings?.() + await inheritOmpLaunchEnvironment(baseEnv, { + shellPath: ctx?.shellPath, + explicitEnv: ctx?.explicitEnv, + isWsl: ctx?.isWsl, + launchAgent: ctx?.launchAgent, + launchCommand: ctx?.command + }) const env = buildPtyHostEnv(id, baseEnv, { isPackaged: getAppEnvironment().isPackaged(), resourcesPath: process.resourcesPath, diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index a37ede4b3e8..1209730ed6d 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -1,3 +1,4 @@ +import { inheritOmpLaunchEnvironment } from '../host-env/omp-launch-environment' import { getAppEnvironment } from '../../../../shared/app-environment' import type { PtySpawnResult } from '../../../providers/types' import { LocalPtyProvider } from '../../../providers/local-pty-provider' @@ -258,6 +259,12 @@ export async function prepareRuntimePtySpawn( throw new Error('Invalid PTY session id') } try { + ctx.env ??= {} + await inheritOmpLaunchEnvironment(ctx.env, { + isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd), + launchAgent: args.launchAgent, + launchCommand: ctx.launchCommand + }) ctx.env = buildPtyHostEnv(ctx.sessionId, ctx.env ?? {}, { isPackaged: getAppEnvironment().isPackaged(), resourcesPath: process.resourcesPath, diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index 69884d6591b..3c3347b3b4f 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -98,6 +98,76 @@ describe('PiTitlebarExtensionService', () => { rmSync(join(userDataDir, 'omp-managed-status-extension'), { recursive: true, force: true }) }) + it.each([ + ['pi', '.pi', 'ORCA_PI_SOURCE_AGENT_DIR'], + ['omp', '.omp', 'ORCA_OMP_SOURCE_AGENT_DIR'], + ['prime-agent', '.prime', 'ORCA_PRIME_AGENT_SOURCE_AGENT_DIR'] + ] as const)('does not reinterpret XDG data as %s configuration', (kind, root, sourceKey) => { + const fakeHome = mkdtempSync(join(tmpdir(), 'orca-agent-xdg-')) + const dataHome = join(fakeHome, 'data') + const dataAgentDir = join(dataHome, root.slice(1), 'agent') + mkdirSync(dataAgentDir, { recursive: true }) + homedirOverride.current = fakeHome + vi.stubEnv('XDG_DATA_HOME', dataHome) + try { + const env = new PiTitlebarExtensionService().buildPtyEnv('pty-xdg', undefined, kind) + expect(env[sourceKey]).toBe(join(fakeHome, root, 'agent')) + expect(readdirSync(dataAgentDir)).toEqual([]) + expect(env.PI_CODING_AGENT_DIR).toBeUndefined() + } finally { + vi.unstubAllEnvs() + homedirOverride.current = '' + rmSync(fakeHome, { recursive: true, force: true }) + } + }) + + it('materializes OMP extensions under the launch config root without overriding data routing', () => { + const fakeHome = mkdtempSync(join(tmpdir(), 'orca-omp-config-')) + homedirOverride.current = fakeHome + try { + const service = new PiTitlebarExtensionService() + const environment = { PI_CONFIG_DIR: '.config/omp', XDG_DATA_HOME: join(fakeHome, 'data') } + const env = service.buildPtyEnv('pty-config', undefined, 'omp', { + configDirName: environment.PI_CONFIG_DIR + }) + const agentDir = join(fakeHome, '.config', 'omp', 'agent') + expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe(agentDir) + expect(existsSync(join(agentDir, 'extensions', 'orca-agent-status.ts'))).toBe(true) + expect(env.PI_CODING_AGENT_DIR).toBeUndefined() + expect(existsSync(join(fakeHome, '.omp'))).toBe(false) + expect(existsSync(environment.XDG_DATA_HOME)).toBe(false) + expect( + service.buildPtyEnv('pty-explicit', piHome, 'omp', { + configDirName: environment.PI_CONFIG_DIR + }).ORCA_OMP_SOURCE_AGENT_DIR + ).toBe(piHome) + expect( + service.buildPtyEnv('pty-pi', undefined, 'pi', { configDirName: environment.PI_CONFIG_DIR }) + .ORCA_PI_SOURCE_AGENT_DIR + ).toBe(join(fakeHome, '.pi', 'agent')) + } finally { + homedirOverride.current = '' + rmSync(fakeHome, { recursive: true, force: true }) + } + }) + + it('does not use the daemon ambient PI_CONFIG_DIR when launch root is omitted', () => { + const fakeHome = mkdtempSync(join(tmpdir(), 'orca-omp-ambient-root-')) + homedirOverride.current = fakeHome + vi.stubEnv('PI_CONFIG_DIR', 'host-profile') + try { + const env = new PiTitlebarExtensionService().buildPtyEnv('pty-ambient-root', undefined, 'omp', { + materializeDefaultHome: true + }) + expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe(join(fakeHome, '.omp', 'agent')) + expect(existsSync(join(fakeHome, 'host-profile'))).toBe(false) + } finally { + vi.unstubAllEnvs() + homedirOverride.current = '' + rmSync(fakeHome, { recursive: true, force: true }) + } + }) + function expectPiHomeIntact(): void { expect(readFileSync(join(piHome, 'auth.json'), 'utf-8')).toBe('secret token') expect(readFileSync(join(piHome, 'skills', 'my-skill', 'SKILL.md'), 'utf-8')).toBe( diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 8a093096f4e..0209c6232f5 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -58,8 +58,9 @@ const AGENT_HOME_DIR_NAME: Record = { 'prime-agent': '.prime' } -function getDefaultPiAgentDir(kind: PiAgentKind): string { - return join(homedir(), AGENT_HOME_DIR_NAME[kind], PI_AGENT_SUBDIR) +function getDefaultPiAgentDir(kind: PiAgentKind, configDirName: string | undefined): string { + const root = kind === 'omp' ? configDirName || AGENT_HOME_DIR_NAME.omp : AGENT_HOME_DIR_NAME[kind] + return join(homedir(), root, PI_AGENT_SUBDIR) } function toSafeOverlayDirName(ptyId: string): string { @@ -177,9 +178,12 @@ export class PiTitlebarExtensionService { ptyId: string, existingAgentDir: string | undefined, kind: PiAgentKind, - options?: { materializeDefaultHome?: boolean } + options?: { materializeDefaultHome?: boolean; configDirName?: string } ): Record { - const sourceAgentDir = existingAgentDir || getDefaultPiAgentDir(kind) + // The caller resolves the effective launch environment. Reading the + // daemon's ambient PI_CONFIG_DIR here can select the host profile for a + // guest/WSL launch whose environment has not been hydrated yet. + const sourceAgentDir = existingAgentDir || getDefaultPiAgentDir(kind, options?.configDirName) if (kind !== 'prime-agent') { try { this.safeRemoveOverlay(this.getPtyOverlayDir(ptyId, kind), kind) diff --git a/src/main/providers/local-pty-provider-spawn-env.test.ts b/src/main/providers/local-pty-provider-spawn-env.test.ts index 8dc45d40aa8..daf6286b56d 100644 --- a/src/main/providers/local-pty-provider-spawn-env.test.ts +++ b/src/main/providers/local-pty-provider-spawn-env.test.ts @@ -179,6 +179,18 @@ describe('LocalPtyProvider', () => { ) }) + it('passes explicit pane environment separately from inherited process values', async () => { + const buildSpawnEnv = vi.fn((_id: string, env: Record) => env) + provider.configure({ buildSpawnEnv }) + const env = { XDG_DATA_HOME: '/pane/data' } + await provider.spawn({ cols: 80, rows: 24, env }) + expect(buildSpawnEnv).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ explicitEnv: env }) + ) + }) + it('invokes buildSpawnEnv callback to customize environment', async () => { const buildSpawnEnv = vi.fn((_id: string, env: Record) => { env.CUSTOM_VAR = 'custom-value' diff --git a/src/main/providers/local-pty-provider-types.ts b/src/main/providers/local-pty-provider-types.ts index 0bebff54237..ebe90bbb049 100644 --- a/src/main/providers/local-pty-provider-types.ts +++ b/src/main/providers/local-pty-provider-types.ts @@ -7,6 +7,7 @@ export type LocalPtyProviderOptions = { id: string, baseEnv: Record, ctx?: { + explicitEnv?: PtySpawnOptions['env'] command?: string launchAgent?: PtySpawnOptions['launchAgent'] codexHomePathOverride?: PtySpawnOptions['codexHomePathOverride'] diff --git a/src/main/providers/local-pty-spawn-environment.ts b/src/main/providers/local-pty-spawn-environment.ts index 39b56c4d99f..2413dd12b10 100644 --- a/src/main/providers/local-pty-spawn-environment.ts +++ b/src/main/providers/local-pty-spawn-environment.ts @@ -61,6 +61,7 @@ export function buildLocalPtySpawnEnvironment(args: { return awaitCancelableLocalPtySpawn( id, getOptions().buildSpawnEnv!(id, spawnEnv, { + explicitEnv: spawn.env ?? {}, command: spawn.command, launchAgent: spawn.launchAgent, codexHomePathOverride: spawn.codexHomePathOverride, diff --git a/src/main/startup/login-shell-environment-cache.test.ts b/src/main/startup/login-shell-environment-cache.test.ts new file mode 100644 index 00000000000..972dc35a5d0 --- /dev/null +++ b/src/main/startup/login-shell-environment-cache.test.ts @@ -0,0 +1,70 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { + resetLoginShellEnvironmentCacheForTests, + resolveLoginShellEnvironment +} from './login-shell-environment' + +afterEach(resetLoginShellEnvironmentCacheForTests) + +it('shares concurrent probes only within the same shell and environment', async () => { + const release = Promise.withResolvers() + const spawner = vi.fn(async (_shell: string, env: NodeJS.ProcessEnv) => { + await release.promise + return { PI_CONFIG_DIR: env.PROFILE_ROOT } + }) + const first = { HOME: '/host/a', PROFILE_ROOT: '.first' } + const second = { HOME: '/host/a', PROFILE_ROOT: '.second' } + const pending = [ + resolveLoginShellEnvironment({ shellOverride: '/bin/bash', env: first, spawner }), + resolveLoginShellEnvironment({ shellOverride: '/bin/bash', env: second, spawner }), + ...Array.from({ length: 20 }, () => + resolveLoginShellEnvironment({ + shellOverride: '/bin/bash', + env: { PROFILE_ROOT: '.first', HOME: '/host/a' }, + spawner + }) + ) + ] + expect(spawner).toHaveBeenCalledTimes(2) + release.resolve() + const values = await Promise.all(pending) + expect(values[0]?.PI_CONFIG_DIR).toBe('.first') + expect(values[1]?.PI_CONFIG_DIR).toBe('.second') + expect(values.slice(2).every((value) => value.PI_CONFIG_DIR === '.first')).toBe(true) + await resolveLoginShellEnvironment({ shellOverride: '/bin/zsh', env: first, spawner }) + expect(spawner).toHaveBeenCalledTimes(3) +}) + +it('falls back to the supplied execution environment when its shell probe fails', async () => { + const env = { HOME: '/execution-host', PI_CONFIG_DIR: '.execution-root' } + const spawner = vi.fn(async () => { + throw new Error('probe failed') + }) + await expect( + resolveLoginShellEnvironment({ shellOverride: '/bin/bash', env, spawner }) + ).resolves.toEqual(env) +}) + +it('bounds retained environments and supports explicit refresh', async () => { + const spawner = vi.fn(async (_shell: string, env: NodeJS.ProcessEnv) => env) + for (let index = 0; index < 10; index++) { + await resolveLoginShellEnvironment({ + shellOverride: '/bin/bash', + env: { HOME: `/host/${index}` }, + spawner + }) + } + await resolveLoginShellEnvironment({ + shellOverride: '/bin/bash', + env: { HOME: '/host/0' }, + spawner + }) + expect(spawner).toHaveBeenCalledTimes(11) + await resolveLoginShellEnvironment({ + shellOverride: '/bin/bash', + env: { HOME: '/host/0' }, + spawner, + force: true + }) + expect(spawner).toHaveBeenCalledTimes(12) +}) diff --git a/src/main/startup/login-shell-environment.ts b/src/main/startup/login-shell-environment.ts index d456dff64c9..bc4730be7c0 100644 --- a/src/main/startup/login-shell-environment.ts +++ b/src/main/startup/login-shell-environment.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { win32 as pathWin32 } from 'node:path' import { spawnProcess } from '../../shared/child-process/run-process' import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell' @@ -10,12 +11,12 @@ const START_MARKER = '__ORCA_LOGIN_SHELL_ENV_START__' const END_MARKER = '__ORCA_LOGIN_SHELL_ENV_END__' const SPAWN_TIMEOUT_MS = 5000 -let cached: Promise | null = null -let cachedShellKey: string | null = null +const environmentCache = new Map>() +const MAX_CACHED_ENVIRONMENTS = 8 -function processEnvironment(): NodeJS.ProcessEnv { +function processEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { return Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined) ) } @@ -78,7 +79,10 @@ function parsePowerShellEnvironment(output: Buffer): NodeJS.ProcessEnv | null { } } -function spawnShellAndReadEnvironment(shell: string): Promise { +function spawnShellAndReadEnvironment( + shell: string, + env: NodeJS.ProcessEnv +): Promise { const args = shellProbe(shell) if (!args) { return Promise.resolve(null) @@ -86,7 +90,7 @@ function spawnShellAndReadEnvironment(shell: string): Promise { let settled = false const chunks: Buffer[] = [] - const child = spawnProcess({ program: shell, args, env: process.env }) + const child = spawnProcess({ program: shell, args, env }) const finish = (value: NodeJS.ProcessEnv | null): void => { if (settled) { return @@ -124,7 +128,8 @@ function spawnShellAndReadEnvironment(shell: string): Promise Promise + env?: NodeJS.ProcessEnv + spawner?: (shell: string, env: NodeJS.ProcessEnv) => Promise } /** Resolves the environment seen by commands launched from Orca's profile-loading terminal shell. */ @@ -134,27 +139,42 @@ export function resolveLoginShellEnvironment( const shell = options.shellOverride !== undefined ? options.shellOverride : resolveProfileLoadingShell() const fallback = options.shellOverride === undefined ? resolveProfileLoadingFallbackShell() : null - const shellKey = `${shell ?? ''}\0${fallback ?? ''}` - if (cached && cachedShellKey === shellKey && !options.force) { + const env = processEnvironment(options.env) + const envKey = + options.env === undefined + ? '' + : createHash('sha256') + .update(JSON.stringify(Object.entries(env).sort(([a], [b]) => a.localeCompare(b)))) + .digest('hex') + const shellKey = `${shell ?? ''}\0${fallback ?? ''}\0${envKey}` + const cached = environmentCache.get(shellKey) + if (cached && !options.force) { return cached } if (!shell) { - return Promise.resolve(processEnvironment()) + return Promise.resolve(env) } const spawner = options.spawner ?? spawnShellAndReadEnvironment - cachedShellKey = shellKey - cached = spawner(shell) + const pending = spawner(shell, env) .then(async (environment) => { if (environment) { return environment } - return fallback ? ((await spawner(fallback)) ?? processEnvironment()) : processEnvironment() + return fallback ? ((await spawner(fallback, env)) ?? env) : env }) - .catch(() => processEnvironment()) - return cached + .catch(() => env) + environmentCache.delete(shellKey) + while (environmentCache.size >= MAX_CACHED_ENVIRONMENTS) { + const oldest = environmentCache.keys().next().value + if (oldest === undefined) { + break + } + environmentCache.delete(oldest) + } + environmentCache.set(shellKey, pending) + return pending } export function resetLoginShellEnvironmentCacheForTests(): void { - cached = null - cachedShellKey = null + environmentCache.clear() } diff --git a/src/relay/omp-config-root.test.ts b/src/relay/omp-config-root.test.ts new file mode 100644 index 00000000000..a33ac667edc --- /dev/null +++ b/src/relay/omp-config-root.test.ts @@ -0,0 +1,89 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginOverlayManager } from './plugin-overlay' +import { resolveOmpConfigDirName, resolvePiSourceAgentDir } from './plugin-overlay-env' +import { __resetShellStartupEnvCache } from '../main/pty/shell-startup-env' +import { resetLoginShellEnvironmentCacheForTests } from '../main/startup/login-shell-environment' + +describe('relay OMP config root', () => { + let home: string + let manager: PluginOverlayManager + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-relay-omp-config-')) + manager = new PluginOverlayManager({ homeDir: home }) + manager.setSources({ ompExtensionSource: 'export default function() {}' }) + __resetShellStartupEnvCache() + resetLoginShellEnvironmentCacheForTests() + }) + + afterEach(() => { + vi.unstubAllEnvs() + __resetShellStartupEnvCache() + resetLoginShellEnvironmentCacheForTests() + rmSync(home, { recursive: true, force: true }) + }) + + it.each(['.company-omp', '.config/omp', ''])( + 'installs into the guest config root %j', + async (config) => { + const env = { HOME: home, PI_CONFIG_DIR: config } + const result = manager.materializePi( + 'pane', + resolvePiSourceAgentDir(env, '/bin/bash', 'omp'), + 'omp', + { + materializeDefaultHome: true, + configDirName: await resolveOmpConfigDirName(env, '/bin/bash') + } + ) + const expected = join(home, config || '.omp', 'agent') + expect(result?.sourceAgentDir).toBe(expected) + expect(readFileSync(join(expected, 'extensions', 'orca-agent-status.ts'), 'utf8')).toContain( + '@orca-managed-pi-extension' + ) + if (config) { + expect(existsSync(join(home, '.omp'))).toBe(false) + } + } + ) + + it('keeps an explicit source directory ahead of the config root', () => { + const source = join(home, 'explicit-agent') + mkdirSync(source) + const result = manager.materializePi('pane', source, 'omp', { configDirName: '.company-omp' }) + expect(result?.sourceAgentDir).toBe(source) + expect(existsSync(join(home, '.company-omp'))).toBe(false) + }) + + it('does not create a missing config root for a bare shell', () => { + const result = manager.materializePi('pane', undefined, 'omp', { + materializeDefaultHome: false, + configDirName: '.company-omp' + }) + expect(result?.sourceAgentDir).toBeUndefined() + expect(result?.statusExtensionPath).toBeTruthy() + expect(existsSync(join(home, '.company-omp'))).toBe(false) + }) + + it('does not fall back to process.env for the config root', async () => { + vi.stubEnv('PI_CONFIG_DIR', '.wrong-process-root') + expect(await resolveOmpConfigDirName({ HOME: home }, '/bin/bash')).toBeUndefined() + }) + + it.skipIf(process.platform === 'win32')( + 'uses the guest profile only when no pane override exists', + async () => { + writeFileSync(join(home, '.bash_profile'), 'export PI_CONFIG_DIR=".profile-omp"\n') + expect(await resolveOmpConfigDirName({ HOME: home }, '/bin/bash')).toBe('.profile-omp') + expect(await resolveOmpConfigDirName({ HOME: home, PI_CONFIG_DIR: '' }, '/bin/bash')).toBe( + '.omp' + ) + expect( + await resolveOmpConfigDirName({ HOME: home, PI_CONFIG_DIR: '.pane-omp' }, '/bin/bash') + ).toBe('.pane-omp') + } + ) +}) diff --git a/src/relay/plugin-overlay-env.ts b/src/relay/plugin-overlay-env.ts index 1db43f4717e..6ca67361865 100644 --- a/src/relay/plugin-overlay-env.ts +++ b/src/relay/plugin-overlay-env.ts @@ -1,3 +1,4 @@ +import { resolveLoginShellEnvironment } from '../main/startup/login-shell-environment' import { readSessionShellStartupEnvVar } from '../main/pty/shell-startup-env' import { PRIMARY_AGENT_DIR_ENV_BY_KIND, @@ -67,3 +68,17 @@ export function resolvePiSourceAgentDir( } return undefined } + +export async function resolveOmpConfigDirName( + env: Record, + shell: string | undefined +): Promise { + if (env.PI_CONFIG_DIR !== undefined) { + return env.PI_CONFIG_DIR || '.omp' + } + const profile = await resolveLoginShellEnvironment({ + shellOverride: shell ?? env.SHELL ?? null, + env + }) + return profile.PI_CONFIG_DIR === '' ? '.omp' : profile.PI_CONFIG_DIR +} diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index ffb9116beae..1303b5ac231 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -243,8 +243,10 @@ export class PluginOverlayManager { } } - private getDefaultPiAgentDir(kind: PiAgentKind): string { - return join(this.homeDir, PI_AGENT_HOME_DIR_NAME[kind], PI_AGENT_SUBDIR) + private getDefaultPiAgentDir(kind: PiAgentKind, configDirName?: string): string { + const root = + kind === 'omp' ? configDirName || PI_AGENT_HOME_DIR_NAME.omp : PI_AGENT_HOME_DIR_NAME[kind] + return join(this.homeDir, root, PI_AGENT_SUBDIR) } private canOverwritePiExtension(path: string): boolean { @@ -285,14 +287,15 @@ export class PluginOverlayManager { id: string, existingAgentDir?: string, kind: PiAgentKind = 'pi', - options?: { materializeDefaultHome?: boolean } + options?: { materializeDefaultHome?: boolean; configDirName?: string } ): MaterializePiResult | null { const extensionSource = this.getPiExtensionSource(kind) if (!extensionSource || !isUsableId(id)) { return null } try { - const sourceAgentDir = existingAgentDir ?? this.getDefaultPiAgentDir(kind) + const sourceAgentDir = + existingAgentDir ?? this.getDefaultPiAgentDir(kind, options?.configDirName) if (existingAgentDir && !existsSync(existingAgentDir)) { return null } diff --git a/src/relay/pty-handler-spawn-environment.test.ts b/src/relay/pty-handler-spawn-environment.test.ts index ca1873a7c66..eff0f68f2fa 100644 --- a/src/relay/pty-handler-spawn-environment.test.ts +++ b/src/relay/pty-handler-spawn-environment.test.ts @@ -523,6 +523,63 @@ describe('PtyHandler', () => { expect(mockPtySpawn.mock.calls[0][2].env.ORCA_IMAGE_PROTOCOL).toBe('kitty') }) + it('waits for execution-host environment resolution before spawning', async () => { + const entered = Promise.withResolvers() + const resolved = Promise.withResolvers>() + handler.addEnvAugmenter(() => { + entered.resolve() + return resolved.promise + }) + const spawning = dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) + await entered.promise + expect(mockPtySpawn).not.toHaveBeenCalled() + resolved.resolve({ PI_CONFIG_DIR: '.evaluated-profile' }) + await spawning + expect(mockPtySpawn.mock.calls[0]?.[2]?.env.PI_CONFIG_DIR).toBe('.evaluated-profile') + }) + + it('does not spawn when canceled during environment resolution', async () => { + const entered = Promise.withResolvers() + const resolved = Promise.withResolvers>() + handler.addEnvAugmenter(() => { + entered.resolve() + return resolved.promise + }) + const abort = new AbortController() + const spawning = dispatcher.callRequest( + 'pty.spawn', + { cols: 80, rows: 24 }, + { + signal: abort.signal, + isStale: () => abort.signal.aborted + } + ) + const rejected = expect(spawning).rejects.toThrow('client_disconnected') + await entered.promise + abort.abort() + resolved.resolve({ PI_CONFIG_DIR: '.evaluated-profile' }) + await rejected + expect(mockPtySpawn).not.toHaveBeenCalled() + expect(handler.activePtyCount).toBe(0) + }) + + it('disposes a creation already awaiting its execution environment', async () => { + const entered = Promise.withResolvers() + const resolved = Promise.withResolvers>() + handler.addEnvAugmenter(() => { + entered.resolve() + return resolved.promise + }) + const spawning = dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) + await entered.promise + const disposal = handler.dispose({ waitForPhysicalExit: false }) + expect(mockPtySpawn).not.toHaveBeenCalled() + resolved.resolve({ PI_CONFIG_DIR: '.evaluated-profile' }) + await spawning + await disposal + expect(mockPtyInstance.kill).toHaveBeenCalled() + expect(handler.activePtyCount).toBe(0) + }) it('applies env augmenters after process.env and renderer-supplied env (augmenter wins on key conflict)', async () => { handler.addEnvAugmenter(() => ({ ORCA_AGENT_HOOK_PORT: '12345', diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index f6084366988..7e19d1f1306 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -491,7 +491,7 @@ export type PtyEnvAugmenter = (ctx: { env: Record command?: string launchAgent?: TuiAgent -}) => Record +}) => Record | Promise> export type RelayPtyWorktreeRemovalCoordinator = { beginWorktreePtySpawn(operationPath: string): () => void @@ -783,7 +783,7 @@ export class PtyHandler { } /** Build augmented spawn env; augmenter values win over process.env/renderer env. Shared by spawn()/revive() so precedence can't drift. */ - private buildSpawnEnv( + private async buildSpawnEnv( rendererEnv: Record | undefined, ctx: { id: string @@ -793,7 +793,7 @@ export class PtyHandler { launchAgent?: TuiAgent }, envToDelete: readonly string[] = [] - ): Record { + ): Promise> { const baseEnv = mergeGitConfigEnvProtocol( { ...stripInheritedBuildModeEnv(process.env), @@ -809,7 +809,7 @@ export class PtyHandler { const augmented: Record = {} for (const augmenter of this.envAugmenters) { try { - Object.assign(augmented, augmenter({ ...ctx, env: baseEnv })) + Object.assign(augmented, await augmenter({ ...ctx, env: baseEnv })) } catch (err) { process.stderr.write( `[pty-handler] env augmenter threw: ${err instanceof Error ? err.message : String(err)}\n` @@ -1876,7 +1876,7 @@ export class PtyHandler { typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer' const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined - const spawnEnv = this.buildSpawnEnv( + const spawnEnv = await this.buildSpawnEnv( env, { id, paneKey, shell, command, launchAgent }, envToDelete @@ -3020,7 +3020,7 @@ export class PtyHandler { ? entry.terminalWindowsWslDistro : null const historyIsolationEnabled = entry.historyIsolationEnabled === true - const spawnEnv = this.buildSpawnEnv( + const spawnEnv = await this.buildSpawnEnv( revivedEnv, { id: entry.id, paneKey: entry.paneKey, shell }, envToDelete diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 0ca7111ae6b..71402586b86 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -9,7 +9,11 @@ import { } from '../shared/agent-hook-relay' import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' import { assertPluginSourceUnderByteCap } from './plugin-source-limit' -import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' +import { + resolveOpenCodeSourceConfigDir, + resolvePiSourceAgentDir, + resolveOmpConfigDirName +} from './plugin-overlay-env' import { detectExplicitPiAgentKindFromCommand, isPiCompatibleAgentType @@ -74,7 +78,9 @@ export class RelayAgentHookRuntime { }) } - private buildPluginEnvironment(context: Parameters[0]): Record { + private async buildPluginEnvironment( + context: Parameters[0] + ): Promise> { const env: Record = {} const overlayId = context.paneKey ?? context.id if (this.pluginOverlay.hasOpenCodeSource()) { @@ -114,8 +120,13 @@ export class RelayAgentHookRuntime { kind === 'omp' ? resolvePiSourceAgentDir(context.env, context.shell, 'omp') : context.env.ORCA_OMP_SOURCE_AGENT_DIR + const configDirName = await resolveOmpConfigDirName(context.env, context.shell) + if (configDirName !== undefined) { + env.PI_CONFIG_DIR = configDirName + } const result = this.pluginOverlay.materializePi(overlayId, sourceDir, 'omp', { - materializeDefaultHome: explicitKind === 'omp' + materializeDefaultHome: explicitKind === 'omp', + configDirName }) if (result?.statusExtensionPath) { env.ORCA_OMP_STATUS_EXTENSION = result.statusExtensionPath diff --git a/tests/e2e/omp-launch-environment.spec.ts b/tests/e2e/omp-launch-environment.spec.ts new file mode 100644 index 00000000000..45b5d989d79 --- /dev/null +++ b/tests/e2e/omp-launch-environment.spec.ts @@ -0,0 +1,240 @@ +import { chmod, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { dirname, delimiter, join } from 'node:path' +import { test as base, expect } from './helpers/orca-app' +import { buildShellCommandFromArgv } from '../../src/shared/tui-agent-startup-shell' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +const test = base.extend({ + launchEnv: async ({ seedTestRepo }, run, testInfo) => { + void seedTestRepo + if (!process.env.ORCA_OMP_PROOF_BINARY || process.platform !== 'darwin') { + await run({}) + return + } + const shell = testInfo.outputPath('login-shell') + const profileDir = testInfo.outputPath('profile') + await mkdir(profileDir, { recursive: true }) + await writeFile( + join(profileDir, '.zprofile'), + ` +export PI_CONFIG_DIR="\${PI_CONFIG_DIR:-.omp-profile-proof}" +export XDG_DATA_HOME="\${XDG_DATA_HOME:-$HOME/xdg-data}" +export XDG_STATE_HOME="\${XDG_STATE_HOME:-$HOME/xdg-state}" +export XDG_CACHE_HOME="\${XDG_CACHE_HOME:-$HOME/xdg-cache}" +mkdir -p "$HOME/$PI_CONFIG_DIR/agent" "$XDG_DATA_HOME/omp" "$XDG_STATE_HOME/omp" "$XDG_CACHE_HOME/omp" +` + ) + await writeFile( + shell, + `#!/bin/sh +case "$ORCA_E2E_HOME_DIR" in + */orca-e2e-userdata-*/home) ;; + *) echo 'Refusing profile proof outside isolated E2E home' >&2; exit 73 ;; +esac +export HOME="$ORCA_E2E_HOME_DIR" +export ZDOTDIR=${buildShellCommandFromArgv([profileDir], 'posix')} +exec /bin/zsh "$@" +` + ) + await chmod(shell, 0o700) + await run({ + PATH: [dirname(process.env.ORCA_OMP_PROOF_BINARY ?? '/usr/bin/omp'), process.env.PATH] + .filter(Boolean) + .join(delimiter), + SHELL: shell, + XDG_DATA_HOME: undefined, + XDG_STATE_HOME: undefined, + XDG_CACHE_HOME: undefined, + XDG_CONFIG_HOME: undefined, + PI_CONFIG_DIR: undefined + }) + } +}) + +test.skip( + !process.env.ORCA_OMP_PROOF_BINARY || process.platform !== 'darwin', + 'Opt-in macOS OMP runtime proof' +) + +test('OMP launched by Orca uses login-profile data and config roots', async ({ + orcaPage, + electronApp +}, testInfo) => { + await waitForSessionReady(orcaPage) + const worktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + const home = await electronApp.evaluate(({ app }) => app.getPath('home')) + expect(await electronApp.evaluate(() => process.env.XDG_DATA_HOME)).toBeUndefined() + const result = testInfo.outputPath('omp-paths.json') + const probe = testInfo.outputPath('path-probe.ts') + await writeFile( + probe, + `import { writeFileSync } from 'node:fs' +export default function (api) { + api.on('session_start', (_event, ctx) => { + writeFileSync(process.env.ORCA_PATH_PROBE_OUTPUT || ${JSON.stringify(result)}, JSON.stringify({ + data: process.env.XDG_DATA_HOME, config: process.env.PI_CONFIG_DIR, + source: process.env.ORCA_OMP_SOURCE_AGENT_DIR, + status: process.env.ORCA_OMP_STATUS_EXTENSION, + session: ctx.sessionManager.getSessionFile(), pid: process.pid + })) + }) +}` + ) + await execInTerminal( + orcaPage, + ptyId, + buildShellCommandFromArgv(['omp', '--no-extensions', '--extension', probe], 'posix') + ) + await expect + .poll( + async () => { + try { + return JSON.parse(await readFile(result, 'utf8')) + } catch { + return null + } + }, + { timeout: 45_000 } + ) + .toEqual( + expect.objectContaining({ + data: join(home, 'xdg-data'), + config: '.omp-profile-proof', + source: join(home, '.omp-profile-proof', 'agent'), + status: expect.stringContaining(join('.omp-profile-proof', 'agent', 'extensions')), + session: expect.stringContaining(join('xdg-data', 'omp', 'sessions')) + }) + ) + await expect(orcaPage.locator('.xterm-screen').first()).toBeVisible() + await orcaPage.screenshot({ path: testInfo.outputPath('omp-profile-root.png') }) + await expect(async () => { + expect(await readdir(join(home, 'xdg-data', 'omp'))).toContain('agent.db') + }).toPass({ timeout: 30_000 }) + const overrideData = join(home, 'pane-data') + await mkdir(join(overrideData, 'omp'), { recursive: true }) + const overrideResult = testInfo.outputPath('omp-pane-paths.json') + const overridePty = await orcaPage.evaluate( + async ({ command, worktreeId, home, overrideData, overrideResult }) => { + const pane = await window.api.pty.spawn({ + cols: 100, + rows: 30, + cwd: home, + worktreeId, + initiallyHidden: true, + launchAgent: 'omp', + command, + env: { + XDG_DATA_HOME: overrideData, + PI_CONFIG_DIR: '.omp-pane-config', + ORCA_PATH_PROBE_OUTPUT: overrideResult + } + }) + return pane.id + }, + { + command: buildShellCommandFromArgv(['omp', '--no-extensions', '--extension', probe], 'posix'), + worktreeId, + home, + overrideData, + overrideResult + } + ) + try { + await expect + .poll( + async () => { + try { + return JSON.parse(await readFile(overrideResult, 'utf8')) + } catch { + return null + } + }, + { timeout: 45_000 } + ) + .toEqual( + expect.objectContaining({ + data: overrideData, + config: '.omp-pane-config', + source: join(home, '.omp-pane-config', 'agent'), + session: expect.stringContaining(join('pane-data', 'omp', 'sessions')) + }) + ) + await expect(async () => { + expect(await readdir(join(overrideData, 'omp'))).toContain('agent.db') + }).toPass({ timeout: 30_000 }) + } finally { + await orcaPage.evaluate((id) => window.api.pty.kill(id), overridePty) + } + const baselineResult = testInfo.outputPath('omp-login-baseline.json') + const baselineCommand = buildShellCommandFromArgv( + [ + 'env', + ...[ + 'XDG_DATA_HOME', + 'XDG_STATE_HOME', + 'XDG_CACHE_HOME', + 'PI_CONFIG_DIR', + 'ORCA_OMP_SOURCE_AGENT_DIR', + 'ORCA_OMP_STATUS_EXTENSION', + 'ORCA_PI_STATUS_OWNED', + 'ZDOTDIR', + 'ORCA_ORIG_ZDOTDIR' + ].flatMap((key) => ['-u', key]), + `ORCA_PATH_PROBE_OUTPUT=${baselineResult}`, + `HOME=${home}`, + `ZDOTDIR=${testInfo.outputPath('profile')}`, + '/bin/zsh', + '-ilc', + buildShellCommandFromArgv( + [process.env.ORCA_OMP_PROOF_BINARY ?? '', '--no-extensions', '--extension', probe], + 'posix' + ) + ], + 'posix' + ) + const baselinePty = await orcaPage.evaluate( + async ({ command, worktreeId, home }) => { + return ( + await window.api.pty.spawn({ + cols: 100, + rows: 30, + cwd: home, + worktreeId, + initiallyHidden: true, + command + }) + ).id + }, + { command: baselineCommand, worktreeId, home } + ) + try { + await expect + .poll( + async () => { + try { + return JSON.parse(await readFile(baselineResult, 'utf8')) + } catch { + return null + } + }, + { timeout: 45_000 } + ) + .toEqual( + expect.objectContaining({ + data: join(home, 'xdg-data'), + config: '.omp-profile-proof', + session: expect.stringContaining(join('xdg-data', 'omp', 'sessions')) + }) + ) + } finally { + await orcaPage.evaluate((id) => window.api.pty.kill(id), baselinePty) + } +}) diff --git a/tests/tools/omp-config-root-local-shell.test.mjs b/tests/tools/omp-config-root-local-shell.test.mjs new file mode 100644 index 00000000000..c86bb0bd542 --- /dev/null +++ b/tests/tools/omp-config-root-local-shell.test.mjs @@ -0,0 +1,98 @@ +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { runProcess } from '../../src/shared/child-process/run-process' +import { inheritOmpLaunchEnvironment } from '../../src/main/ipc/pty/host-env/omp-launch-environment' +import { resetLoginShellEnvironmentCacheForTests } from '../../src/main/startup/login-shell-environment' +import { PiTitlebarExtensionService } from '../../src/main/pi/titlebar-extension-service' +const fixture = { home: '', shell: '' } +const shells = ['bash', 'zsh', 'fish'].map((name) => { + const path = (process.env.PATH ?? '') + .split(delimiter) + .map((dir) => join(dir, name)) + .find(existsSync) + return { name, path } +}) +afterEach(() => { + vi.unstubAllEnvs() + resetLoginShellEnvironmentCacheForTests() + if (fixture.home) { + rmSync(fixture.home, { recursive: true, force: true }) + } +}) +function prepare(shell) { + fixture.home = mkdtempSync(join(tmpdir(), 'omp20605-shell-root-')) + fixture.shell = shell.path + vi.stubEnv('HOME', fixture.home) + vi.stubEnv('SHELL', shell.path) + vi.stubEnv('ZDOTDIR', fixture.home) + vi.stubEnv('XDG_CONFIG_HOME', join(fixture.home, '.config')) + vi.stubEnv('PI_CONFIG_DIR', undefined) + const file = + shell.name === 'fish' + ? '.config/fish/config.fish' + : shell.name === 'zsh' + ? '.zprofile' + : '.bash_profile' + mkdirSync(join(fixture.home, '.config/fish'), { recursive: true }) + writeFileSync( + join(fixture.home, file), + shell.name === 'fish' + ? 'if not set -q PI_CONFIG_DIR; or test -z "$PI_CONFIG_DIR"\n set -gx PI_CONFIG_DIR .profile-omp\nend\n' + : 'export PI_CONFIG_DIR="${PI_CONFIG_DIR:-.profile-omp}"\n' + ) +} +for (const shell of shells) { + for (const config of [undefined, '.pane-omp', '']) { + it.skipIf(process.platform === 'win32' || !shell.path)( + `${shell.name}: local installer agrees with the launched shell, pane=${JSON.stringify(config)}`, + async () => { + prepare(shell) + const env = config === undefined ? {} : { PI_CONFIG_DIR: config } + await inheritOmpLaunchEnvironment(env, { launchAgent: 'omp', explicitEnv: { ...env } }) + const service = new PiTitlebarExtensionService() + const managed = service.buildPtyEnv('root-proof', undefined, 'omp', { + configDirName: env.PI_CONFIG_DIR + }) + const result = await runProcess({ + program: shell.path, + args: ['-ilc', 'printf "ROOT=%s\\n" "$PI_CONFIG_DIR"'], + env: { ...process.env, ...env } + }) + expect(result.code).toBe(0) + const actual = result.stdout.match(/ROOT=([^\r\n]*)/)?.[1] + if (config === '') { + expect(actual).toBe('.omp') + } + expect(managed.ORCA_OMP_SOURCE_AGENT_DIR).toBe( + join(fixture.home, actual || '.omp', 'agent') + ) + } + ) + } +} + +const selectedShell = shells.find((shell) => shell.name === 'zsh') +it.skipIf(process.platform === 'win32' || !selectedShell?.path).each(['provider', 'daemon'])( + '%s uses the selected pane shell instead of the process default', + async (route) => { + prepare(selectedShell) + vi.stubEnv('SHELL', '/bin/bash') + writeFileSync(join(fixture.home, '.bash_profile'), 'export PI_CONFIG_DIR=.wrong-shell\n') + const env = { SHELL: route === 'provider' ? '/bin/bash' : selectedShell.path } + await inheritOmpLaunchEnvironment(env, { + launchAgent: 'omp', + explicitEnv: { ...env }, + ...(route === 'provider' ? { shellPath: selectedShell.path } : {}) + }) + expect(env.PI_CONFIG_DIR).toBe('.profile-omp') + const launched = await runProcess({ + program: selectedShell.path, + args: ['-ilc', 'printf "ROOT=%s\\n" "$PI_CONFIG_DIR"'], + env: { ...process.env, ...env } + }) + expect(launched.code).toBe(0) + expect(launched.stdout).toContain('ROOT=.profile-omp') + } +) diff --git a/tests/tools/omp-config-root-relay-shell.test.mjs b/tests/tools/omp-config-root-relay-shell.test.mjs new file mode 100644 index 00000000000..5b9222406e3 --- /dev/null +++ b/tests/tools/omp-config-root-relay-shell.test.mjs @@ -0,0 +1,77 @@ +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { runProcess } from '../../src/shared/child-process/run-process' +import { resetLoginShellEnvironmentCacheForTests } from '../../src/main/startup/login-shell-environment' +import { PluginOverlayManager } from '../../src/relay/plugin-overlay' +import { resolveOmpConfigDirName } from '../../src/relay/plugin-overlay-env' +import { __resetShellStartupEnvCache } from '../../src/main/pty/shell-startup-env' +const fixture = { home: '', shell: '' } +const shells = ['bash', 'zsh', 'fish'].map((name) => { + const path = (process.env.PATH ?? '') + .split(delimiter) + .map((dir) => join(dir, name)) + .find(existsSync) + return { name, path } +}) +afterEach(() => { + vi.unstubAllEnvs() + resetLoginShellEnvironmentCacheForTests() + __resetShellStartupEnvCache() + if (fixture.home) { + rmSync(fixture.home, { recursive: true, force: true }) + } +}) +function prepare(shell) { + fixture.home = mkdtempSync(join(tmpdir(), 'omp20605-shell-root-')) + fixture.shell = shell.path + vi.stubEnv('HOME', fixture.home) + vi.stubEnv('SHELL', shell.path) + vi.stubEnv('ZDOTDIR', fixture.home) + vi.stubEnv('XDG_CONFIG_HOME', join(fixture.home, '.config')) + vi.stubEnv('PI_CONFIG_DIR', undefined) + const file = + shell.name === 'fish' + ? '.config/fish/config.fish' + : shell.name === 'zsh' + ? '.zprofile' + : '.bash_profile' + mkdirSync(join(fixture.home, '.config/fish'), { recursive: true }) + writeFileSync( + join(fixture.home, file), + shell.name === 'fish' + ? 'if not set -q PI_CONFIG_DIR; or test -z "$PI_CONFIG_DIR"\n set -gx PI_CONFIG_DIR .profile-omp\nend\n' + : 'export PI_CONFIG_DIR="${PI_CONFIG_DIR:-.profile-omp}"\n' + ) +} +for (const shell of shells) { + for (const config of [undefined, '', '.pane-omp']) { + it.skipIf(process.platform === 'win32' || !shell.path)( + `${shell.name}: relay root matches profile execution, pane=${JSON.stringify(config)}`, + async () => { + prepare(shell) + const env = { HOME: fixture.home, XDG_CONFIG_HOME: join(fixture.home, '.config') } + if (config !== undefined) { + env.PI_CONFIG_DIR = config + } + const configDirName = await resolveOmpConfigDirName(env, shell.path) + if (configDirName !== undefined) { + env.PI_CONFIG_DIR = configDirName + } + const manager = new PluginOverlayManager({ homeDir: fixture.home }) + manager.setSources({ ompExtensionSource: 'export default function() {}' }) + const installed = manager.materializePi('pane', undefined, 'omp', { configDirName }) + const result = await runProcess({ + program: shell.path, + args: ['-ilc', 'printf "ROOT=%s\\n" "$PI_CONFIG_DIR"'], + env + }) + expect(result.code).toBe(0) + const actual = result.stdout.match(/ROOT=([^\r\n]*)/)?.[1] + expect(actual).toBe(config === undefined ? '.profile-omp' : config || '.omp') + expect(installed?.sourceAgentDir).toBe(join(fixture.home, actual || '.omp', 'agent')) + } + ) + } +}