mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user