fix(omp): keep task storage aligned with the login shell

Verified across macOS, Windows, Linux, local shell/config roots, SSH E2E, changed E2E, package, typecheck, static analysis, and Pullfrog.
This commit is contained in:
Neil
2026-09-19 01:17:57 -07:00
committed by GitHub
parent 3af09f824b
commit db478bd7d8
22 changed files with 988 additions and 35 deletions
+7 -1
View File
@@ -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)
@@ -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()
})
})
@@ -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<string, string>,
options: {
shellPath?: string
isWsl?: boolean
launchAgent?: string
launchCommand?: string
explicitEnv?: Record<string, string>
}
): Promise<void> {
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
}
}
}
+6
View File
@@ -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,
@@ -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,
@@ -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,
@@ -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(
+8 -4
View File
@@ -58,8 +58,9 @@ const AGENT_HOME_DIR_NAME: Record<PiAgentKind, string> = {
'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<string, string> {
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)
@@ -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<string, string>) => 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<string, string>) => {
env.CUSTOM_VAR = 'custom-value'
@@ -7,6 +7,7 @@ export type LocalPtyProviderOptions = {
id: string,
baseEnv: Record<string, string>,
ctx?: {
explicitEnv?: PtySpawnOptions['env']
command?: string
launchAgent?: PtySpawnOptions['launchAgent']
codexHomePathOverride?: PtySpawnOptions['codexHomePathOverride']
@@ -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,
@@ -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<void>()
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)
})
+37 -17
View File
@@ -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<NodeJS.ProcessEnv> | null = null
let cachedShellKey: string | null = null
const environmentCache = new Map<string, Promise<NodeJS.ProcessEnv>>()
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<NodeJS.ProcessEnv | null> {
function spawnShellAndReadEnvironment(
shell: string,
env: NodeJS.ProcessEnv
): Promise<NodeJS.ProcessEnv | null> {
const args = shellProbe(shell)
if (!args) {
return Promise.resolve(null)
@@ -86,7 +90,7 @@ function spawnShellAndReadEnvironment(shell: string): Promise<NodeJS.ProcessEnv
return new Promise((resolve) => {
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<NodeJS.ProcessEnv
export type ResolveLoginShellEnvironmentOptions = {
force?: boolean
shellOverride?: string | null
spawner?: (shell: string) => Promise<NodeJS.ProcessEnv | null>
env?: NodeJS.ProcessEnv
spawner?: (shell: string, env: NodeJS.ProcessEnv) => Promise<NodeJS.ProcessEnv | null>
}
/** 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()
}
+89
View File
@@ -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')
}
)
})
+15
View File
@@ -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<string, string>,
shell: string | undefined
): Promise<string | undefined> {
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
}
+7 -4
View File
@@ -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
}
@@ -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<void>()
const resolved = Promise.withResolvers<Record<string, string>>()
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<void>()
const resolved = Promise.withResolvers<Record<string, string>>()
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<void>()
const resolved = Promise.withResolvers<Record<string, string>>()
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',
+6 -6
View File
@@ -491,7 +491,7 @@ export type PtyEnvAugmenter = (ctx: {
env: Record<string, string>
command?: string
launchAgent?: TuiAgent
}) => Record<string, string>
}) => Record<string, string> | Promise<Record<string, string>>
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<string, string> | undefined,
ctx: {
id: string
@@ -793,7 +793,7 @@ export class PtyHandler {
launchAgent?: TuiAgent
},
envToDelete: readonly string[] = []
): Record<string, string> {
): Promise<Record<string, string>> {
const baseEnv = mergeGitConfigEnvProtocol(
{
...stripInheritedBuildModeEnv(process.env),
@@ -809,7 +809,7 @@ export class PtyHandler {
const augmented: Record<string, string> = {}
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
+14 -3
View File
@@ -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<PtyEnvAugmenter>[0]): Record<string, string> {
private async buildPluginEnvironment(
context: Parameters<PtyEnvAugmenter>[0]
): Promise<Record<string, string>> {
const env: Record<string, string> = {}
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
+240
View File
@@ -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)
}
})
@@ -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')
}
)
@@ -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'))
}
)
}
}