From 208b76339aa503d869b2ff49cf74ecaba7825a82 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:23:40 -0700 Subject: [PATCH 01/21] fix: refresh host environments before structured agent command resolution --- .../claude-structured-child-env.test.ts | 29 ++++++ .../claude/claude-structured-child-env.ts | 6 +- .../claude-structured-launch-resolution.ts | 3 +- .../runtime/orca-runtime-get-worktree-ps.ts | 4 +- .../structured-agent-session-runtime.ts | 3 +- ...structured-agent-shell-environment.test.ts | 92 ++++++++++++++++++- .../structured-agent-shell-environment.ts | 26 +++++- .../structured-claude-runtime-adapter.ts | 2 +- 8 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 src/main/claude/claude-structured-child-env.test.ts diff --git a/src/main/claude/claude-structured-child-env.test.ts b/src/main/claude/claude-structured-child-env.test.ts new file mode 100644 index 000000000000..8ab162edda2a --- /dev/null +++ b/src/main/claude/claude-structured-child-env.test.ts @@ -0,0 +1,29 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { resolveClaudeChildEnvSources } from './claude-structured-child-env' +import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' + +afterEach(() => vi.unstubAllEnvs()) + +it('resolves a custom Claude command against the login shell PATH', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-shell-only-cli-')) + try { + const binary = join(root, process.platform === 'win32' ? 'claude-custom.cmd' : 'claude-custom') + writeFileSync(binary, '#!/bin/sh\nexit 0\n') + chmodSync(binary, 0o755) + vi.stubEnv('PATH', '') + const settings = { agentCmdOverrides: { claude: 'claude-custom' } } + const inherited = vi.fn(async () => ({ PATH: root, HOME: root })) + const sources = await resolveClaudeChildEnvSources({ + resolveCommand: (options) => resolveStructuredAgentCommand('claude', settings, options), + resolveEnv: async () => ({}), + resolveInheritedEnv: inherited + }) + expect(sources.command).toBe(binary) + expect(sources.inheritedEnv.PATH).toBe(root) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/claude/claude-structured-child-env.ts b/src/main/claude/claude-structured-child-env.ts index 5f787ca60604..e776c8c4f180 100644 --- a/src/main/claude/claude-structured-child-env.ts +++ b/src/main/claude/claude-structured-child-env.ts @@ -32,11 +32,15 @@ export type ClaudeChildEnvSources = { export async function resolveClaudeChildEnvSources( deps: ClaudeEnvDeps ): Promise { - const command = (deps.resolveCommand ?? resolveClaudeCommand)() const overlay = await deps.resolveEnv?.() const inheritedEnv = deps.resolveInheritedEnv ? await deps.resolveInheritedEnv() : cloneDefinedEnv(process.env) + const effectiveEnv = { ...inheritedEnv, ...overlay } + const command = (deps.resolveCommand ?? resolveClaudeCommand)({ + pathEnv: effectiveEnv.PATH ?? effectiveEnv.Path ?? null, + homePath: effectiveEnv.HOME ?? effectiveEnv.USERPROFILE + }) return { command, overlay: overlay ? cloneDefinedEnv(overlay) : undefined, inheritedEnv } } diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 98ec3d971add..ac7e7d57522c 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -1,4 +1,5 @@ import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' +import type { resolveClaudeCommand } from '../codex-cli/command' import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { Options as ClaudeAgentSdkOptions, @@ -114,7 +115,7 @@ export type ClaudeStructuredLaunchResolverDeps = { store: Pick resolveLaunchArgs: () => Promise | string[] resolveWorkspacePath: (workspaceId: string) => Promise - resolveCommand?: () => string + resolveCommand?: typeof resolveClaudeCommand resolveEnv?: () => | Promise | undefined> | Record diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index b73d018c211f..b76b88edf602 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -216,8 +216,8 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis // in a plain folder lands in the folder rather than failing to resolve. resolveWorkspacePath: async (workspaceId) => (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path, - resolveClaudeCommand: () => - resolveStructuredAgentCommand('claude', this.requireStore().getSettings()), + resolveClaudeCommand: (options) => + resolveStructuredAgentCommand('claude', this.requireStore().getSettings(), options), resolveCodexCommand: (options) => resolveStructuredAgentCommand('codex', this.requireStore().getSettings(), options), resolveLaunchArgs: (agent) => diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 9c28b4b8ad24..a128f2c2da26 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -12,6 +12,7 @@ import type { PiRpcSessionDeps } from '../pi/rpc-session' import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' +import type { ClaudeStructuredLaunchResolverDeps } from '../claude/claude-structured-launch-resolution' import { existsSync } from 'node:fs' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' @@ -99,7 +100,7 @@ export type StructuredAgentSessionRuntimeDeps = { claimKeyId: string resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string - resolveClaudeCommand?: () => string + resolveClaudeCommand?: ClaudeStructuredLaunchResolverDeps['resolveCommand'] openPiConnection?: PiRpcSessionDeps['openConnection'] /** Which version-gated flags a Claude CLI takes; absent never passes one. */ claudeCliFlags?: ClaudeCliFlagSupport diff --git a/src/main/runtime/structured-agent-shell-environment.test.ts b/src/main/runtime/structured-agent-shell-environment.test.ts index ff82b19c6916..d73dbe2719c1 100644 --- a/src/main/runtime/structured-agent-shell-environment.test.ts +++ b/src/main/runtime/structured-agent-shell-environment.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import * as loginShell from '../startup/login-shell-environment' +import * as windowsPath from '../pty/windows-environment-path' import { createStructuredAgentEnvironmentResolvers, structuredAgentBaseEnvironment @@ -68,6 +70,94 @@ describe('structuredAgentBaseEnvironment', () => { }) describe('createStructuredAgentEnvironmentResolvers', () => { + it('refreshes the saved Windows PATH before capturing the configured shell', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const registry = vi + .spyOn(windowsPath, 'mergePersistedWindowsPathAsync') + .mockImplementation(async (env) => { + env.PATH = 'C:\\new-cli' + }) + const capture = vi + .spyOn(loginShell, 'resolveLoginShellEnvironment') + .mockImplementation(async (options) => options?.env ?? {}) + try { + const resolvers = createStructuredAgentEnvironmentResolvers({ + resolveShellEnvironmentPolicy: () => INHERIT_ALL + }) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('C:\\new-cli') + expect(registry).toHaveBeenCalledWith(expect.any(Object), { forceRefresh: true }) + expect(capture).toHaveBeenCalledWith({ + force: true, + env: expect.objectContaining({ PATH: 'C:\\new-cli' }) + }) + } finally { + registry.mockRestore() + capture.mockRestore() + Object.defineProperty(process, 'platform', platform) + } + }) + + it('bypasses the login-shell cache on every settled acquisition', async () => { + const capture = vi + .spyOn(loginShell, 'resolveLoginShellEnvironment') + .mockResolvedValue({ PATH: '/shell/current' }) + try { + const resolvers = createStructuredAgentEnvironmentResolvers({}) + await resolvers.resolveBaseEnvironment() + await resolvers.resolveClaudeInheritedEnv() + expect(capture).toHaveBeenCalledTimes(2) + expect(capture).toHaveBeenCalledWith(expect.objectContaining({ force: true })) + } finally { + capture.mockRestore() + } + }) + + it('shares concurrent captures and retries after a failed capture', async () => { + let release: (env: NodeJS.ProcessEnv) => void = () => {} + const capture = vi.fn( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const resolvers = createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) + const first = resolvers.resolveBaseEnvironment() + const second = resolvers.resolveClaudeInheritedEnv() + expect(capture).toHaveBeenCalledOnce() + release({ PATH: '/first' }) + await Promise.all([first, second]) + capture.mockRejectedValueOnce(new Error('shell failed')) + await expect(resolvers.resolveBaseEnvironment()).rejects.toThrow('shell failed') + capture.mockResolvedValueOnce({ PATH: '/repaired' }) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/repaired') + expect(capture).toHaveBeenCalledTimes(3) + }) + + it('refreshes the shell PATH for later acquisitions while rereading settings', async () => { + let shellPath = '/shell/A' + let overlay = 'A' + const resolveEnvironment = vi.fn(async () => ({ PATH: shellPath })) + const resolvers = createStructuredAgentEnvironmentResolvers({ + resolveEnvironment, + resolveShellEnvironmentPolicy: () => INHERIT_ALL, + resolveLaunchEnvOverlay: () => ({ CURRENT_SETTING: overlay }) + }) + expect(await resolvers.resolveCodexEnvironment()).toEqual({ + PATH: '/shell/A', + CURRENT_SETTING: 'A' + }) + shellPath = '/shell/B' + overlay = 'B' + expect(await resolvers.resolveBaseEnvironment()).toEqual({ PATH: '/shell/B' }) + expect(await resolvers.resolveClaudeInheritedEnv()).toEqual({ PATH: '/shell/B' }) + expect(await resolvers.resolveCodexEnvironment()).toEqual({ + PATH: '/shell/B', + CURRENT_SETTING: 'B' + }) + expect(resolveEnvironment).toHaveBeenCalledTimes(4) + }) + it('gives Codex and Claude the same base, with overlays on Codex only', async () => { const resolvers = createStructuredAgentEnvironmentResolvers({ resolveEnvironment: async () => ({ PATH: '/shell/bin', SHELL_ONLY: '1' }), diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index ed0d628d3d11..52f5de51b0df 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -1,4 +1,5 @@ import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' +import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' import { nativeChatShellEnvironmentPolicy, type NativeChatShellEnvironmentPolicy @@ -81,10 +82,17 @@ export type StructuredAgentEnvironmentSources = { resolveCodexOverrides?: () => NodeJS.ProcessEnv } +async function captureHostShellEnvironment(): Promise { + if (process.platform !== 'win32') { + return resolveLoginShellEnvironment({ force: true }) + } + const env = { ...process.env } + await mergePersistedWindowsPathAsync(env, { forceRefresh: true }) + return resolveLoginShellEnvironment({ force: true, env }) +} + /** - * Both providers' child envs over one login-shell snapshot, taken once at install. - * The policy and overlays are re-read per acquisition, so a settings change reaches - * the next chat without a restart. + * Refresh on acquisition so a host-side CLI install reaches the next chat. */ export function createStructuredAgentEnvironmentResolvers( sources: StructuredAgentEnvironmentSources @@ -94,10 +102,18 @@ export function createStructuredAgentEnvironmentResolvers( /** The shared base every agent's child env starts from, before its own overlay. */ resolveBaseEnvironment: () => Promise> } { - const shellEnvironment = (sources.resolveEnvironment ?? resolveLoginShellEnvironment)() + let pendingEnvironment: Promise | null = null + const resolveEnvironment = sources.resolveEnvironment ?? captureHostShellEnvironment + const resolveShellEnvironment = (): Promise => { + // Concurrent probes share a capture; a settled capture never survives the next acquisition. + pendingEnvironment ??= resolveEnvironment().finally(() => { + pendingEnvironment = null + }) + return pendingEnvironment + } const resolveBase = async (): Promise> => structuredAgentBaseEnvironment({ - shellEnv: await shellEnvironment, + shellEnv: await resolveShellEnvironment(), policy: sources.resolveShellEnvironmentPolicy?.() ?? nativeChatShellEnvironmentPolicy(null) }) return { diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index d9abf1cf3885..238630ab201d 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -26,7 +26,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { store: AgentSessionRecordStore resolveWorkspacePath: (workspaceId: string) => Promise resolveClaudeLaunchArgs: () => Promise | string[] - resolveClaudeCommand?: () => string + resolveClaudeCommand?: typeof resolveClaudeCommand /** Which version-gated flags a Claude CLI takes; absent never passes one. */ claudeCliFlags?: ClaudeCliFlagSupport /** Each chat's visuals folder and skill; absent leaves chats without visuals. */ From 73c1b90adcced4a1f570074805134789205b7c47 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:23:41 -0700 Subject: [PATCH 02/21] fix: ship the owning server CLI for managed remote agents --- .github/workflows/node-server-tests.yml | 22 +++ config/scripts/build-orcad.mjs | 3 + config/scripts/build-windows-cli-launcher.mjs | 15 +- .../build-windows-cli-launcher.test.mjs | 36 +++++ config/scripts/node-server-test-paths.mjs | 1 + config/scripts/orcad-cli-package.mjs | 69 ++++++++++ config/scripts/orcad-cli-package.test.mjs | 53 +++++++ native/windows-cli-launcher/src/main.rs | 85 ++++++++---- src/main/cli/orca-cli-child-path.test.ts | 3 +- src/main/cli/orca-cli-child-path.ts | 19 ++- src/main/ipc/pty/host-env/assembly.ts | 11 +- .../orcad-cli-launcher.integration.test.ts | 130 ++++++++++++++++++ src/main/orcad/orcad-cli-launcher.ts | 11 ++ src/main/orcad/orcad-entry.ts | 2 + ...uctured-session-child-identity-env.test.ts | 23 ++++ .../structured-session-child-identity-env.ts | 3 +- .../ssh/orcad-artifact-materializer.test.ts | 1 + src/main/ssh/orcad-artifact-materializer.ts | 2 +- src/main/ssh/orcad-remote-install.ts | 14 +- src/shared/app-environment.ts | 2 + src/shared/orcad-artifacts.test.ts | 9 ++ src/shared/orcad-artifacts.ts | 12 +- 22 files changed, 486 insertions(+), 40 deletions(-) create mode 100644 config/scripts/orcad-cli-package.mjs create mode 100644 config/scripts/orcad-cli-package.test.mjs create mode 100644 src/main/orcad/orcad-cli-launcher.integration.test.ts create mode 100644 src/main/orcad/orcad-cli-launcher.ts diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 90c3fc68473d..7b1443cd32be 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -206,6 +206,13 @@ jobs: run: | node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64 node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64 + - name: Build the Windows CLI launchers for the desktop template + if: inputs.build_template && matrix.os == 'windows-2022' + shell: bash + run: | + rustup target add x86_64-pc-windows-msvc aarch64-pc-windows-msvc + node config/scripts/build-windows-cli-launcher.mjs --arch x64 --output .build/windows-cli-launcher/x64/orca.exe + node config/scripts/build-windows-cli-launcher.mjs --arch arm64 --output .build/windows-cli-launcher/arm64/orca.exe - name: Keep the Windows process-table addons for the desktop template if: inputs.build_template && matrix.os == 'windows-2022' uses: actions/upload-artifact@v7 @@ -216,6 +223,16 @@ jobs: if-no-files-found: error retention-days: 7 overwrite: true + - name: Keep the Windows CLI launchers for the desktop template + if: inputs.build_template && matrix.os == 'windows-2022' + uses: actions/upload-artifact@v7 + with: + name: orcad-windows-cli-launcher + path: .build/windows-cli-launcher/ + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + overwrite: true - uses: actions/setup-node@v6 if: runner.arch == 'X64' with: @@ -458,6 +475,11 @@ jobs: with: name: orcad-windows-process-tree path: .build/windows-process-tree + - name: Collect the Windows CLI launchers + uses: actions/download-artifact@v8 + with: + name: orcad-windows-cli-launcher + path: .build/windows-cli-launcher - name: Merge the lanes and gate the full slot matrix shell: bash run: | diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 96a46677626f..56035083cd88 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -27,6 +27,7 @@ import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs' import { smokeForeignSqliteReaderWorker } from './foreign-sqlite-reader-worker-smoke.mjs' import { materializeWatcherPackage } from './orcad-watcher-package.mjs' import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs' +import { buildOrcadCli } from './orcad-cli-package.mjs' import { ORCAD_EMOJI_SHORTCODE_DATASET, ORCAD_FOREIGN_SQLITE_READER_ENTRY, @@ -235,6 +236,7 @@ const childResults = await Promise.all([ const result = await buildOrcadEntry(SERVER_OUT_FILE) const launcherResult = await buildOrcadLauncher(OUT_FILE) +const cliResult = await buildOrcadCli(ROOT, OUT_DIR, BUILD_TARGET) const output = Object.values(result.metafile.outputs).find( (o) => o.entryPoint === 'src/main/orcad/main.ts' @@ -262,6 +264,7 @@ function collectImporters(metafiles, matches) { const metafiles = [ launcherResult.metafile, result.metafile, + cliResult.metafile, ...childResults.map((child) => child.metafile) ] const electronImporters = collectImporters( diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index 3d9d93aaed20..0e231dc4d926 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -55,6 +55,12 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) } const repoRoot = resolve(import.meta.dirname, '../..') + const arch = readArg('--arch') + const rustTarget = + arch === 'arm64' ? 'aarch64-pc-windows-msvc' : arch === 'x64' ? 'x86_64-pc-windows-msvc' : null + if (arch && !rustTarget) { + throw new Error(`Unsupported Windows launcher architecture: ${arch}`) + } const crateRoot = join(repoRoot, 'native', 'windows-cli-launcher') const manifestPath = join(crateRoot, 'Cargo.toml') const iconPath = join(repoRoot, 'resources', 'build', 'icon.ico') @@ -71,7 +77,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) iconPath, join(repoRoot, 'config/scripts/build-windows-cli-launcher.mjs') ], - version + `${version}:${rustTarget ?? 'host'}` ) const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot) @@ -92,7 +98,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) '--manifest-path', manifestPath, '--target-dir', - targetDirectory + targetDirectory, + ...(rustTarget ? ['--target', rustTarget] : []) ], { cwd: crateRoot, @@ -119,7 +126,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exit(result.status ?? 1) } - const builtPath = join(targetDirectory, 'release', 'orca.exe') + const builtPath = rustTarget + ? join(targetDirectory, rustTarget, 'release', 'orca.exe') + : join(targetDirectory, 'release', 'orca.exe') const vcRuntimeImports = findDynamicVcRuntimeImports( readPeImportedDllNames(readFileSync(builtPath)) ) diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 0e06cafd22eb..e4d5e641f624 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -186,6 +186,42 @@ describe('Windows CLI launcher', () => { ).toBe(false) }) + itWindows('runs a server CLI with pinned Node and preserves multiline arguments', () => { + const root = mkdtempSync(join(tmpdir(), 'orca server launcher ')) + try { + const slot = join(root, 'slot') + const launcher = join(slot, 'bin', 'orca.exe') + const runtime = join(root, 'runtimes', `node-${'a'.repeat(64)}`, 'node.exe') + mkdirSync(dirname(launcher), { recursive: true }) + mkdirSync(dirname(runtime), { recursive: true }) + copyFileSync(process.execPath, runtime) + writeFileSync(join(slot, '.runtime-node'), `${'a'.repeat(64)}\n`) + writeFileSync( + join(slot, 'orca-cli.js'), + 'console.log(JSON.stringify({argv:process.argv.slice(2),profile:process.env.ORCA_USER_DATA_PATH,launcher:process.env.ORCA_CLI_COMMAND}))' + ) + const build = spawnSync( + process.execPath, + ['config/scripts/build-windows-cli-launcher.mjs', '--output', launcher], + { cwd: projectRoot, encoding: 'utf8' } + ) + expect(build.status, build.stderr).toBe(0) + const body = 'line one\n\nline two' + const child = spawnSync(launcher, ['orchestration', 'send', '--body', body], { + encoding: 'utf8', + env: { ...process.env, ORCA_USER_DATA_PATH: root } + }) + expect(child.status, child.stderr).toBe(0) + expect(JSON.parse(child.stdout)).toEqual({ + argv: ['orchestration', 'send', '--body', body], + profile: root, + launcher + }) + } finally { + removeFixtureTree(root) + } + }) + itWindows('preserves a multiline argument from PowerShell through the native launcher', () => { const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher ')) try { diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs index 9e51b23af9ce..36a66e628a50 100644 --- a/config/scripts/node-server-test-paths.mjs +++ b/config/scripts/node-server-test-paths.mjs @@ -31,6 +31,7 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } = 'src/main/orcad/orcad-packaged-node-pty.integration.test.ts', 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', 'src/main/orcad/orcad-node-launcher.integration.test.ts', + 'src/main/orcad/orcad-cli-launcher.integration.test.ts', 'src/main/orcad/orcad-launcher-isolation.integration.test.ts', 'src/main/orcad/orcad-stop-request-shutdown.integration.test.ts', 'src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts', diff --git a/config/scripts/orcad-cli-package.mjs b/config/scripts/orcad-cli-package.mjs new file mode 100644 index 000000000000..95e29e3fa81b --- /dev/null +++ b/config/scripts/orcad-cli-package.mjs @@ -0,0 +1,69 @@ +import { build } from 'esbuild' +import { chmodSync, copyFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { + ORCAD_CLI_ENTRY_FILENAME, + orcadCliLauncherFilename +} from '../../src/shared/orcad-artifacts.ts' +import { externalNativeAddons } from './orcad-entry-build.mjs' +import { runProcessSync } from './script-child-process.mjs' + +export function orcadPosixCliLauncher() { + return `#!/bin/sh +set -eu +: "\${ORCA_USER_DATA_PATH:?The Orca server data path is required}" +slot=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +runtime_sha=$(cat "$slot/.runtime-node") +case "$runtime_sha" in + *[!a-f0-9]*|'') echo 'Invalid Orca runtime reference' >&2; exit 78 ;; +esac +[ "\${#runtime_sha}" -eq 64 ] || exit 78 +unset NODE_OPTIONS NODE_REPL_EXTERNAL_MODULE +exec "$slot/../runtimes/node-$runtime_sha/bin/node" "$slot/${ORCAD_CLI_ENTRY_FILENAME}" "$@" +` +} + +export async function buildOrcadCli(root, outputDir, target) { + const result = await build({ + entryPoints: [join(root, 'src/cli/index.ts')], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: join(outputDir, ORCAD_CLI_ENTRY_FILENAME), + plugins: [externalNativeAddons], + metafile: true, + minify: true, + sourcemap: false, + define: { 'process.env.NODE_ENV': '"production"' }, + logLevel: 'error' + }) + const [platform, arch] = target.split('-') + const launcher = join(outputDir, orcadCliLauncherFilename(platform)) + mkdirSync(dirname(launcher), { recursive: true }) + if (platform === 'win32') { + const source = join(root, '.build', 'windows-cli-launcher', arch, 'orca.exe') + if (process.platform === 'win32') { + const compile = runProcessSync({ + program: process.execPath, + args: [ + join(root, 'config/scripts/build-windows-cli-launcher.mjs'), + '--arch', + arch, + '--output', + source + ], + stdio: 'inherit', + timeoutMs: null + }) + if (compile.code !== 0) { + throw new Error('Could not build the Orca server CLI launcher') + } + } + copyFileSync(source, launcher) + } else { + writeFileSync(launcher, orcadPosixCliLauncher()) + chmodSync(launcher, 0o755) + } + return result +} diff --git a/config/scripts/orcad-cli-package.test.mjs b/config/scripts/orcad-cli-package.test.mjs new file mode 100644 index 000000000000..9731cf7ce4ec --- /dev/null +++ b/config/scripts/orcad-cli-package.test.mjs @@ -0,0 +1,53 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { expect, it } from 'vitest' +import { orcadPosixCliLauncher } from './orcad-cli-package.mjs' + +it.skipIf(process.platform === 'win32')( + 'uses the slot runtime and preserves multiline CLI arguments', + () => { + const root = mkdtempSync(join(tmpdir(), 'orcad cli launcher ')) + try { + const slot = join(root, 'slot') + const launcher = join(slot, 'bin', 'orca') + const runtime = join(root, 'runtimes', `node-${'a'.repeat(64)}`, 'bin', 'node') + mkdirSync(dirname(launcher), { recursive: true }) + mkdirSync(dirname(runtime), { recursive: true }) + symlinkSync(process.execPath, runtime) + writeFileSync(join(slot, '.runtime-node'), `${'a'.repeat(64)}\n`) + writeFileSync(launcher, orcadPosixCliLauncher()) + chmodSync(launcher, 0o755) + writeFileSync( + join(slot, 'orca-cli.js'), + `console.log(JSON.stringify({argv: process.argv.slice(2), runtime: process.execPath, profile: process.env.ORCA_USER_DATA_PATH, options: process.env.NODE_OPTIONS ?? null}))` + ) + const body = 'paragraph one\n\nparagraph two "$variable" `literal`' + const env = { + PATH: '/usr/bin:/bin', + ORCA_USER_DATA_PATH: root, + NODE_OPTIONS: '--no-warnings' + } + const child = spawnSync(launcher, ['orchestration', 'send', '--body', body], { + env, + encoding: 'utf8' + }) + expect(child.status, child.stderr).toBe(0) + expect(JSON.parse(child.stdout)).toEqual({ + argv: ['orchestration', 'send', '--body', body], + runtime: process.execPath, + profile: root, + options: null + }) + const unbound = spawnSync(launcher, ['status', '--json'], { + env: { PATH: env.PATH }, + encoding: 'utf8' + }) + expect(unbound.status).not.toBe(0) + expect(unbound.stderr).toContain('server data path is required') + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +) diff --git a/native/windows-cli-launcher/src/main.rs b/native/windows-cli-launcher/src/main.rs index 00282fe455ee..c8a68f9b93d2 100644 --- a/native/windows-cli-launcher/src/main.rs +++ b/native/windows-cli-launcher/src/main.rs @@ -11,6 +11,7 @@ //! was flagged as exactly that across several vendors (#23383). use std::env; +use std::fs; use std::path::{Path, PathBuf}; use std::process::{exit, Command}; @@ -26,30 +27,58 @@ fn main() { launcher.display() )) }; - let Some(app_directory) = resources_directory.parent() else { - fail(&format!( - "Unable to locate Orca.exe next to \"{}\"", - resources_directory.display() - )) - }; + let (runtime_path, cli_path, headless) = if resources_directory.join("orca-cli.js").is_file() { + let runtime_sha = fs::read_to_string(resources_directory.join(".runtime-node")) + .unwrap_or_else(|error| fail(&format!("Unable to read the Orca runtime: {error}"))); + let runtime_sha = runtime_sha.trim(); + if runtime_sha.len() != 64 || !runtime_sha.bytes().all(|byte| byte.is_ascii_hexdigit()) { + fail("Invalid Orca runtime reference"); + } + if env::var_os("ORCA_USER_DATA_PATH").is_none_or(|value| value.is_empty()) { + fail("The Orca server data path is required"); + } + ( + resources_directory + .join("..") + .join("runtimes") + .join(format!("node-{runtime_sha}")) + .join("node.exe"), + resources_directory.join("orca-cli.js"), + true, + ) + } else { + let Some(app_directory) = resources_directory.parent() else { + fail(&format!( + "Unable to locate Orca.exe next to \"{}\"", + resources_directory.display() + )) + }; - let electron_path = app_directory.join("Orca.exe"); - if !electron_path.is_file() { - fail(&format!( - "Unable to locate Orca.exe next to \"{}\"", - resources_directory.display() - )); - } + let electron_path = app_directory.join("Orca.exe"); + if !electron_path.is_file() { + fail(&format!( + "Unable to locate Orca.exe next to \"{}\"", + resources_directory.display() + )); + } - let cli_path: PathBuf = resources_directory - .join("app.asar.unpacked") - .join("out") - .join("cli") - .join("index.js"); - if !cli_path.is_file() { + let cli_path: PathBuf = resources_directory + .join("app.asar.unpacked") + .join("out") + .join("cli") + .join("index.js"); + if !cli_path.is_file() { + fail(&format!( + "Unable to locate the Orca CLI entrypoint at \"{}\"", + cli_path.display() + )); + } + (electron_path, cli_path, false) + }; + if !runtime_path.is_file() { fail(&format!( - "Unable to locate the Orca CLI entrypoint at \"{}\"", - cli_path.display() + "Unable to locate the Orca runtime at \"{}\"", + runtime_path.display() )); } @@ -62,21 +91,25 @@ fn main() { "NODE_REPL_EXTERNAL_MODULE", "ORCA_NODE_REPL_EXTERNAL_MODULE", ); - env::set_var("ELECTRON_RUN_AS_NODE", "1"); + if !headless { + env::set_var("ELECTRON_RUN_AS_NODE", "1"); + } env::set_var("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1"); let requested_command = env::var("ORCA_CLI_COMMAND").unwrap_or_default(); env::set_var( "ORCA_CLI_COMMAND", - if requested_command == "orca-ide" { - "orca-ide" + if headless { + launcher.to_string_lossy().into_owned() + } else if requested_command == "orca-ide" { + "orca-ide".into() } else { - "orca" + "orca".into() }, ); // Each argument stays its own argv entry, so a body holding newlines reaches // the CLI intact. - let mut command = Command::new(&electron_path); + let mut command = Command::new(&runtime_path); command.arg(&cli_path).args(env::args_os().skip(1)); match command.status() { diff --git a/src/main/cli/orca-cli-child-path.test.ts b/src/main/cli/orca-cli-child-path.test.ts index 7440b1a9ad82..c9e6e6a8b67e 100644 --- a/src/main/cli/orca-cli-child-path.test.ts +++ b/src/main/cli/orca-cli-child-path.test.ts @@ -29,7 +29,8 @@ describe('prependOrcaCliDirToChildPath', () => { expect(env.PATH).toBe(`${SHIM_DIR}:/usr/local/bin:/usr/bin`) expect(env.ORCA_CLI_BIN_DIR).toBe(SHIM_DIR) expect(shim.ensureLinuxTerminalOrcaCliShimDir).toHaveBeenCalledWith({ - userDataPath: USER_DATA + userDataPath: USER_DATA, + resourcesPath: RESOURCES }) }) diff --git a/src/main/cli/orca-cli-child-path.ts b/src/main/cli/orca-cli-child-path.ts index a15943827cc4..97025fd29d59 100644 --- a/src/main/cli/orca-cli-child-path.ts +++ b/src/main/cli/orca-cli-child-path.ts @@ -13,7 +13,7 @@ * every branch behaves exactly as it did inside `buildPtyHostEnv`. */ -import { delimiter, join } from 'node:path' +import { delimiter, dirname, join } from 'node:path' import { readInheritedPath } from '../ipc/pty/host-env/path' import { resolvePathEnvKey } from '../pty/windows-environment-path' import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim' @@ -24,6 +24,7 @@ export type OrcaCliChildPathOptions = { isPackaged: boolean userDataPath: string resourcesPath?: string | null + launcherPath?: string | null /** Test seam — production reads the real platform, which is what every branch below assumes. */ platform?: NodeJS.Platform } @@ -43,6 +44,17 @@ export function prependOrcaCliDirToChildPath( // Why: matches node:path's `delimiter` for the running platform, but stays correct when a test // drives a foreign platform through the seam. const pathDelimiter = platform === 'win32' ? ';' : delimiter + if (opts.launcherPath) { + const cliBin = dirname(opts.launcherPath) + const inheritedPath = readInheritedPath(env, platform) + env[resolvePathEnvKey(env, platform)] = inheritedPath + ? `${cliBin}${pathDelimiter}${inheritedPath}` + : cliBin + if (platform !== 'win32') { + env.ORCA_CLI_BIN_DIR = cliBin + } + return opts.launcherPath + } // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. if (!opts.isPackaged) { const devCliBin = join(opts.userDataPath, 'cli', 'bin') @@ -57,7 +69,10 @@ export function prependOrcaCliDirToChildPath( return join(devCliBin, platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME) } else if (platform === 'linux') { // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). - const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath: opts.userDataPath, + resourcesPath: opts.resourcesPath + }) if (shimDir) { env.ORCA_CLI_BIN_DIR = shimDir const inheritedEntries = readInheritedPath(env, platform) diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 0fe3faf375e7..2a0bc0178eae 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -4,6 +4,7 @@ import { isPiCompatibleAgentType } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' +import { getAppEnvironment } from '../../../../shared/app-environment' import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer' import { mimoCodeHookService } from '../../../mimo/hook-service' import { agentHookServer } from '../../../agent-hooks/server' @@ -278,11 +279,17 @@ export function buildPtyHostEnv( } delete baseEnv.ORCA_CLI_COMMAND } - prependOrcaCliDirToChildPath(baseEnv, { + const hostLauncher = !opts.isWsl ? getAppEnvironment().getCliLauncherPath?.() : null + const launcher = prependOrcaCliDirToChildPath(baseEnv, { isPackaged: opts.isPackaged, userDataPath: opts.userDataPath, - resourcesPath: opts.resourcesPath + resourcesPath: opts.resourcesPath, + launcherPath: hostLauncher }) + if (hostLauncher && launcher) { + baseEnv.ORCA_CLI_COMMAND = launcher + baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath + } if ( opts.routeBrowserOpensToClient === true && diff --git a/src/main/orcad/orcad-cli-launcher.integration.test.ts b/src/main/orcad/orcad-cli-launcher.integration.test.ts new file mode 100644 index 000000000000..062b404751d5 --- /dev/null +++ b/src/main/orcad/orcad-cli-launcher.integration.test.ts @@ -0,0 +1,130 @@ +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { expect, it } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' +import { resolveBundledOrcadRuntime } from './orcad-bundled-runtime' +import { ORCAD_NODE_RUNTIME_MARKER_FILENAME } from '../../shared/orcad-artifacts' +import { resolveOrcadCliLauncher } from './orcad-cli-launcher' +import { skipForMissingInputs } from './orcad-node-slot-fixture' +import { + killChildAndWait, + killProfileDaemons, + removeTestRoot +} from './orcad-daemon-teardown-fixture' + +const slotDir = resolve('out/orcad') +const runtime = existsSync(join(slotDir, ORCAD_NODE_RUNTIME_MARKER_FILENAME)) + ? resolveBundledOrcadRuntime(slotDir) + : null +const skip = skipForMissingInputs('artifact', runtime ? [] : ['a Node orcad slot in out/orcad']) + +it.skipIf(skip)( + 'a session child runs the shipped CLI against its owning server', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orcad-cli-')) + const home = join(root, 'home') + mkdirSync(home) + const userData = join(root, 'state') + mkdirSync(userData) + writeFileSync( + join(userData, 'orca-data.json'), + JSON.stringify({ settings: { agentStatusHooksEnabled: false } }) + ) + const env: NodeJS.ProcessEnv = { + HOME: home, + USERPROFILE: home, + APPDATA: home, + LOCALAPPDATA: home, + SystemRoot: process.env.SystemRoot, + ComSpec: process.env.ComSpec, + PATH: process.platform === 'win32' ? process.env.PATH : '/usr/bin:/bin', + TEMP: root, + TMP: root, + TMPDIR: root, + CODEX_HOME: join(home, 'codex'), + CLAUDE_CONFIG_DIR: join(home, 'claude'), + ORCA_USER_DATA: userData, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_DISABLE_MACOS_LOGIN_SHELL: '1' + } + const child = spawnProcess({ + program: runtime!, + args: [ + join(slotDir, 'orcad.js'), + '--bind', + '127.0.0.1', + '--port', + '0', + '--no-pairing', + '--json' + ], + env, + timeoutMs: null + }) + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + try { + await new Promise((ready, reject) => { + let stdout = '' + const timer = setTimeout( + () => reject(new Error(`server readiness timed out: ${stderr.slice(-2000)}`)), + 120_000 + ) + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + if (stdout.includes('\n')) { + clearTimeout(timer) + ready() + } + }) + child.once('exit', (code) => { + clearTimeout(timer) + reject(new Error(`server exited ${String(code)}: ${stderr.slice(-2000)}`)) + }) + }) + const launcher = resolveOrcadCliLauncher(slotDir) + expect(launcher).not.toBeNull() + installFakeAppEnvironment({ + getPath: () => userData, + isPackaged: () => true, + getCliLauncherPath: () => launcher + }) + const childEnv = structuredSessionChildIdentityEnv('claude_test-session', { + PATH: env.PATH ?? '' + }) + const result = await runProcess({ + program: childEnv.ORCA_CLI_COMMAND!, + args: ['status', '--json'], + env: { ...env, ...childEnv }, + cwd: home, + timeoutMs: 15_000 + }) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + result: { + app: { running: true, pid: child.pid }, + runtime: { reachable: true, state: 'ready' } + } + }) + const skill = await runProcess({ + program: childEnv.ORCA_CLI_COMMAND!, + args: ['skills', 'get', 'orchestration'], + env: { ...env, ...childEnv }, + timeoutMs: 15_000 + }) + expect(skill.code, skill.stderr).toBe(0) + expect(skill.stdout).toContain('Worker obligations') + } finally { + await killChildAndWait(child) + await killProfileDaemons(userData) + await removeTestRoot(root) + } + }, + 150_000 +) diff --git a/src/main/orcad/orcad-cli-launcher.ts b/src/main/orcad/orcad-cli-launcher.ts new file mode 100644 index 000000000000..f21fb4797c3f --- /dev/null +++ b/src/main/orcad/orcad-cli-launcher.ts @@ -0,0 +1,11 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { ORCAD_CLI_ENTRY_FILENAME, orcadCliLauncherFilename } from '../../shared/orcad-artifacts' +import { resolveOrcadInstallRoot } from './orcad-app-paths' + +export function resolveOrcadCliLauncher(directory = resolveOrcadInstallRoot()): string | null { + const launcher = join(directory, orcadCliLauncherFilename(process.platform)) + return existsSync(join(directory, ORCAD_CLI_ENTRY_FILENAME)) && existsSync(launcher) + ? launcher + : null +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index b89c813774fc..ed8731b607ea 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -12,6 +12,7 @@ */ import process from 'node:process' import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' +import { resolveOrcadCliLauncher } from './orcad-cli-launcher' import { setSecretStore, type SecretStore } from '../../shared/secret-store' import type { ServeReadiness } from '../server/serve-readiness' import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' @@ -59,6 +60,7 @@ function createNodeAppEnvironment(): AppEnvironment { return { getPath: resolveOrcadPath, getAppPath: () => resolveOrcadInstallRoot(), + getCliLauncherPath: () => resolveOrcadCliLauncher(), getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad', // Why still true: consumers read this as "production build, not a dev checkout" — // it gates HTTPS-only skill downloads, the real CLI command name, and shell-PATH diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 095aad2a8a5c..1ca2ff661cca 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -57,6 +57,29 @@ afterEach(() => { }) describe('structuredSessionChildIdentityEnv', () => { + it.each(['linux', 'darwin', 'win32'] as const)( + 'gives a plain-Node %s child the execution host launcher', + (platform) => { + pinPlatform(platform) + Object.defineProperty(process, 'resourcesPath', { configurable: true, value: undefined }) + const launcher = join('/server', 'bin', platform === 'win32' ? 'orca.exe' : 'orca') + installFakeAppEnvironment({ + isPackaged: () => true, + getPath: () => USER_DATA, + getCliLauncherPath: () => launcher + }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { + PATH: '/task/bin', + ORCA_CLI_COMMAND: '/other/orca', + ORCA_USER_DATA_PATH: '/other/profile' + }) + expect(env.ORCA_CLI_COMMAND).toBe(launcher) + expect(env.ORCA_USER_DATA_PATH).toBe(USER_DATA) + expect(env.PATH).toBe(`${join('/server', 'bin')}${platform === 'win32' ? ';' : ':'}/task/bin`) + expect(env.ORCA_AGENT_SESSION_ID).toBe(SESSION_ID) + } + ) + it("gives an ordinary chat session its own id and this app's CLI, and no terminal identity", () => { // The id names the caller, so a bare `orca orchestration check` acts as this session instead of // guessing a terminal — every guess landed on a sibling pane, and `check` consumed its mail. diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index a7026483f7a5..21ffc3710c4b 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -81,7 +81,8 @@ function applyThisAppCli(env: Record): void { const launcher = prependOrcaCliDirToChildPath(env, { isPackaged, userDataPath, - resourcesPath: process.resourcesPath ?? null + resourcesPath: process.resourcesPath ?? null, + launcherPath: app.getCliLauncherPath?.() }) if (launcher) { env.ORCA_CLI_COMMAND = launcher diff --git a/src/main/ssh/orcad-artifact-materializer.test.ts b/src/main/ssh/orcad-artifact-materializer.test.ts index 301d023b6684..557c45774999 100644 --- a/src/main/ssh/orcad-artifact-materializer.test.ts +++ b/src/main/ssh/orcad-artifact-materializer.test.ts @@ -164,6 +164,7 @@ describe('assembleOrcadArtifact', () => { const target = 'darwin-arm64' as const const fixture = createTemplate(target) const executables = [ + 'bin/orca', orcadRipgrepArtifact(target), 'node_modules/node-pty/build/Release/spawn-helper' ] diff --git a/src/main/ssh/orcad-artifact-materializer.ts b/src/main/ssh/orcad-artifact-materializer.ts index f9b95c38c2bf..423f487cd62e 100644 --- a/src/main/ssh/orcad-artifact-materializer.ts +++ b/src/main/ssh/orcad-artifact-materializer.ts @@ -133,7 +133,7 @@ export async function assembleOrcadArtifact(args: { } function isExecutableArtifact(filename: string): boolean { - return /(?:^|\/)(?:rg|spawn-helper)$/.test(filename) + return /(?:^|\/)(?:rg|spawn-helper|orca)$/.test(filename) } function artifactSources( diff --git a/src/main/ssh/orcad-remote-install.ts b/src/main/ssh/orcad-remote-install.ts index 44feffaef232..e5ffdd8303bc 100644 --- a/src/main/ssh/orcad-remote-install.ts +++ b/src/main/ssh/orcad-remote-install.ts @@ -1,5 +1,9 @@ import { randomUUID } from 'node:crypto' -import { orcadNodePtyNativeArtifacts, orcadRipgrepArtifact } from '../../shared/orcad-artifacts' +import { + orcadCliLauncherFilename, + orcadNodePtyNativeArtifacts, + orcadRipgrepArtifact +} from '../../shared/orcad-artifacts' import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name' import type { NodeRuntimeTarget } from '../../shared/node-runtime-pin' import { ensureRemoteOrcadNodeRuntime } from './orcad-remote-node-runtime' @@ -122,8 +126,12 @@ function executablePermissionsCommand( directory: string, target: NodeRuntimeTarget ): string { - const required = [orcadRipgrepArtifact(target), ...orcadNodePtyNativeArtifacts(target)] - .filter((artifact) => /\/(?:rg|spawn-helper)$/.test(artifact)) + const required = [ + orcadCliLauncherFilename(host.os), + orcadRipgrepArtifact(target), + ...orcadNodePtyNativeArtifacts(target) + ] + .filter((artifact) => /\/(?:rg|spawn-helper|orca)$/.test(artifact)) .map((artifact) => joinRemotePath(host, directory, ...artifact.split('/'))) const browsers = new Set( (['glibc', 'musl'] as const).map((libc) => diff --git a/src/shared/app-environment.ts b/src/shared/app-environment.ts index 86aca4f8182d..dd99a47d3010 100644 --- a/src/shared/app-environment.ts +++ b/src/shared/app-environment.ts @@ -22,6 +22,8 @@ export type AppEnvironment = { getPath(name: AppPathName): string /** Mirrors electron `app.getAppPath()` — the install/bundle root. */ getAppPath(): string + /** The execution host's CLI launcher, when its layout differs from Electron resources. */ + getCliLauncherPath?(): string | null getVersion(): string isPackaged(): boolean /** Shutdown hook: electron `will-quit`, or SIGTERM/SIGINT on a Node host. */ diff --git a/src/shared/orcad-artifacts.test.ts b/src/shared/orcad-artifacts.test.ts index 33462dcb95a5..57a0c4d79ad5 100644 --- a/src/shared/orcad-artifacts.test.ts +++ b/src/shared/orcad-artifacts.test.ts @@ -5,6 +5,7 @@ import { BUNDLED_RIPGREP_PLATFORMS, bundledRipgrepBinaryName } from './bundled-r import { SERVER_TARGETS } from './node-runtime-pin' import { ORCAD_BUILD_TARGET_FILENAME, + ORCAD_CLI_ENTRY_FILENAME, ORCAD_FOREIGN_SQLITE_READER_ENTRY, ORCAD_NODE_PTY_JS_ARTIFACTS, ORCAD_NODE_RUNTIME_MARKER_FILENAME, @@ -17,6 +18,14 @@ import { } from './orcad-artifacts' describe('standalone runtime artifacts', () => { + it.each(SERVER_TARGETS)('ships a CLI and host launcher for %s', (target) => { + expect(orcadArtifactFilenames(target)).toContain(ORCAD_CLI_ENTRY_FILENAME) + expect(orcadArtifactFilenames(target)).toContain( + target.startsWith('win32-') ? 'bin/orca.exe' : 'bin/orca' + ) + expect(orcadTemplateCommonFilenames()).toContain(ORCAD_CLI_ENTRY_FILENAME) + }) + it.each(SERVER_TARGETS)( 'ships the foreign SQLite reader worker the %s runtime starts', (target) => { diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 33b3e963c913..6adfdf7f9b7b 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -102,6 +102,11 @@ export const ORCAD_EMOJI_SHORTCODE_DATASET = export const ORCAD_VERSION = '0.1.0' export const ORCAD_LAUNCHER_FILENAME = 'orcad.js' export const ORCAD_SERVER_ENTRY_FILENAME = 'orcad-server.js' +export const ORCAD_CLI_ENTRY_FILENAME = 'orca-cli.js' + +export function orcadCliLauncherFilename(platform: string): string { + return platform === 'win32' ? 'bin/orca.exe' : 'bin/orca' +} // Equals FOREIGN_SQLITE_READER_ENTRY_FILENAME; that module is not loadable under type stripping. export const ORCAD_FOREIGN_SQLITE_READER_ENTRY = 'foreign-sqlite-reader-entry.js' @@ -161,6 +166,7 @@ export type OrcadArtifact = { export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [ { filename: ORCAD_LAUNCHER_FILENAME }, { filename: ORCAD_SERVER_ENTRY_FILENAME }, + { filename: ORCAD_CLI_ENTRY_FILENAME }, // Forked so a native @parcel/watcher fault kills the child, not the server. { filename: 'parcel-watcher-process-entry.js' }, // Forked so PTYs outlive the runtime process; its absence makes every restart destructive. @@ -195,7 +201,11 @@ export function orcadArtifactFilenames(target: string): string[] { const filenames = ORCAD_ARTIFACTS.filter((artifact) => !artifact.optional).map( (artifact) => artifact.filename ) - filenames.push(...orcadNodePtyNativeArtifacts(target), orcadRipgrepArtifact(target)) + filenames.push( + ...orcadNodePtyNativeArtifacts(target), + orcadRipgrepArtifact(target), + orcadCliLauncherFilename(target.split('-')[0]) + ) if (isWindowsTarget(target)) { filenames.push(ORCAD_WINDOWS_PROCESS_TREE_FILENAME) } From ff26c97236b0100ef801604774ec13ce5d6fb9f5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:00:57 -0700 Subject: [PATCH 03/21] test: include the shipped CLI in remote install fixtures --- src/main/ssh/orcad-remote-deploy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index 6742623a91ab..ea392d5e9202 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -447,7 +447,7 @@ describe('deployOrcad', () => { 'restores uploaded executable modes with optional browser %s', async (browserTarget) => { const directory = mkdtempSync(join(tmpdir(), 'orcad-install-modes-')) - const binaries = ['ripgrep/linux-x64/rg'] + const binaries = ['ripgrep/linux-x64/rg', 'bin/orca'] if (browserTarget) { binaries.push(`agent-browser-${browserTarget}`) } From 478c03fe96794d361e2ca87d226f59d18332feef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:43:06 -0700 Subject: [PATCH 04/21] Bind packaged agent CLI calls to their execution host --- .../build-windows-cli-launcher.test.mjs | 8 +- config/scripts/node-server-test-paths.mjs | 1 + config/scripts/orcad-cli-package.mjs | 13 +- config/scripts/orcad-cli-package.test.mjs | 5 +- native/windows-cli-launcher/src/main.rs | 5 +- src/cli/cli-version.test.ts | 20 ++- src/cli/cli-version.ts | 17 ++- .../handlers/orchestration-host-cli.test.ts | 44 ++++++ .../orchestration/runtime-compatibility.ts | 5 + .../index-local-command-routing-flags.test.ts | 11 ++ src/cli/index-test-harness.ts | 15 +- src/cli/index.ts | 6 +- src/cli/runtime/client-execution-host.test.ts | 51 +++++++ src/cli/runtime/client.ts | 5 +- src/main/ipc/pty/host-env/assembly.ts | 9 +- .../orcad-cli-launcher.integration.test.ts | 13 +- ...rcad-cli-orchestration.integration.test.ts | 139 ++++++++++++++++++ ...ructured-agent-session-integration.test.ts | 11 +- ...uctured-session-child-identity-env.test.ts | 1 + .../structured-session-child-identity-env.ts | 7 +- src/shared/cli-execution-host-env.ts | 23 +++ 21 files changed, 377 insertions(+), 32 deletions(-) create mode 100644 src/cli/handlers/orchestration-host-cli.test.ts create mode 100644 src/cli/runtime/client-execution-host.test.ts create mode 100644 src/main/orcad/orcad-cli-orchestration.integration.test.ts create mode 100644 src/shared/cli-execution-host-env.ts diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index e4d5e641f624..ddd46537511e 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -198,7 +198,7 @@ describe('Windows CLI launcher', () => { writeFileSync(join(slot, '.runtime-node'), `${'a'.repeat(64)}\n`) writeFileSync( join(slot, 'orca-cli.js'), - 'console.log(JSON.stringify({argv:process.argv.slice(2),profile:process.env.ORCA_USER_DATA_PATH,launcher:process.env.ORCA_CLI_COMMAND}))' + 'console.log(JSON.stringify({argv:process.argv.slice(2),profile:process.env.ORCA_USER_DATA_PATH,launcher:process.env.ORCA_CLI_COMMAND,owningHost:process.env.ORCA_CLI_OWNING_HOST,desktopMarker:process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER??null}))' ) const build = spawnSync( process.execPath, @@ -209,13 +209,15 @@ describe('Windows CLI launcher', () => { const body = 'line one\n\nline two' const child = spawnSync(launcher, ['orchestration', 'send', '--body', body], { encoding: 'utf8', - env: { ...process.env, ORCA_USER_DATA_PATH: root } + env: { ...process.env, ORCA_USER_DATA_PATH: root, ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER: '1' } }) expect(child.status, child.stderr).toBe(0) expect(JSON.parse(child.stdout)).toEqual({ argv: ['orchestration', 'send', '--body', body], profile: root, - launcher + launcher, + owningHost: '1', + desktopMarker: null }) } finally { removeFixtureTree(root) diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs index 36a66e628a50..f631adbcd551 100644 --- a/config/scripts/node-server-test-paths.mjs +++ b/config/scripts/node-server-test-paths.mjs @@ -32,6 +32,7 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } = 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', 'src/main/orcad/orcad-node-launcher.integration.test.ts', 'src/main/orcad/orcad-cli-launcher.integration.test.ts', + 'src/main/orcad/orcad-cli-orchestration.integration.test.ts', 'src/main/orcad/orcad-launcher-isolation.integration.test.ts', 'src/main/orcad/orcad-stop-request-shutdown.integration.test.ts', 'src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts', diff --git a/config/scripts/orcad-cli-package.mjs b/config/scripts/orcad-cli-package.mjs index 95e29e3fa81b..686a2ec0f02a 100644 --- a/config/scripts/orcad-cli-package.mjs +++ b/config/scripts/orcad-cli-package.mjs @@ -1,5 +1,5 @@ import { build } from 'esbuild' -import { chmodSync, copyFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { ORCAD_CLI_ENTRY_FILENAME, @@ -7,11 +7,13 @@ import { } from '../../src/shared/orcad-artifacts.ts' import { externalNativeAddons } from './orcad-entry-build.mjs' import { runProcessSync } from './script-child-process.mjs' +import { ORCA_CLI_OWNING_HOST_ENV } from '../../src/shared/cli-execution-host-env.ts' export function orcadPosixCliLauncher() { return `#!/bin/sh set -eu : "\${ORCA_USER_DATA_PATH:?The Orca server data path is required}" +export ${ORCA_CLI_OWNING_HOST_ENV}=1 slot=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) runtime_sha=$(cat "$slot/.runtime-node") case "$runtime_sha" in @@ -24,6 +26,10 @@ exec "$slot/../runtimes/node-$runtime_sha/bin/node" "$slot/${ORCAD_CLI_ENTRY_FIL } export async function buildOrcadCli(root, outputDir, target) { + const version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version + if (typeof version !== 'string' || version.length === 0) { + throw new Error('The Orca server CLI build version is missing') + } const result = await build({ entryPoints: [join(root, 'src/cli/index.ts')], bundle: true, @@ -35,7 +41,10 @@ export async function buildOrcadCli(root, outputDir, target) { metafile: true, minify: true, sourcemap: false, - define: { 'process.env.NODE_ENV': '"production"' }, + define: { + 'process.env.NODE_ENV': '"production"', + __ORCA_CLI_BUILD_VERSION__: JSON.stringify(version) + }, logLevel: 'error' }) const [platform, arch] = target.split('-') diff --git a/config/scripts/orcad-cli-package.test.mjs b/config/scripts/orcad-cli-package.test.mjs index 9731cf7ce4ec..3cbf2098760a 100644 --- a/config/scripts/orcad-cli-package.test.mjs +++ b/config/scripts/orcad-cli-package.test.mjs @@ -21,7 +21,7 @@ it.skipIf(process.platform === 'win32')( chmodSync(launcher, 0o755) writeFileSync( join(slot, 'orca-cli.js'), - `console.log(JSON.stringify({argv: process.argv.slice(2), runtime: process.execPath, profile: process.env.ORCA_USER_DATA_PATH, options: process.env.NODE_OPTIONS ?? null}))` + `console.log(JSON.stringify({argv: process.argv.slice(2), runtime: process.execPath, profile: process.env.ORCA_USER_DATA_PATH, options: process.env.NODE_OPTIONS ?? null, owningHost: process.env.ORCA_CLI_OWNING_HOST}))` ) const body = 'paragraph one\n\nparagraph two "$variable" `literal`' const env = { @@ -38,7 +38,8 @@ it.skipIf(process.platform === 'win32')( argv: ['orchestration', 'send', '--body', body], runtime: process.execPath, profile: root, - options: null + options: null, + owningHost: '1' }) const unbound = spawnSync(launcher, ['status', '--json'], { env: { PATH: env.PATH }, diff --git a/native/windows-cli-launcher/src/main.rs b/native/windows-cli-launcher/src/main.rs index c8a68f9b93d2..5b3397fe8f8a 100644 --- a/native/windows-cli-launcher/src/main.rs +++ b/native/windows-cli-launcher/src/main.rs @@ -93,8 +93,11 @@ fn main() { ); if !headless { env::set_var("ELECTRON_RUN_AS_NODE", "1"); + env::set_var("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1"); + } else { + env::remove_var("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER"); + env::set_var("ORCA_CLI_OWNING_HOST", "1"); } - env::set_var("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1"); let requested_command = env::var("ORCA_CLI_COMMAND").unwrap_or_default(); env::set_var( "ORCA_CLI_COMMAND", diff --git a/src/cli/cli-version.test.ts b/src/cli/cli-version.test.ts index 6ffe60692fb2..e29bc56b0604 100644 --- a/src/cli/cli-version.test.ts +++ b/src/cli/cli-version.test.ts @@ -1,16 +1,28 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { readOrcaCliVersion } from './cli-version' const temporaryDirectories: string[] = [] -afterEach(() => - Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))) -) +afterEach(async () => { + vi.unstubAllGlobals() + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))) +}) describe('CLI version', () => { + it('uses the embedded host build version without a parent package boundary', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-server-cli-version-')) + const runtimeDir = join(root, 'slot') + temporaryDirectories.push(root) + await mkdir(runtimeDir) + vi.stubGlobal('__ORCA_CLI_BUILD_VERSION__', '1.4.214') + expect(readOrcaCliVersion(runtimeDir)).toBe('1.4.214') + await writeFile(join(root, 'package.json'), JSON.stringify({ version: 'unrelated' })) + expect(readOrcaCliVersion(runtimeDir)).toBe('1.4.214') + }) + it('reads the package boundary beside the compiled CLI', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-cli-version-')) const runtimeDir = join(root, 'cli') diff --git a/src/cli/cli-version.ts b/src/cli/cli-version.ts index 0918ec763fd6..b1103d54d4ea 100644 --- a/src/cli/cli-version.ts +++ b/src/cli/cli-version.ts @@ -1,13 +1,22 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' +declare const __ORCA_CLI_BUILD_VERSION__: string | undefined + // Node-mode CLI code cannot read the package metadata inside app.asar. export function readOrcaCliVersion(runtimeDir = __dirname): string | null { + if (typeof __ORCA_CLI_BUILD_VERSION__ === 'string' && __ORCA_CLI_BUILD_VERSION__.length > 0) { + return __ORCA_CLI_BUILD_VERSION__ + } try { - const parsed = JSON.parse(readFileSync(join(runtimeDir, '..', 'package.json'), 'utf8')) as { - version?: unknown - } - return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : null + const parsed: unknown = JSON.parse(readFileSync(join(runtimeDir, '..', 'package.json'), 'utf8')) + return typeof parsed === 'object' && + parsed !== null && + 'version' in parsed && + typeof parsed.version === 'string' && + parsed.version.length > 0 + ? parsed.version + : null } catch { return null } diff --git a/src/cli/handlers/orchestration-host-cli.test.ts b/src/cli/handlers/orchestration-host-cli.test.ts new file mode 100644 index 000000000000..78c8fe7da31b --- /dev/null +++ b/src/cli/handlers/orchestration-host-cli.test.ts @@ -0,0 +1,44 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { ORCHESTRATION_HANDLERS } from './orchestration' +import { RuntimeClient } from '../runtime-client' + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +it.each(['question', 'resume'])( + 'an owning-host Windows launcher delivers orchestration ask --%s', + async (kind) => { + vi.stubEnv('ORCA_CLI_COMMAND', 'C:\\orca-host\\bin\\orca.exe') + vi.stubEnv('ORCA_CLI_OWNING_HOST', '1') + vi.stubEnv('ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER', '1') + vi.stubEnv('ORCA_AGENT_SESSION_ID', '') + vi.stubEnv('ORCA_STRUCTURED_SESSION', '') + const client = new RuntimeClient('/isolated', 1000, null, null) + const call = vi.spyOn(client, 'call').mockResolvedValue({ + id: 'req_ask', + ok: true, + result: { answer: 'yes', messageId: 'msg_1', threadId: 'msg_1', timedOut: false }, + _meta: { runtimeId: 'runtime-owner' } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + await ORCHESTRATION_HANDLERS['orchestration ask']({ + flags: new Map([ + ['from', 'term_worker'], + [kind, kind === 'question' ? 'Proceed?' : 'msg_1'] + ]), + client, + cwd: '/isolated', + json: true + }) + expect(call).toHaveBeenCalledWith( + 'orchestration.ask', + expect.objectContaining({ + [kind]: kind === 'question' ? 'Proceed?' : 'msg_1', + compatibilityWindowsCommand: undefined + }), + expect.any(Object) + ) + } +) diff --git a/src/cli/handlers/orchestration/runtime-compatibility.ts b/src/cli/handlers/orchestration/runtime-compatibility.ts index e4076534daa8..3cb161503a01 100644 --- a/src/cli/handlers/orchestration/runtime-compatibility.ts +++ b/src/cli/handlers/orchestration/runtime-compatibility.ts @@ -1,4 +1,6 @@ import { RuntimeClientError } from '../../runtime-client' +import { win32 } from 'node:path' +import { ORCA_CLI_OWNING_HOST_ENV } from '../../../shared/cli-execution-host-env' export function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' { const configured = process.env.ORCA_CLI_COMMAND @@ -16,6 +18,9 @@ export function resolvePackagedWindowsCompatibilityCommand(): 'orca' | 'orca-ide if (command === 'orca' || command === 'orca-ide') { return command } + if (process.env[ORCA_CLI_OWNING_HOST_ENV] === '1' && command && win32.isAbsolute(command)) { + return undefined + } throw new RuntimeClientError( 'invalid_argument', 'The packaged Orca launcher did not provide a valid resume command. No question was created.' diff --git a/src/cli/index-local-command-routing-flags.test.ts b/src/cli/index-local-command-routing-flags.test.ts index bcd6ac6840ed..f6e9389fdfac 100644 --- a/src/cli/index-local-command-routing-flags.test.ts +++ b/src/cli/index-local-command-routing-flags.test.ts @@ -156,6 +156,17 @@ describe('runtime-selector flags on locally pinned CLI commands', () => { expect(runtimeClientConstructorMock).toHaveBeenCalledWith(undefined, 'm4air') }) + it('preserves explicit routing for an owning-host invocation with a stale ambient selector', async () => { + process.env.ORCA_CLI_OWNING_HOST = '1' + process.env.ORCA_ENVIRONMENT = 'stale-shell-selection' + pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air') + fakeMachineNameRuntime('M4 Air') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + await main(['host', 'name', '--environment', 'm4air', '--json'], '/tmp/repo') + expect(JSON.parse(String(log.mock.calls[0]?.[0])).result.machineName).toBe('M4 Air') + expect(runtimeClientConstructorMock).toHaveBeenCalledWith(undefined, 'm4air') + }) + it('routes `host name` through an ambient ORCA_ENVIRONMENT, unlike the pinned `host list`', async () => { // Why: the pin used to cover the whole `host` family, which silently answered for this machine // when the shell was pointed at another one. `host name` describes one runtime, so it routes. diff --git a/src/cli/index-test-harness.ts b/src/cli/index-test-harness.ts index bb8c2d613667..7333cdcb40e4 100644 --- a/src/cli/index-test-harness.ts +++ b/src/cli/index-test-harness.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, vi, type Mock } from 'vitest' +import { readCliRemoteSelectionDefaults } from '../shared/cli-execution-host-env' export type RuntimeClientModuleMocks = { callMock: Mock @@ -36,12 +37,11 @@ export async function createRuntimeClientModuleMock(mocks: RuntimeClientModuleMo environmentSelector?: string | null ) { mocks.runtimeClientConstructorMock(remotePairingCode, environmentSelector) + const defaults = readCliRemoteSelectionDefaults() const effectivePairingCode = - remotePairingCode === undefined - ? (process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING) - : remotePairingCode + remotePairingCode === undefined ? defaults.pairingCode : remotePairingCode const effectiveEnvironment = - environmentSelector === undefined ? process.env.ORCA_ENVIRONMENT : environmentSelector + environmentSelector === undefined ? defaults.environment : environmentSelector if (effectivePairingCode && effectiveEnvironment) { throw new RuntimeClientError( 'invalid_argument', @@ -116,6 +116,7 @@ export function useWorktreeAwarenessEnvironment(mocks: WorktreeAwarenessMocks): const originalPairingCode = process.env.ORCA_PAIRING_CODE const originalRemotePairing = process.env.ORCA_REMOTE_PAIRING const originalEnvironment = process.env.ORCA_ENVIRONMENT + const originalOwningHost = process.env.ORCA_CLI_OWNING_HOST const originalWorkspaceId = process.env.ORCA_WORKSPACE_ID const originalWorktreeId = process.env.ORCA_WORKTREE_ID @@ -123,6 +124,7 @@ export function useWorktreeAwarenessEnvironment(mocks: WorktreeAwarenessMocks): mocks.callMock.mockReset() delete process.env.ORCA_TERMINAL_HANDLE delete process.env.ORCA_USER_DATA_PATH + delete process.env.ORCA_CLI_OWNING_HOST delete process.env.ORCA_DEV_CLI_INVOCATION delete process.env.ORCA_WORKSPACE_ID delete process.env.ORCA_WORKTREE_ID @@ -188,6 +190,11 @@ export function useWorktreeAwarenessEnvironment(mocks: WorktreeAwarenessMocks): } else { process.env.ORCA_ENVIRONMENT = originalEnvironment } + if (originalOwningHost === undefined) { + delete process.env.ORCA_CLI_OWNING_HOST + } else { + process.env.ORCA_CLI_OWNING_HOST = originalOwningHost + } if (originalWorkspaceId === undefined) { delete process.env.ORCA_WORKSPACE_ID } else { diff --git a/src/cli/index.ts b/src/cli/index.ts index be5277278766..3ae88194cbd1 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,6 +9,7 @@ import { validateCommandAndFlags } from './args' import { readOrcaCliVersion } from './cli-version' +import { readCliRemoteSelectionDefaults } from '../shared/cli-execution-host-env' import { dispatch } from './dispatch' import { assertEnvironmentSelectorResolvable, @@ -120,6 +121,7 @@ export async function main( const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code') const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment') + const ambientSelection = readCliRemoteSelectionDefaults() // Why: only the explicit flag is asserted eagerly. An ambient ORCA_ENVIRONMENT is background // config, and failing local-only commands because of a stale one would be a regression; the // explicit flag means the caller named that machine, so a bad name should fail immediately @@ -145,8 +147,8 @@ export async function main( environmentSelector: typeof environmentSelector === 'string' ? { value: environmentSelector, label: '--environment' } - : process.env.ORCA_ENVIRONMENT - ? { value: process.env.ORCA_ENVIRONMENT, label: 'ORCA_ENVIRONMENT' } + : ambientSelection.environment + ? { value: ambientSelection.environment, label: 'ORCA_ENVIRONMENT' } : null }) // Why: --host runtime: is canonicalized to the environment's id so downstream host-id diff --git a/src/cli/runtime/client-execution-host.test.ts b/src/cli/runtime/client-execution-host.test.ts new file mode 100644 index 000000000000..35833168ee22 --- /dev/null +++ b/src/cli/runtime/client-execution-host.test.ts @@ -0,0 +1,51 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { RuntimeClient } from './client' +import { addEnvironmentFromPairingCode } from './environments' +import { encodePairingOffer } from '../../shared/pairing' + +const roots: string[] = [] +const pairingCode = encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'fixture-token', + publicKeyB64: 'fixture-key', + scope: 'runtime' +}) + +afterEach(() => { + vi.unstubAllEnvs() + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) + +function owningHost(): string { + const root = mkdtempSync(join(tmpdir(), 'cli-owner-')) + roots.push(root) + vi.stubEnv('ORCA_CLI_OWNING_HOST', '1') + vi.stubEnv('ORCA_ENVIRONMENT', undefined) + vi.stubEnv('ORCA_PAIRING_CODE', undefined) + vi.stubEnv('ORCA_REMOTE_PAIRING', undefined) + return root +} + +it.each(['ORCA_ENVIRONMENT', 'ORCA_PAIRING_CODE', 'ORCA_REMOTE_PAIRING'])( + 'an owning-host invocation ignores inherited %s', + (key) => { + const root = owningHost() + vi.stubEnv(key, 'stale-shell-selection') + expect(new RuntimeClient(root).isRemote).toBe(false) + } +) + +it('ignores a valid paired default while preserving explicit targeting and standalone defaults', () => { + const root = owningHost() + addEnvironmentFromPairingCode(root, { name: 'other-server', pairingCode }) + vi.stubEnv('ORCA_ENVIRONMENT', 'other-server') + expect(new RuntimeClient(root).isRemote).toBe(false) + expect(new RuntimeClient(root, undefined, null, 'other-server').isRemote).toBe(true) + expect(new RuntimeClient(root, undefined, pairingCode, null).isRemote).toBe(true) + vi.stubEnv('ORCA_CLI_OWNING_HOST', undefined) + expect(new RuntimeClient(root).isRemote).toBe(true) +}) diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 5efe54c7ba35..13b62051c512 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { readCliRemoteSelectionDefaults } from '../../shared/cli-execution-host-env' import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' @@ -63,8 +64,8 @@ export class RuntimeClient { constructor( userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 60_000, - remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null, - environmentSelector = process.env.ORCA_ENVIRONMENT ?? null, + remotePairingCode = readCliRemoteSelectionDefaults().pairingCode, + environmentSelector = readCliRemoteSelectionDefaults().environment, cliExecutable = resolveOrchestrationCliExecutable(), originalArgs?: readonly string[] ) { diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 2a0bc0178eae..2ea3ce0172d9 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -5,6 +5,10 @@ import { } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' import { getAppEnvironment } from '../../../../shared/app-environment' +import { + bindOrcaCliToExecutionHost, + ORCA_CLI_OWNING_HOST_ENV +} from '../../../../shared/cli-execution-host-env' import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer' import { mimoCodeHookService } from '../../../mimo/hook-service' import { agentHookServer } from '../../../agent-hooks/server' @@ -264,6 +268,7 @@ export function buildPtyHostEnv( // Why: an inherited copy (e.g. Orca launched from a WSL pane) names another launch's CLI. delete baseEnv.ORCA_WSL_CLI_DIR + delete baseEnv[ORCA_CLI_OWNING_HOST_ENV] // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. if (opts.isWsl) { baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath @@ -286,9 +291,9 @@ export function buildPtyHostEnv( resourcesPath: opts.resourcesPath, launcherPath: hostLauncher }) - if (hostLauncher && launcher) { + if (!opts.isWsl && launcher) { baseEnv.ORCA_CLI_COMMAND = launcher - baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath + bindOrcaCliToExecutionHost(baseEnv, opts.userDataPath) } if ( diff --git a/src/main/orcad/orcad-cli-launcher.integration.test.ts b/src/main/orcad/orcad-cli-launcher.integration.test.ts index 062b404751d5..d527880caf16 100644 --- a/src/main/orcad/orcad-cli-launcher.integration.test.ts +++ b/src/main/orcad/orcad-cli-launcher.integration.test.ts @@ -95,7 +95,10 @@ it.skipIf(skip)( getCliLauncherPath: () => launcher }) const childEnv = structuredSessionChildIdentityEnv('claude_test-session', { - PATH: env.PATH ?? '' + PATH: env.PATH ?? '', + ORCA_ENVIRONMENT: 'stale-shell-selection', + ORCA_PAIRING_CODE: 'stale-pairing', + ORCA_REMOTE_PAIRING: 'stale-remote-pairing' }) const result = await runProcess({ program: childEnv.ORCA_CLI_COMMAND!, @@ -120,6 +123,14 @@ it.skipIf(skip)( }) expect(skill.code, skill.stderr).toBe(0) expect(skill.stdout).toContain('Worker obligations') + const version = await runProcess({ + program: childEnv.ORCA_CLI_COMMAND!, + args: ['--version'], + env: { ...env, ...childEnv }, + timeoutMs: 15_000 + }) + expect(version.code, version.stderr).toBe(0) + expect(version.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/u) } finally { await killChildAndWait(child) await killProfileDaemons(userData) diff --git a/src/main/orcad/orcad-cli-orchestration.integration.test.ts b/src/main/orcad/orcad-cli-orchestration.integration.test.ts new file mode 100644 index 000000000000..32eebef416b0 --- /dev/null +++ b/src/main/orcad/orcad-cli-orchestration.integration.test.ts @@ -0,0 +1,139 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { resolveOrcadCliLauncher } from './orcad-cli-launcher' +import { skipForMissingInputs } from './orcad-node-slot-fixture' + +const launcher = resolveOrcadCliLauncher(resolve('out/orcad')) +const skip = skipForMissingInputs('artifact', launcher ? [] : ['the packaged server CLI']) + +it.skipIf(skip)( + 'the packaged launcher delivers ask and resume RPCs to its owning host', + async () => { + const root = mkdtempSync(join(tmpdir(), 'cli-rpc-')) + const endpoint = + process.platform === 'win32' ? `\\\\.\\pipe\\cli-${randomUUID()}` : join(root, 'rpc') + const requests: { method: string; params: unknown }[] = [] + const server = createServer((socket) => { + let buffer = '' + socket.setEncoding('utf8') + socket.on('data', (chunk: string) => { + buffer += chunk + const newline = buffer.indexOf('\n') + if (newline === -1) { + return + } + const request: unknown = JSON.parse(buffer.slice(0, newline)) + if ( + typeof request !== 'object' || + request === null || + !('id' in request) || + !('method' in request) + ) { + return + } + if (typeof request.method !== 'string') { + return + } + requests.push({ + method: request.method, + params: 'params' in request ? request.params : null + }) + socket.end( + `${JSON.stringify({ + id: request.id, + ok: true, + result: + request.method === 'status.get' + ? { + capabilities: [ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY], + graphStatus: 'ready' + } + : { answer: 'yes', messageId: 'msg_1', threadId: 'msg_1', timedOut: false }, + _meta: { runtimeId: 'runtime-owner' } + })}\n` + ) + }) + }) + try { + await new Promise((ready) => server.listen(endpoint, ready)) + writeFileSync( + join(root, 'orca-runtime.json'), + JSON.stringify({ + runtimeId: 'runtime-owner', + pid: process.pid, + startedAt: 1, + authToken: 'fixture-token', + transports: [{ kind: process.platform === 'win32' ? 'named-pipe' : 'unix', endpoint }] + }) + ) + const home = join(root, 'home') + mkdirSync(home) + const env: NodeJS.ProcessEnv = { + HOME: home, + USERPROFILE: home, + APPDATA: home, + LOCALAPPDATA: home, + SystemRoot: process.env.SystemRoot, + ComSpec: process.env.ComSpec, + PATH: process.platform === 'win32' ? process.env.PATH : '/usr/bin:/bin', + TEMP: root, + TMP: root, + TMPDIR: root, + CODEX_HOME: join(home, 'codex'), + CLAUDE_CONFIG_DIR: join(home, 'claude'), + ORCA_USER_DATA_PATH: root, + ORCA_CLI_COMMAND: launcher!, + ORCA_ENVIRONMENT: 'stale-shell-selection', + ORCA_PAIRING_CODE: 'stale-pairing', + ORCA_BACKGROUND_LAUNCH: '1', + ...(process.platform === 'win32' ? { ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER: '1' } : {}) + } + for (const [flag, value] of [ + ['--question', 'Proceed?'], + ['--resume', 'msg_1'] + ]) { + const result = await runProcess({ + program: launcher!, + args: ['orchestration', 'ask', '--from', 'term_worker', flag, value, '--json'], + env, + cwd: home, + timeoutMs: 15_000 + }) + expect(result.code, `${result.stderr}\n${result.stdout}`).toBe(0) + expect(JSON.parse(result.stdout)).toMatchObject({ result: { answer: 'yes' } }) + } + expect(requests.filter((request) => request.method === 'orchestration.ask')).toMatchObject([ + { params: { question: 'Proceed?' } }, + { params: { resume: 'msg_1' } } + ]) + for (const request of requests.filter((request) => request.method === 'orchestration.ask')) { + expect(request.params).not.toHaveProperty('compatibilityWindowsCommand') + } + const version = await runProcess({ + program: launcher!, + args: ['--version'], + env, + timeoutMs: 15_000 + }) + const packageMetadata: unknown = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) + expect(version.code, version.stderr).toBe(0) + expect(version.stdout.trim()).toBe( + typeof packageMetadata === 'object' && + packageMetadata !== null && + 'version' in packageMetadata + ? packageMetadata.version + : null + ) + } finally { + await new Promise((done) => server.close(() => done())) + rmSync(root, { recursive: true, force: true }) + } + }, + 45_000 +) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 92510aa3241a..65bd3559db95 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -238,6 +238,7 @@ let dispatcher: RpcDispatcher let bootEnvironmentReads: number let codexOverrideReads: number let configuredCodexProfile: string +let shellExportedToken: string let shellEnvironmentPolicy: NativeChatShellEnvironmentPolicy /** Runs a one-shot method and returns its decoded reply. */ @@ -324,6 +325,7 @@ beforeEach(async () => { bootEnvironmentReads = 0 codexOverrideReads = 0 configuredCodexProfile = 'configured' + shellExportedToken = 'shell-exported' shellEnvironmentPolicy = { inheritAll: true, names: [] } const runtime = { getRuntimeId: () => 'runtime-1', @@ -352,7 +354,7 @@ beforeEach(async () => { bootEnvironmentReads += 1 return { PATH: '/shell/bin:/usr/bin', - EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + EXAMPLE_GATEWAY_TOKEN: shellExportedToken, CODEX_HOME: '/shell/home' } }, @@ -740,7 +742,7 @@ describe('a structured codex session over agentSession.*', () => { ]) }) - it('caches shell exports but re-reads configured overrides for a resume', async () => { + it('refreshes shell exports and configured overrides for a resume', async () => { shellEnvironmentPolicy = { inheritAll: false, names: [] } const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) expect(codex.live().launch.env?.EXAMPLE_GATEWAY_TOKEN).toBeUndefined() @@ -750,6 +752,7 @@ describe('a structured codex session over agentSession.*', () => { }) configuredCodexProfile = 'updated' + shellExportedToken = 'repaired-shell-export' shellEnvironmentPolicy = { inheritAll: false, names: ['EXAMPLE_GATEWAY_TOKEN'] } const resumed = await ok<{ fence: number }>('agentSession.ensure', attachParams(created.fence)) @@ -757,10 +760,10 @@ describe('a structured codex session over agentSession.*', () => { expect(codex.live().resumedThreadId).toBe(THREAD) expect(codex.live().launch.env).toMatchObject({ CODEX_PROFILE: 'updated', - EXAMPLE_GATEWAY_TOKEN: 'shell-exported' + EXAMPLE_GATEWAY_TOKEN: 'repaired-shell-export' }) expect({ bootEnvironmentReads, codexOverrideReads }).toEqual({ - bootEnvironmentReads: 1, + bootEnvironmentReads: 2, codexOverrideReads: 2 }) }) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 1ca2ff661cca..54034775e16b 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -94,6 +94,7 @@ describe('structuredSessionChildIdentityEnv', () => { ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca'), ORCA_CLI_BIN_DIR: SHIM_DIR, + ORCA_CLI_OWNING_HOST: '1', // The instance that minted the id, so any current CLI dials it rather than the default. ORCA_USER_DATA_PATH: USER_DATA }) diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index 21ffc3710c4b..c789e06a6c67 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -47,6 +47,10 @@ import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' +import { + bindOrcaCliToExecutionHost, + ORCA_CLI_OWNING_HOST_ENV +} from '../../shared/cli-execution-host-env' import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' import { resolveStructuredWorkerIdentityForSession } from './structured-worker-authority' @@ -71,13 +75,14 @@ export function structuredSessionChildIdentityEnv( */ function applyThisAppCli(env: Record): void { delete env.ORCA_CLI_COMMAND + delete env[ORCA_CLI_OWNING_HOST_ENV] if (!hasAppEnvironment()) { return } const app = getAppEnvironment() const userDataPath = app.getPath('userData') const isPackaged = app.isPackaged() - env.ORCA_USER_DATA_PATH = userDataPath + bindOrcaCliToExecutionHost(env, userDataPath) const launcher = prependOrcaCliDirToChildPath(env, { isPackaged, userDataPath, diff --git a/src/shared/cli-execution-host-env.ts b/src/shared/cli-execution-host-env.ts new file mode 100644 index 000000000000..220364d3f740 --- /dev/null +++ b/src/shared/cli-execution-host-env.ts @@ -0,0 +1,23 @@ +export const ORCA_CLI_OWNING_HOST_ENV = 'ORCA_CLI_OWNING_HOST' + +export function bindOrcaCliToExecutionHost( + env: Record, + userDataPath: string +): void { + env.ORCA_USER_DATA_PATH = userDataPath + env[ORCA_CLI_OWNING_HOST_ENV] = '1' +} + +export function readCliRemoteSelectionDefaults(env: NodeJS.ProcessEnv = process.env): { + pairingCode: string | null + environment: string | null +} { + // Shell profiles cannot retarget a child's owning-server CLI; explicit flags still can. + if (env[ORCA_CLI_OWNING_HOST_ENV] === '1') { + return { pairingCode: null, environment: null } + } + return { + pairingCode: env.ORCA_PAIRING_CODE ?? env.ORCA_REMOTE_PAIRING ?? null, + environment: env.ORCA_ENVIRONMENT ?? null + } +} From 075aae77877f809ab130f807af479467e3735ae4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:34:38 -0700 Subject: [PATCH 05/21] Keep owning-server CLI binding scoped to managed agents --- src/main/codex/codex-structured-child-environment.test.ts | 5 +++++ src/main/codex/codex-structured-session-adapter.test.ts | 1 + src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts | 1 + src/main/ipc/pty/host-env/assembly.ts | 2 +- 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index 517f9effb90c..47564b28d458 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -40,6 +40,7 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: 'session-not-a-worker', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ORCA_CLI_OWNING_HOST: '1', ...(process.platform !== 'win32' ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) } : {}), @@ -65,6 +66,7 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: sessionId, ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ORCA_CLI_OWNING_HOST: '1', ...(process.platform !== 'win32' ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) } : {}), @@ -108,6 +110,7 @@ const ENV_REPORTING_APP_SERVER = String.raw` result: { sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null, cliCommand: process.env.ORCA_CLI_COMMAND ?? null, + owningHost: process.env.ORCA_CLI_OWNING_HOST ?? null, cliBinDir: process.env.ORCA_CLI_BIN_DIR ?? null, path: process.env.PATH ?? process.env.Path ?? null } @@ -125,6 +128,7 @@ describe('the spawned Codex child', () => { // The builder's output is an overlay on process.env, so only the spawned child proves the id // survives the merge — an Orca launched inside another session inherits that session's id. vi.stubEnv('ORCA_AGENT_SESSION_ID', 'a0b1c2d3-0000-4000-8000-00000000abcd') + vi.stubEnv('ORCA_CLI_OWNING_HOST', 'stale-binding') const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' const env = buildCodexStructuredChildEnvironment( { @@ -147,6 +151,7 @@ describe('the spawned Codex child', () => { await expect(connection.request('test/env')).resolves.toEqual({ sessionId, cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER), + owningHost: '1', cliBinDir: process.platform === 'win32' ? null : expect.stringMatching(DEV_CLI_BIN_DIR), path: expect.stringMatching(DEV_CLI_BIN_FIRST) }) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index b2154b196cb5..aa8154725ce4 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -138,6 +138,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ORCA_AGENT_SESSION_ID: 'session-1', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), + ORCA_CLI_OWNING_HOST: '1', ...(process.platform !== 'win32' ? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) } : {}), diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index 48cff20c9dda..f16e74c374d2 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -454,6 +454,7 @@ describe('registerPtyHandlers', () => { expect(entries.indexOf(shimDir)).toBeGreaterThanOrEqual(0) expect(entries.indexOf(shimDir)).toBeLessThan(entries.indexOf('/usr/bin')) expect(env.ORCA_CLI_COMMAND).toBeUndefined() + expect(env.ORCA_CLI_OWNING_HOST).toBeUndefined() } finally { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 2ea3ce0172d9..109268a112f3 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -291,7 +291,7 @@ export function buildPtyHostEnv( resourcesPath: opts.resourcesPath, launcherPath: hostLauncher }) - if (!opts.isWsl && launcher) { + if (hostLauncher && launcher) { baseEnv.ORCA_CLI_COMMAND = launcher bindOrcaCliToExecutionHost(baseEnv, opts.userDataPath) } From fe7baa5659ee8a3eeeb577384dc294b78075bc25 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:13:25 -0700 Subject: [PATCH 06/21] Serve the last shell environment to chat starts and refresh it in the background Only the first chat start waits for the login shell; later starts get the last snapshot and refresh it in the background once it is 10 s old. On Windows hosts without the registry addon (managed server slots), the saved PATH is reread with reg.exe. The Windows server CLI launcher stager now checks the target architecture, and the CLI help says ambient selectors are ignored inside a managed server. --- config/scripts/orcad-windows-cli-launcher.mjs | 26 ++- .../orcad-windows-cli-launcher.test.mjs | 50 +++++ src/cli/root-help-text-secondary.ts | 4 +- src/main/orcad/orcad-cli-launcher.ts | 1 + src/main/pty/windows-environment-path.ts | 99 +++++---- .../pty/windows-path-registry-reader.test.ts | 7 +- src/main/pty/windows-path-registry-reader.ts | 8 +- ...ructured-agent-session-integration.test.ts | 11 +- ...structured-agent-shell-environment.test.ts | 189 ++++++++++++++---- .../structured-agent-shell-environment.ts | 32 ++- 10 files changed, 328 insertions(+), 99 deletions(-) create mode 100644 config/scripts/orcad-windows-cli-launcher.test.mjs diff --git a/config/scripts/orcad-windows-cli-launcher.mjs b/config/scripts/orcad-windows-cli-launcher.mjs index 158a5131e51c..055d14a08edb 100644 --- a/config/scripts/orcad-windows-cli-launcher.mjs +++ b/config/scripts/orcad-windows-cli-launcher.mjs @@ -1,16 +1,26 @@ -import { copyFileSync, mkdirSync } from 'node:fs' +import { copyFileSync, lstatSync, mkdirSync } from 'node:fs' +import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME } from '../../src/shared/orcad-artifacts.ts' import { runProcessSync } from './script-child-process.mjs' +const { PE_MACHINE, describePeMachine, readPeMachine } = createRequire(import.meta.url)( + './windows-pe-machine.cjs' +) + // Windows has no profile shell launcher; the native one keeps multiline message arguments intact. -export function stageOrcadWindowsCliLauncher(root, outputDir, target) { +export function stageOrcadWindowsCliLauncher( + root, + outputDir, + target, + host = { platform: process.platform } +) { if (!target.startsWith('win32-')) { return } const arch = target.slice('win32-'.length) const source = join(root, '.build', 'windows-cli-launcher', arch, 'orca.exe') - if (process.platform === 'win32') { + if (host.platform === 'win32') { const compile = runProcessSync({ program: process.execPath, args: [ @@ -27,6 +37,16 @@ export function stageOrcadWindowsCliLauncher(root, outputDir, target) { throw new Error('Could not build the Orca server CLI launcher') } } + if (!lstatSync(source, { throwIfNoEntry: false })) { + throw new Error( + `Orcad ${target} requires the Windows CLI launcher at ${source}. On Windows, run: ` + + `node config/scripts/build-windows-cli-launcher.mjs --arch ${arch} --output ${source}` + ) + } + const machine = readPeMachine(source) + if (machine !== PE_MACHINE[arch]) { + throw new Error(`Orcad ${target} CLI launcher has ${describePeMachine(machine)}`) + } const launcher = join(outputDir, ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME) mkdirSync(dirname(launcher), { recursive: true }) copyFileSync(source, launcher) diff --git a/config/scripts/orcad-windows-cli-launcher.test.mjs b/config/scripts/orcad-windows-cli-launcher.test.mjs new file mode 100644 index 000000000000..2ba199b29788 --- /dev/null +++ b/config/scripts/orcad-windows-cli-launcher.test.mjs @@ -0,0 +1,50 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME } from '../../src/shared/orcad-artifacts.ts' +import { stageOrcadWindowsCliLauncher } from './orcad-windows-cli-launcher.mjs' +import { peImage } from './windows-pe-image-fixture.mjs' + +// Staging is checked on a non-Windows host so the test never compiles the launcher. +const crossHost = { platform: 'linux' } + +const roots = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function fixture(arch, image) { + const root = mkdtempSync(join(tmpdir(), 'orcad-cli-launcher-')) + roots.push(root) + const output = join(root, 'output') + if (image) { + mkdirSync(join(root, '.build/windows-cli-launcher', arch), { recursive: true }) + writeFileSync(join(root, '.build/windows-cli-launcher', arch, 'orca.exe'), image) + } + return { root, output } +} + +it('stages the launcher built for the target machine', () => { + const image = peImage({ arch: 'arm64' }) + const { root, output } = fixture('arm64', image) + stageOrcadWindowsCliLauncher(root, output, 'win32-arm64', crossHost) + expect(readFileSync(join(output, ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME))).toEqual(image) +}) + +it('rejects a launcher built for another machine', () => { + const { root, output } = fixture('x64', peImage({ arch: 'arm64' })) + expect(() => stageOrcadWindowsCliLauncher(root, output, 'win32-x64', crossHost)).toThrow( + 'CLI launcher has machine 0xaa64' + ) +}) + +it('names the build command when the launcher artifact is missing', () => { + const { root, output } = fixture('x64') + expect(() => stageOrcadWindowsCliLauncher(root, output, 'win32-x64', crossHost)).toThrow( + 'build-windows-cli-launcher.mjs --arch x64' + ) +}) + +it('ignores non-Windows targets', () => { + const { root, output } = fixture('x64') + expect(() => stageOrcadWindowsCliLauncher(root, output, 'linux-x64', crossHost)).not.toThrow() +}) diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts index 4b61499ffc10..9b872ec19e05 100644 --- a/src/cli/root-help-text-secondary.ts +++ b/src/cli/root-help-text-secondary.ts @@ -111,7 +111,9 @@ export const ROOT_HELP_TEXT_SECONDARY = [ '', 'Behavior:', ' Most commands require a running Orca runtime. If Orca is not open yet, run `orca open` first.', - ' Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.', + ' Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT,', + ' except inside an Orca-managed server terminal or agent, which uses its own server;', + ' pass --environment or --pairing-code there to reach another runtime.', ' Use selectors for discovery and handles for repeated live terminal operations.', '', 'Agent Sessions And Worktrees:', diff --git a/src/main/orcad/orcad-cli-launcher.ts b/src/main/orcad/orcad-cli-launcher.ts index e79e6de2fb09..e2128c6d3b1f 100644 --- a/src/main/orcad/orcad-cli-launcher.ts +++ b/src/main/orcad/orcad-cli-launcher.ts @@ -25,6 +25,7 @@ export async function prepareOrcadCliLauncher(): Promise { } if (process.platform === 'win32') { // The slot's native launcher keeps argv intact; never proxy message bodies through cmd.exe. + // Temporary: per-slot, so a terminal open across two updates loses `orca` once GC drops it. const native = join(installRoot, ...ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME.split('/')) launcherPath = existsSync(native) ? native : null return diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 554e87686129..1e352d8104cb 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -1,6 +1,7 @@ import type { execFile, execFileSync } from 'node:child_process' import { expandWindowsEnvironmentVariables } from '../../shared/windows-environment-expansion' import { getRegExePath } from '../win32-utils' +import { runProcess } from '../../shared/child-process/run-process' import { mergeWindowsPathSegments } from './windows-path-segment-merge' import { WindowsPathRegistryFallback, @@ -77,11 +78,21 @@ function registryOutputSegments( : [] } +const FAILED_REGISTRY_READS: RegistryPathRead[] = WINDOWS_PATH_REGISTRY_KEYS.map(() => ({ + failed: true, + segments: [] +})) + +/** Null when the native addon cannot load. */ function readNativeRegistryPaths( env: NodeJS.ProcessEnv, pathDelimiter: string -): RegistryPathRead[] { - return readWindowsPathRegistry().map((read) => ({ +): RegistryPathRead[] | null { + const reads = readWindowsPathRegistry() + if (reads === null) { + return null + } + return reads.map((read) => ({ failed: read.failed, segments: read.value === null @@ -106,31 +117,50 @@ function cachePersistedWindowsPathReads(reads: RegistryPathRead[], readSequence: return [...segments] } -function readRegistryPathAsync( - run: ExecFile, - executable: string, - registryValue: readonly [key: string, valueName: string], +/** Stdout of `reg query`, or null when the query failed. */ +type RegQuery = (executable: string, args: string[]) => Promise + +function execFileRegQuery(run: ExecFile): RegQuery { + return (executable, args) => + new Promise((resolve) => { + run( + executable, + args, + { encoding: 'utf8', timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, windowsHide: true }, + (error, stdout) => resolve(error ? null : String(stdout)) + ) + }) +} + +async function runProcessRegQuery(executable: string, args: string[]): Promise { + try { + const result = await runProcess({ + program: executable, + args, + timeoutMs: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS + }) + return result.code === 0 && !result.timedOut ? result.stdout : null + } catch { + return null + } +} + +function readRegExePathsAsync( + query: RegQuery, env: NodeJS.ProcessEnv, pathDelimiter: string -): Promise { - const [key, valueName] = registryValue - return new Promise((resolve) => { - run( - executable, - ['query', key, '/v', valueName], - { encoding: 'utf8', timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, windowsHide: true }, - (error, stdout) => { - if (error) { - resolve({ failed: true, segments: [] }) - return - } - resolve({ - failed: false, - segments: registryOutputSegments(String(stdout), valueName, env, pathDelimiter) - }) - } - ) - }) +): Promise { + return Promise.all( + WINDOWS_PATH_REGISTRY_KEYS.map(async ([key, valueName]) => { + const stdout = await query(getRegExePath(env), ['query', key, '/v', valueName]) + return stdout === null + ? { failed: true, segments: [] } + : { + failed: false, + segments: registryOutputSegments(stdout, valueName, env, pathDelimiter) + } + }) + ) } export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions = {}): string[] { @@ -183,7 +213,8 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions return { failed: true, segments: [] } } }) - : readNativeRegistryPaths(env, pathDelimiter) + : // Why: no synchronous reg.exe on the PTY hot path; the async refresh fills the cache. + (readNativeRegistryPaths(env, pathDelimiter) ?? FAILED_REGISTRY_READS) const segments = reads.flatMap((read) => read.segments) if (!useProductionCache) { return segments @@ -227,18 +258,12 @@ export async function readPersistedWindowsPathSegmentsAsync( const readSequence = useProductionCache ? ++persistedWindowsPathReadSequence : 0 const refresh = ( options.execFile - ? Promise.all( - WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => - readRegistryPathAsync( - options.execFile!, - getRegExePath(env), - registryValue, - env, - pathDelimiter - ) - ) + ? readRegExePathsAsync(execFileRegQuery(options.execFile), env, pathDelimiter) + : // Why: an Orca server slot does not ship the native addon, so reg.exe reads it there. + Promise.resolve( + readNativeRegistryPaths(env, pathDelimiter) ?? + readRegExePathsAsync(runProcessRegQuery, env, pathDelimiter) ) - : Promise.resolve(readNativeRegistryPaths(env, pathDelimiter)) ).then((reads) => { const segments = reads.flatMap((read) => read.segments) if (!useProductionCache) { diff --git a/src/main/pty/windows-path-registry-reader.test.ts b/src/main/pty/windows-path-registry-reader.test.ts index 387e4238b8e7..0cb91e2c9c18 100644 --- a/src/main/pty/windows-path-registry-reader.test.ts +++ b/src/main/pty/windows-path-registry-reader.test.ts @@ -69,14 +69,11 @@ describe('readWindowsPathRegistry', () => { ]) }) - it('fails closed when the optional native module is unavailable', () => { + it('reports an unavailable native module apart from a failed read', () => { __setWindowsPathRegistryLoaderForTests(() => { throw new Error('native module unavailable') }) - expect(readWindowsPathRegistry()).toEqual([ - { failed: true, value: null }, - { failed: true, value: null } - ]) + expect(readWindowsPathRegistry()).toBeNull() }) }) diff --git a/src/main/pty/windows-path-registry-reader.ts b/src/main/pty/windows-path-registry-reader.ts index cff14c6ff3ef..4aff69fd89d4 100644 --- a/src/main/pty/windows-path-registry-reader.ts +++ b/src/main/pty/windows-path-registry-reader.ts @@ -46,15 +46,13 @@ function readRegistryPath( } } -export function readWindowsPathRegistry(): WindowsPathRegistryRead[] { +/** Null when the native addon cannot load (an Orca server slot does not ship it). */ +export function readWindowsPathRegistry(): WindowsPathRegistryRead[] | null { let registry: WindowsNativeRegistryModule try { registry = windowsRegistryLoader() } catch { - return [ - { failed: true, value: null }, - { failed: true, value: null } - ] + return null } return [ readRegistryPath(registry, registry.HK.LM, MACHINE_ENVIRONMENT_KEY), diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 7aaef442e24c..2842c2b280fd 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -238,7 +238,6 @@ let dispatcher: RpcDispatcher let bootEnvironmentReads: number let codexOverrideReads: number let configuredCodexProfile: string -let shellExportedToken: string let shellEnvironmentPolicy: NativeChatShellEnvironmentPolicy /** Runs a one-shot method and returns its decoded reply. */ @@ -325,7 +324,6 @@ beforeEach(async () => { bootEnvironmentReads = 0 codexOverrideReads = 0 configuredCodexProfile = 'configured' - shellExportedToken = 'shell-exported' shellEnvironmentPolicy = { inheritAll: true, names: [] } const runtime = { getRuntimeId: () => 'runtime-1', @@ -354,7 +352,7 @@ beforeEach(async () => { bootEnvironmentReads += 1 return { PATH: '/shell/bin:/usr/bin', - EXAMPLE_GATEWAY_TOKEN: shellExportedToken, + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' } }, @@ -742,7 +740,7 @@ describe('a structured codex session over agentSession.*', () => { ]) }) - it('refreshes shell exports and configured overrides for a resume', async () => { + it('caches shell exports but re-reads configured overrides for a resume', async () => { shellEnvironmentPolicy = { inheritAll: false, names: [] } const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) expect(codex.live().launch.env?.EXAMPLE_GATEWAY_TOKEN).toBeUndefined() @@ -752,7 +750,6 @@ describe('a structured codex session over agentSession.*', () => { }) configuredCodexProfile = 'updated' - shellExportedToken = 'repaired-shell-export' shellEnvironmentPolicy = { inheritAll: false, names: ['EXAMPLE_GATEWAY_TOKEN'] } const resumed = await ok<{ fence: number }>('agentSession.ensure', attachParams(created.fence)) @@ -760,10 +757,10 @@ describe('a structured codex session over agentSession.*', () => { expect(codex.live().resumedThreadId).toBe(THREAD) expect(codex.live().launch.env).toMatchObject({ CODEX_PROFILE: 'updated', - EXAMPLE_GATEWAY_TOKEN: 'repaired-shell-export' + EXAMPLE_GATEWAY_TOKEN: 'shell-exported' }) expect({ bootEnvironmentReads, codexOverrideReads }).toEqual({ - bootEnvironmentReads: 2, + bootEnvironmentReads: 1, codexOverrideReads: 2 }) }) diff --git a/src/main/runtime/structured-agent-shell-environment.test.ts b/src/main/runtime/structured-agent-shell-environment.test.ts index d73dbe2719c1..b3dc1492a734 100644 --- a/src/main/runtime/structured-agent-shell-environment.test.ts +++ b/src/main/runtime/structured-agent-shell-environment.test.ts @@ -1,13 +1,32 @@ import { describe, expect, it, vi } from 'vitest' import * as loginShell from '../startup/login-shell-environment' -import * as windowsPath from '../pty/windows-environment-path' +import type * as runProcessModule from '../../shared/child-process/run-process' +import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { createStructuredAgentEnvironmentResolvers, structuredAgentBaseEnvironment } from './structured-agent-shell-environment' +const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) + +vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ + ...(await importOriginal()), + runProcess: runProcessMock +})) + const INHERIT_ALL = { inheritAll: true, names: [] } +/** The value if the promise settles without any timer firing; undefined if it is still waiting. */ +async function settledWithoutWaiting(promise: Promise): Promise { + let value: T | undefined + void promise.then((resolved) => { + value = resolved + }) + await vi.advanceTimersByTimeAsync(0) + return value +} + const shellEnv = { PATH: '/shell/bin', LANG: 'en_US.UTF-8', @@ -70,14 +89,22 @@ describe('structuredAgentBaseEnvironment', () => { }) describe('createStructuredAgentEnvironmentResolvers', () => { - it('refreshes the saved Windows PATH before capturing the configured shell', async () => { + it('rereads the saved Windows PATH with reg.exe when the native addon is missing', async () => { + // An Orca server slot ships no registry addon, so this is the path a Windows SSH host takes. const platform = Object.getOwnPropertyDescriptor(process, 'platform')! Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const registry = vi - .spyOn(windowsPath, 'mergePersistedWindowsPathAsync') - .mockImplementation(async (env) => { - env.PATH = 'C:\\new-cli' - }) + __setWindowsPathRegistryLoaderForTests(() => { + throw new Error('Cannot find module @orca/windows-registry') + }) + runProcessMock.mockImplementation(async (spec: { args: string[] }) => ({ + code: 0, + signal: null, + stderr: '', + timedOut: false, + stdout: spec.args[1]?.startsWith('HKCU') + ? '\r\nHKEY_CURRENT_USER\\Environment\r\n Path REG_SZ C:\\Users\\me\\new-cli\r\n' + : '\r\nHKEY_LOCAL_MACHINE\\...\r\n Path REG_SZ C:\\Windows\\System32\r\n' + })) const capture = vi .spyOn(loginShell, 'resolveLoginShellEnvironment') .mockImplementation(async (options) => options?.env ?? {}) @@ -85,59 +112,154 @@ describe('createStructuredAgentEnvironmentResolvers', () => { const resolvers = createStructuredAgentEnvironmentResolvers({ resolveShellEnvironmentPolicy: () => INHERIT_ALL }) - expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('C:\\new-cli') - expect(registry).toHaveBeenCalledWith(expect.any(Object), { forceRefresh: true }) + const env = await resolvers.resolveBaseEnvironment() + const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path')! + expect(env[pathKey]?.split(';')).toContain('C:\\Users\\me\\new-cli') + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + program: expect.stringMatching(/reg\.exe$/i), + args: ['query', 'HKCU\\Environment', '/v', 'Path'] + }) + ) expect(capture).toHaveBeenCalledWith({ force: true, - env: expect.objectContaining({ PATH: 'C:\\new-cli' }) + env: expect.objectContaining({ [pathKey]: env[pathKey] }) }) } finally { - registry.mockRestore() + runProcessMock.mockReset() capture.mockRestore() + __setWindowsPathRegistryLoaderForTests() + __resetPersistedWindowsPathCacheForTests() Object.defineProperty(process, 'platform', platform) } }) - it('bypasses the login-shell cache on every settled acquisition', async () => { + it('serves the last snapshot to every start after the first and refreshes it past the TTL', async () => { const capture = vi .spyOn(loginShell, 'resolveLoginShellEnvironment') - .mockResolvedValue({ PATH: '/shell/current' }) + .mockResolvedValueOnce({ PATH: '/shell/A' }) + .mockResolvedValueOnce({ PATH: '/shell/B' }) + vi.useFakeTimers({ toFake: ['Date'] }) try { const resolvers = createStructuredAgentEnvironmentResolvers({}) - await resolvers.resolveBaseEnvironment() - await resolvers.resolveClaudeInheritedEnv() - expect(capture).toHaveBeenCalledTimes(2) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/A') + expect((await resolvers.resolveClaudeInheritedEnv()).PATH).toBe('/shell/A') + expect(capture).toHaveBeenCalledOnce() expect(capture).toHaveBeenCalledWith(expect.objectContaining({ force: true })) + vi.advanceTimersByTime(10_000) + // The stale start still gets the old snapshot; the install shows up on the next one. + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/A') + expect(capture).toHaveBeenCalledTimes(2) + await new Promise((resolve) => setImmediate(resolve)) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/B') } finally { + vi.useRealTimers() capture.mockRestore() } }) - it('shares concurrent captures and retries after a failed capture', async () => { + it('makes only the first start wait on a slow profile', async () => { + vi.useFakeTimers() + try { + let captures = 0 + const capture = vi.fn( + () => + new Promise((resolve) => { + captures += 1 + const path = `/capture-${captures}` + setTimeout(() => resolve({ PATH: path }), 2000) + }) + ) + const resolvers = createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) + const first = resolvers.resolveBaseEnvironment() + expect(await settledWithoutWaiting(first)).toBeUndefined() + await vi.advanceTimersByTimeAsync(2000) + expect((await first).PATH).toBe('/capture-1') + for (const expected of ['/capture-1', '/capture-2', '/capture-3']) { + // Each start is past the TTL, so it starts a 2 s refresh but must not wait for it. + await vi.advanceTimersByTimeAsync(12_000) + expect((await settledWithoutWaiting(resolvers.resolveBaseEnvironment()))?.PATH).toBe( + expected + ) + } + expect(capture).toHaveBeenCalledTimes(4) + } finally { + vi.useRealTimers() + } + }) + + it('does not hold later starts behind a hung profile', async () => { + vi.useFakeTimers() + try { + // The first capture ends at the login-shell timeout with Orca's own env; later ones hang. + const capture = vi + .fn<() => Promise>(() => new Promise(() => {})) + .mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ PATH: '/fallback' }), 5000)) + ) + const resolvers = createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) + const first = resolvers.resolveBaseEnvironment() + await vi.advanceTimersByTimeAsync(5000) + expect((await first).PATH).toBe('/fallback') + for (let start = 0; start < 3; start += 1) { + await vi.advanceTimersByTimeAsync(10_000) + expect((await settledWithoutWaiting(resolvers.resolveBaseEnvironment()))?.PATH).toBe( + '/fallback' + ) + } + // The hung refresh is shared, not restarted per start. + expect(capture).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('shares concurrent first captures and retries after a failed first capture', async () => { let release: (env: NodeJS.ProcessEnv) => void = () => {} - const capture = vi.fn( + const capture = vi.fn<() => Promise>() + capture.mockRejectedValueOnce(new Error('shell failed')) + const resolvers = createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) + await expect(resolvers.resolveBaseEnvironment()).rejects.toThrow('shell failed') + capture.mockImplementationOnce( () => new Promise((resolve) => { release = resolve }) ) - const resolvers = createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) const first = resolvers.resolveBaseEnvironment() const second = resolvers.resolveClaudeInheritedEnv() - expect(capture).toHaveBeenCalledOnce() - release({ PATH: '/first' }) - await Promise.all([first, second]) - capture.mockRejectedValueOnce(new Error('shell failed')) - await expect(resolvers.resolveBaseEnvironment()).rejects.toThrow('shell failed') - capture.mockResolvedValueOnce({ PATH: '/repaired' }) - expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/repaired') - expect(capture).toHaveBeenCalledTimes(3) + release({ PATH: '/repaired' }) + expect((await first).PATH).toBe('/repaired') + expect((await second).PATH).toBe('/repaired') + expect(capture).toHaveBeenCalledTimes(2) + }) + + it('keeps the last snapshot when a background refresh fails', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const capture = vi + .fn<() => Promise>() + .mockResolvedValueOnce({ PATH: '/good' }) + .mockRejectedValueOnce(new Error('shell failed')) + .mockResolvedValueOnce({ PATH: '/fixed' }) + const resolvers = createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) + await resolvers.resolveBaseEnvironment() + vi.advanceTimersByTime(10_000) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/good') + await new Promise((resolve) => setImmediate(resolve)) + // The failed refresh left the snapshot stale, so this start retries in the background. + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/good') + await new Promise((resolve) => setImmediate(resolve)) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/fixed') + expect(capture).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } }) - it('refreshes the shell PATH for later acquisitions while rereading settings', async () => { - let shellPath = '/shell/A' + it('rereads settings on every acquisition while the shell snapshot is cached', async () => { let overlay = 'A' - const resolveEnvironment = vi.fn(async () => ({ PATH: shellPath })) + const resolveEnvironment = vi.fn(async () => ({ PATH: '/shell/A' })) const resolvers = createStructuredAgentEnvironmentResolvers({ resolveEnvironment, resolveShellEnvironmentPolicy: () => INHERIT_ALL, @@ -147,15 +269,12 @@ describe('createStructuredAgentEnvironmentResolvers', () => { PATH: '/shell/A', CURRENT_SETTING: 'A' }) - shellPath = '/shell/B' overlay = 'B' - expect(await resolvers.resolveBaseEnvironment()).toEqual({ PATH: '/shell/B' }) - expect(await resolvers.resolveClaudeInheritedEnv()).toEqual({ PATH: '/shell/B' }) expect(await resolvers.resolveCodexEnvironment()).toEqual({ - PATH: '/shell/B', + PATH: '/shell/A', CURRENT_SETTING: 'B' }) - expect(resolveEnvironment).toHaveBeenCalledTimes(4) + expect(resolveEnvironment).toHaveBeenCalledOnce() }) it('gives Codex and Claude the same base, with overlays on Codex only', async () => { diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index 52f5de51b0df..84f803af177c 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -91,8 +91,13 @@ async function captureHostShellEnvironment(): Promise { return resolveLoginShellEnvironment({ force: true, env }) } +// Why: long enough that a burst of starts shares one capture, short enough that a CLI +// installed on the host reaches new chats without a restart. +const SHELL_ENVIRONMENT_TTL_MS = 10_000 + /** - * Refresh on acquisition so a host-side CLI install reaches the next chat. + * Stale-while-refresh: only the first acquisition waits for the login shell; later ones get + * the last snapshot at once and, once it is older than the TTL, refresh it in the background. */ export function createStructuredAgentEnvironmentResolvers( sources: StructuredAgentEnvironmentSources @@ -102,15 +107,30 @@ export function createStructuredAgentEnvironmentResolvers( /** The shared base every agent's child env starts from, before its own overlay. */ resolveBaseEnvironment: () => Promise> } { + let snapshot: { env: NodeJS.ProcessEnv; capturedAt: number } | null = null let pendingEnvironment: Promise | null = null const resolveEnvironment = sources.resolveEnvironment ?? captureHostShellEnvironment - const resolveShellEnvironment = (): Promise => { - // Concurrent probes share a capture; a settled capture never survives the next acquisition. - pendingEnvironment ??= resolveEnvironment().finally(() => { - pendingEnvironment = null - }) + const refresh = (): Promise => { + pendingEnvironment ??= resolveEnvironment() + .then((env) => { + snapshot = { env, capturedAt: Date.now() } + return env + }) + .finally(() => { + pendingEnvironment = null + }) return pendingEnvironment } + const resolveShellEnvironment = (): Promise => { + if (!snapshot) { + return refresh() + } + if (Date.now() - snapshot.capturedAt >= SHELL_ENVIRONMENT_TTL_MS) { + // A failed background refresh keeps the last snapshot. + refresh().catch(() => {}) + } + return Promise.resolve(snapshot.env) + } const resolveBase = async (): Promise> => structuredAgentBaseEnvironment({ shellEnv: await resolveShellEnvironment(), From 1e24a20a22e8d8d5357787c57ee63f526bf5cd9b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:29:21 -0700 Subject: [PATCH 07/21] Keep the last good shell environment when a refresh fails, and load the Windows profile once A login-shell capture now says whether the shell produced the env or Orca fell back to its own, and only a real capture replaces the snapshot. Windows loads the PowerShell profile once per process and refreshes only the saved PATH. The reg.exe fallback queries the whole key so a missing user Path no longer drops the saved PATH. The first capture starts when the resolvers are created. --- src/main/pty/windows-environment-path.ts | 17 +- ...structured-agent-shell-environment.test.ts | 312 +++++++++++++----- .../structured-agent-shell-environment.ts | 40 ++- .../login-shell-environment-cache.test.ts | 20 ++ src/main/startup/login-shell-environment.ts | 41 ++- 5 files changed, 308 insertions(+), 122 deletions(-) diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 1e352d8104cb..14b04f522516 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -152,7 +152,8 @@ function readRegExePathsAsync( ): Promise { return Promise.all( WINDOWS_PATH_REGISTRY_KEYS.map(async ([key, valueName]) => { - const stdout = await query(getRegExePath(env), ['query', key, '/v', valueName]) + // Why: `/v Path` fails when the value is absent; the native reader calls that an empty PATH. + const stdout = await query(getRegExePath(env), ['query', key]) return stdout === null ? { failed: true, segments: [] } : { @@ -196,15 +197,11 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions const reads = options.execFileSync ? WINDOWS_PATH_REGISTRY_KEYS.map(([key, valueName]) => { try { - const output = options.execFileSync!( - getRegExePath(env), - ['query', key, '/v', valueName], - { - encoding: 'utf8', - timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, - windowsHide: true - } - ) + const output = options.execFileSync!(getRegExePath(env), ['query', key], { + encoding: 'utf8', + timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, + windowsHide: true + }) return { failed: false, segments: registryOutputSegments(output, valueName, env, pathDelimiter) diff --git a/src/main/runtime/structured-agent-shell-environment.test.ts b/src/main/runtime/structured-agent-shell-environment.test.ts index b3dc1492a734..728209aacc31 100644 --- a/src/main/runtime/structured-agent-shell-environment.test.ts +++ b/src/main/runtime/structured-agent-shell-environment.test.ts @@ -1,3 +1,7 @@ +import { EventEmitter } from 'node:events' +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import * as loginShell from '../startup/login-shell-environment' import type * as runProcessModule from '../../shared/child-process/run-process' @@ -8,13 +12,22 @@ import { structuredAgentBaseEnvironment } from './structured-agent-shell-environment' -const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) - -vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ - ...(await importOriginal()), - runProcess: runProcessMock +const { runProcessMock, spawnProcessMock } = vi.hoisted(() => ({ + runProcessMock: vi.fn(), + spawnProcessMock: vi.fn() })) +vi.mock('../../shared/child-process/run-process', async (importOriginal) => { + const real = await importOriginal() + return { + ...real, + runProcess: runProcessMock, + // Unmocked calls spawn for real, so a stub login shell runs through the production capture. + spawnProcess: (...args: Parameters) => + spawnProcessMock(...args) ?? real.spawnProcess(...args) + } +}) + const INHERIT_ALL = { inheritAll: true, names: [] } /** The value if the promise settles without any timer firing; undefined if it is still waiting. */ @@ -27,6 +40,78 @@ async function settledWithoutWaiting(promise: Promise): Promise): string[] { + const key = Object.keys(env).find((name) => name.toLowerCase() === 'path') + return key ? (env[key]?.split(';') ?? []) : [] +} + +/** A powershell.exe child that prints the env it was given plus one profile-only variable. */ +function fakePowerShell(spec: { env: NodeJS.ProcessEnv }): EventEmitter { + const stdout = new EventEmitter() + const child = Object.assign(new EventEmitter(), { + stdin: Object.assign(new EventEmitter(), { end: () => {} }), + stdout, + stderr: Object.assign(new EventEmitter(), { resume: () => {} }), + kill: () => true + }) + const printed = JSON.stringify({ ...spec.env, PROFILE_ONLY: '1' }) + setImmediate(() => { + stdout.emit( + 'data', + Buffer.from(`__ORCA_LOGIN_SHELL_ENV_START__${printed}__ORCA_LOGIN_SHELL_ENV_END__`) + ) + child.emit('close', 0) + }) + return child +} + +/** Windows without the registry addon (an Orca server slot); `savedPath` is each key's saved + * Path value, null when the key has none, answered the way reg.exe answers. */ +async function withSimulatedWindowsServer( + savedPath: (key: string) => string | null, + run: () => Promise +): Promise { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + __setWindowsPathRegistryLoaderForTests(() => { + throw new Error('Cannot find module @orca/windows-registry') + }) + runProcessMock.mockImplementation(async ({ args }: { args: string[] }) => { + const key = args[1] ?? '' + const path = savedPath(key) + if (path === null && args.includes('/v')) { + const stderr = 'ERROR: The system was unable to find the specified registry key or value.' + return { code: 1, signal: null, timedOut: false, stdout: '', stderr } + } + const lines = ['', key, ' TEMP REG_EXPAND_SZ %USERPROFILE%\\AppData\\Local\\Temp'] + if (path !== null) { + lines.push(` Path REG_EXPAND_SZ ${path}`) + } + return { + code: 0, + signal: null, + timedOut: false, + stderr: '', + stdout: [...lines, ''].join('\r\n') + } + }) + spawnProcessMock.mockImplementation(fakePowerShell) + vi.useFakeTimers({ toFake: ['Date'] }) + try { + await run() + } finally { + vi.useRealTimers() + runProcessMock.mockReset() + spawnProcessMock.mockReset() + __setWindowsPathRegistryLoaderForTests() + __resetPersistedWindowsPathCacheForTests() + loginShell.resetLoginShellEnvironmentCacheForTests() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } +} + const shellEnv = { PATH: '/shell/bin', LANG: 'en_US.UTF-8', @@ -89,74 +174,146 @@ describe('structuredAgentBaseEnvironment', () => { }) describe('createStructuredAgentEnvironmentResolvers', () => { - it('rereads the saved Windows PATH with reg.exe when the native addon is missing', async () => { + it('loads the PowerShell profile once and refreshes only the saved PATH on Windows', async () => { + let machineReads = 0 + await withSimulatedWindowsServer( + (key) => { + if (key.startsWith('HKLM')) { + machineReads += 1 + return machineReads === 1 ? 'C:\\Windows' : 'C:\\Windows;C:\\NewTool' + } + return 'C:\\Users\\me\\bin' + }, + async () => { + const resolvers = createStructuredAgentEnvironmentResolvers({ + resolveShellEnvironmentPolicy: () => INHERIT_ALL + }) + const first = await resolvers.resolveBaseEnvironment() + expect(first.PROFILE_ONLY).toBe('1') + expect(pathSegments(first)).toEqual( + expect.arrayContaining(['C:\\Windows', 'C:\\Users\\me\\bin']) + ) + expect(pathSegments(first)).not.toContain('C:\\NewTool') + vi.advanceTimersByTime(10_000) + await resolvers.resolveBaseEnvironment() + // A machine-wide install reaches new chats, and the profile's variables stay. + await vi.waitFor(async () => { + const refreshed = await resolvers.resolveBaseEnvironment() + expect(pathSegments(refreshed)).toContain('C:\\NewTool') + expect(refreshed.PROFILE_ONLY).toBe('1') + }) + expect(spawnProcessMock).toHaveBeenCalledOnce() + expect(spawnProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ program: 'powershell.exe' }) + ) + } + ) + }) + + it('keeps the machine PATH when the user has no saved Path value and reg.exe reads it', async () => { // An Orca server slot ships no registry addon, so this is the path a Windows SSH host takes. - const platform = Object.getOwnPropertyDescriptor(process, 'platform')! - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - __setWindowsPathRegistryLoaderForTests(() => { - throw new Error('Cannot find module @orca/windows-registry') - }) - runProcessMock.mockImplementation(async (spec: { args: string[] }) => ({ - code: 0, - signal: null, - stderr: '', - timedOut: false, - stdout: spec.args[1]?.startsWith('HKCU') - ? '\r\nHKEY_CURRENT_USER\\Environment\r\n Path REG_SZ C:\\Users\\me\\new-cli\r\n' - : '\r\nHKEY_LOCAL_MACHINE\\...\r\n Path REG_SZ C:\\Windows\\System32\r\n' - })) - const capture = vi - .spyOn(loginShell, 'resolveLoginShellEnvironment') - .mockImplementation(async (options) => options?.env ?? {}) - try { - const resolvers = createStructuredAgentEnvironmentResolvers({ - resolveShellEnvironmentPolicy: () => INHERIT_ALL - }) - const env = await resolvers.resolveBaseEnvironment() - const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path')! - expect(env[pathKey]?.split(';')).toContain('C:\\Users\\me\\new-cli') - expect(runProcessMock).toHaveBeenCalledWith( - expect.objectContaining({ - program: expect.stringMatching(/reg\.exe$/i), - args: ['query', 'HKCU\\Environment', '/v', 'Path'] + await withSimulatedWindowsServer( + (key) => (key.startsWith('HKLM') ? 'C:\\Program Files\\nodejs\\;C:\\NewMachineTool' : null), + async () => { + const resolvers = createStructuredAgentEnvironmentResolvers({ + resolveShellEnvironmentPolicy: () => INHERIT_ALL }) + expect(pathSegments(await resolvers.resolveBaseEnvironment())).toContain( + 'C:\\NewMachineTool' + ) + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + program: expect.stringMatching(/reg\.exe$/i), + args: ['query', 'HKCU\\Environment'] + }) + ) + } + ) + }) + + it.skipIf(process.platform === 'win32')( + 'keeps the last good snapshot when a later login-shell capture fails', + async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-structured-shell-env-')) + const counter = join(dir, 'count') + const shell = join(dir, 'shell.sh') + // Run 1 has the profile PATH, run 2 fails like a timed-out or broken profile, later runs succeed. + await writeFile( + shell, + [ + '#!/bin/sh', + `n=$(cat '${counter}' 2>/dev/null || echo 0); n=$((n+1)); echo $n > '${counter}'`, + 'case $n in 1) p=/profile/A ;; 2) exit 1 ;; *) p=/profile/B ;; esac', + 'printf \'\\000__ORCA_LOGIN_SHELL_ENV_START__\\000PATH=%s\\000\\000__ORCA_LOGIN_SHELL_ENV_END__\\000\' "$p"', + '' + ].join('\n') ) - expect(capture).toHaveBeenCalledWith({ - force: true, - env: expect.objectContaining({ [pathKey]: env[pathKey] }) - }) - } finally { - runProcessMock.mockReset() - capture.mockRestore() - __setWindowsPathRegistryLoaderForTests() - __resetPersistedWindowsPathCacheForTests() - Object.defineProperty(process, 'platform', platform) + await chmod(shell, 0o755) + const originalShell = process.env.SHELL + process.env.SHELL = shell + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const resolvers = createStructuredAgentEnvironmentResolvers({ + resolveShellEnvironmentPolicy: () => INHERIT_ALL + }) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/profile/A') + const served: (string | undefined)[] = [] + for (let start = 0; start < 200 && served.at(-1) !== '/profile/B'; start += 1) { + // Every start is past the TTL, so each one after a refresh settles starts the next run. + vi.advanceTimersByTime(10_000) + served.push((await resolvers.resolveBaseEnvironment()).PATH) + await new Promise((resolve) => setTimeout(resolve, 20)) + } + expect(Number(await readFile(counter, 'utf8'))).toBeGreaterThanOrEqual(3) + expect(served.at(-1)).toBe('/profile/B') + // The failed run never served Orca's own env in place of the profile's. + expect(served.filter((path) => path !== '/profile/A' && path !== '/profile/B')).toEqual([]) + } finally { + vi.useRealTimers() + if (originalShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = originalShell + } + loginShell.resetLoginShellEnvironmentCacheForTests() + await rm(dir, { recursive: true, force: true }) + } } + ) + + it('starts the first capture when created, before any chat asks for it', () => { + const capture = vi.fn(async () => ({ PATH: '/shell/A' })) + createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) + expect(capture).toHaveBeenCalledOnce() }) - it('serves the last snapshot to every start after the first and refreshes it past the TTL', async () => { - const capture = vi - .spyOn(loginShell, 'resolveLoginShellEnvironment') - .mockResolvedValueOnce({ PATH: '/shell/A' }) - .mockResolvedValueOnce({ PATH: '/shell/B' }) - vi.useFakeTimers({ toFake: ['Date'] }) - try { - const resolvers = createStructuredAgentEnvironmentResolvers({}) - expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/A') - expect((await resolvers.resolveClaudeInheritedEnv()).PATH).toBe('/shell/A') - expect(capture).toHaveBeenCalledOnce() - expect(capture).toHaveBeenCalledWith(expect.objectContaining({ force: true })) - vi.advanceTimersByTime(10_000) - // The stale start still gets the old snapshot; the install shows up on the next one. - expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/A') - expect(capture).toHaveBeenCalledTimes(2) - await new Promise((resolve) => setImmediate(resolve)) - expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/B') - } finally { - vi.useRealTimers() - capture.mockRestore() + // Windows reloads only the saved PATH, covered by the simulated-Windows case above. + it.skipIf(process.platform === 'win32')( + 'serves the last snapshot to every start after the first and refreshes it past the TTL', + async () => { + const capture = vi + .spyOn(loginShell, 'captureLoginShellEnvironment') + .mockResolvedValueOnce({ status: 'captured', env: { PATH: '/shell/A' } }) + .mockResolvedValueOnce({ status: 'captured', env: { PATH: '/shell/B' } }) + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const resolvers = createStructuredAgentEnvironmentResolvers({}) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/A') + expect((await resolvers.resolveClaudeInheritedEnv()).PATH).toBe('/shell/A') + expect(capture).toHaveBeenCalledOnce() + expect(capture).toHaveBeenCalledWith(expect.objectContaining({ force: true })) + vi.advanceTimersByTime(10_000) + // The stale start still gets the old snapshot; the install shows up on the next one. + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/A') + expect(capture).toHaveBeenCalledTimes(2) + await new Promise((resolve) => setImmediate(resolve)) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/B') + } finally { + vi.useRealTimers() + capture.mockRestore() + } } - }) + ) it('makes only the first start wait on a slow profile', async () => { vi.useFakeTimers() @@ -234,29 +391,6 @@ describe('createStructuredAgentEnvironmentResolvers', () => { expect(capture).toHaveBeenCalledTimes(2) }) - it('keeps the last snapshot when a background refresh fails', async () => { - vi.useFakeTimers({ toFake: ['Date'] }) - try { - const capture = vi - .fn<() => Promise>() - .mockResolvedValueOnce({ PATH: '/good' }) - .mockRejectedValueOnce(new Error('shell failed')) - .mockResolvedValueOnce({ PATH: '/fixed' }) - const resolvers = createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) - await resolvers.resolveBaseEnvironment() - vi.advanceTimersByTime(10_000) - expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/good') - await new Promise((resolve) => setImmediate(resolve)) - // The failed refresh left the snapshot stale, so this start retries in the background. - expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/good') - await new Promise((resolve) => setImmediate(resolve)) - expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/fixed') - expect(capture).toHaveBeenCalledTimes(3) - } finally { - vi.useRealTimers() - } - }) - it('rereads settings on every acquisition while the shell snapshot is cached', async () => { let overlay = 'A' const resolveEnvironment = vi.fn(async () => ({ PATH: '/shell/A' })) diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index 84f803af177c..0ecbb1512191 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -1,4 +1,7 @@ -import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' +import { + captureLoginShellEnvironment, + type LoginShellEnvironmentCapture +} from '../startup/login-shell-environment' import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' import { nativeChatShellEnvironmentPolicy, @@ -82,13 +85,19 @@ export type StructuredAgentEnvironmentSources = { resolveCodexOverrides?: () => NodeJS.ProcessEnv } -async function captureHostShellEnvironment(): Promise { +/** + * One capture of the host shell environment. Windows loads the PowerShell profile once per + * process; later refreshes only reread the saved PATH over the last snapshot. + */ +async function captureHostShellEnvironment( + previous: NodeJS.ProcessEnv | null +): Promise { if (process.platform !== 'win32') { - return resolveLoginShellEnvironment({ force: true }) + return captureLoginShellEnvironment({ force: true }) } - const env = { ...process.env } + const env = { ...(previous ?? process.env) } await mergePersistedWindowsPathAsync(env, { forceRefresh: true }) - return resolveLoginShellEnvironment({ force: true, env }) + return previous ? { status: 'captured', env } : captureLoginShellEnvironment({ force: true, env }) } // Why: long enough that a burst of starts shares one capture, short enough that a CLI @@ -96,8 +105,9 @@ async function captureHostShellEnvironment(): Promise { const SHELL_ENVIRONMENT_TTL_MS = 10_000 /** - * Stale-while-refresh: only the first acquisition waits for the login shell; later ones get - * the last snapshot at once and, once it is older than the TTL, refresh it in the background. + * Stale-while-refresh: the first capture starts at creation; later acquisitions get the last + * snapshot at once and, once it is older than the TTL, refresh it in the background. A capture + * that fell back to Orca's own env replaces only the first, empty snapshot. */ export function createStructuredAgentEnvironmentResolvers( sources: StructuredAgentEnvironmentSources @@ -109,10 +119,17 @@ export function createStructuredAgentEnvironmentResolvers( } { let snapshot: { env: NodeJS.ProcessEnv; capturedAt: number } | null = null let pendingEnvironment: Promise | null = null - const resolveEnvironment = sources.resolveEnvironment ?? captureHostShellEnvironment + const injected = sources.resolveEnvironment + const capture = injected + ? async (): Promise => ({ + status: 'captured', + env: await injected() + }) + : captureHostShellEnvironment const refresh = (): Promise => { - pendingEnvironment ??= resolveEnvironment() - .then((env) => { + pendingEnvironment ??= capture(snapshot?.env ?? null) + .then((result) => { + const env = result.status === 'captured' || !snapshot ? result.env : snapshot.env snapshot = { env, capturedAt: Date.now() } return env }) @@ -126,11 +143,12 @@ export function createStructuredAgentEnvironmentResolvers( return refresh() } if (Date.now() - snapshot.capturedAt >= SHELL_ENVIRONMENT_TTL_MS) { - // A failed background refresh keeps the last snapshot. refresh().catch(() => {}) } return Promise.resolve(snapshot.env) } + // Why: start as soon as the runtime exists so the first chat rarely waits for the profile. + refresh().catch(() => {}) const resolveBase = async (): Promise> => structuredAgentBaseEnvironment({ shellEnv: await resolveShellEnvironment(), diff --git a/src/main/startup/login-shell-environment-cache.test.ts b/src/main/startup/login-shell-environment-cache.test.ts index 972dc35a5d01..393dc9b574ef 100644 --- a/src/main/startup/login-shell-environment-cache.test.ts +++ b/src/main/startup/login-shell-environment-cache.test.ts @@ -1,5 +1,6 @@ import { afterEach, expect, it, vi } from 'vitest' import { + captureLoginShellEnvironment, resetLoginShellEnvironmentCacheForTests, resolveLoginShellEnvironment } from './login-shell-environment' @@ -68,3 +69,22 @@ it('bounds retained environments and supports explicit refresh', async () => { }) expect(spawner).toHaveBeenCalledTimes(12) }) + +it('says whether the shell produced the env, and a failed forced capture keeps the cached one', async () => { + const env = { HOME: '/host' } + const spawner = vi + .fn<(shell: string, env: NodeJS.ProcessEnv) => Promise>() + .mockResolvedValueOnce({ PATH: '/profile/bin' }) + .mockResolvedValueOnce(null) + const options = { shellOverride: '/bin/bash', env, spawner } + await expect(captureLoginShellEnvironment(options)).resolves.toEqual({ + status: 'captured', + env: { PATH: '/profile/bin' } + }) + await expect(captureLoginShellEnvironment({ ...options, force: true })).resolves.toEqual({ + status: 'fallback', + env + }) + await expect(resolveLoginShellEnvironment(options)).resolves.toEqual({ PATH: '/profile/bin' }) + expect(spawner).toHaveBeenCalledTimes(2) +}) diff --git a/src/main/startup/login-shell-environment.ts b/src/main/startup/login-shell-environment.ts index bc4730be7c06..7787f2524c8a 100644 --- a/src/main/startup/login-shell-environment.ts +++ b/src/main/startup/login-shell-environment.ts @@ -11,7 +11,13 @@ const START_MARKER = '__ORCA_LOGIN_SHELL_ENV_START__' const END_MARKER = '__ORCA_LOGIN_SHELL_ENV_END__' const SPAWN_TIMEOUT_MS = 5000 -const environmentCache = new Map>() +/** Whether a profile-loading shell produced the env, or Orca fell back to its own. */ +export type LoginShellEnvironmentCapture = { + status: 'captured' | 'fallback' + env: NodeJS.ProcessEnv +} + +const environmentCache = new Map>() const MAX_CACHED_ENVIRONMENTS = 8 function processEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { @@ -132,10 +138,11 @@ export type ResolveLoginShellEnvironmentOptions = { spawner?: (shell: string, env: NodeJS.ProcessEnv) => Promise } -/** Resolves the environment seen by commands launched from Orca's profile-loading terminal shell. */ -export function resolveLoginShellEnvironment( +/** Captures the environment seen by commands launched from Orca's profile-loading terminal + * shell, saying whether the shell produced it or it is Orca's own env as a fallback. */ +export function captureLoginShellEnvironment( options: ResolveLoginShellEnvironmentOptions = {} -): Promise { +): Promise { const shell = options.shellOverride !== undefined ? options.shellOverride : resolveProfileLoadingShell() const fallback = options.shellOverride === undefined ? resolveProfileLoadingFallbackShell() : null @@ -151,18 +158,17 @@ export function resolveLoginShellEnvironment( if (cached && !options.force) { return cached } + const fellBack: LoginShellEnvironmentCapture = { status: 'fallback', env } if (!shell) { - return Promise.resolve(env) + return Promise.resolve(fellBack) } const spawner = options.spawner ?? spawnShellAndReadEnvironment const pending = spawner(shell, env) - .then(async (environment) => { - if (environment) { - return environment - } - return fallback ? ((await spawner(fallback, env)) ?? env) : env + .then(async (environment): Promise => { + const captured = environment ?? (fallback ? await spawner(fallback, env) : null) + return captured ? { status: 'captured', env: captured } : fellBack }) - .catch(() => env) + .catch(() => fellBack) environmentCache.delete(shellKey) while (environmentCache.size >= MAX_CACHED_ENVIRONMENTS) { const oldest = environmentCache.keys().next().value @@ -171,10 +177,21 @@ export function resolveLoginShellEnvironment( } environmentCache.delete(oldest) } - environmentCache.set(shellKey, pending) + // Why: a forced capture that falls back must not replace a good one other readers share. + environmentCache.set( + shellKey, + cached ? pending.then((capture) => (capture.status === 'captured' ? capture : cached)) : pending + ) return pending } +/** Resolves the environment seen by commands launched from Orca's profile-loading terminal shell. */ +export function resolveLoginShellEnvironment( + options: ResolveLoginShellEnvironmentOptions = {} +): Promise { + return captureLoginShellEnvironment(options).then((capture) => capture.env) +} + export function resetLoginShellEnvironmentCacheForTests(): void { environmentCache.clear() } From b44e02b31b590ae61bb5da20b963948ccf5c420f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:48:13 -0700 Subject: [PATCH 08/21] Keep the last good login-shell env for other readers while a forced refresh runs --- .../login-shell-environment-cache.test.ts | 19 ++++++++ src/main/startup/login-shell-environment.ts | 46 ++++++++++++++----- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/main/startup/login-shell-environment-cache.test.ts b/src/main/startup/login-shell-environment-cache.test.ts index 393dc9b574ef..21cafa10302e 100644 --- a/src/main/startup/login-shell-environment-cache.test.ts +++ b/src/main/startup/login-shell-environment-cache.test.ts @@ -88,3 +88,22 @@ it('says whether the shell produced the env, and a failed forced capture keeps t await expect(resolveLoginShellEnvironment(options)).resolves.toEqual({ PATH: '/profile/bin' }) expect(spawner).toHaveBeenCalledTimes(2) }) + +it('serves the cached env to other readers while a forced capture runs', async () => { + const release = Promise.withResolvers() + const spawner = vi + .fn<(shell: string, env: NodeJS.ProcessEnv) => Promise>() + .mockResolvedValueOnce({ PATH: '/first' }) + .mockReturnValueOnce(release.promise) + const options = { shellOverride: '/bin/bash', env: { HOME: '/host' }, spawner } + await resolveLoginShellEnvironment(options) + const forced = resolveLoginShellEnvironment({ ...options, force: true }) + const tick = new Promise((resolve) => setImmediate(() => resolve('still waiting'))) + await expect(Promise.race([resolveLoginShellEnvironment(options), tick])).resolves.toEqual({ + PATH: '/first' + }) + release.resolve({ PATH: '/second' }) + await expect(forced).resolves.toEqual({ PATH: '/second' }) + await expect(resolveLoginShellEnvironment(options)).resolves.toEqual({ PATH: '/second' }) + expect(spawner).toHaveBeenCalledTimes(2) +}) diff --git a/src/main/startup/login-shell-environment.ts b/src/main/startup/login-shell-environment.ts index 7787f2524c8a..d6762deceb7a 100644 --- a/src/main/startup/login-shell-environment.ts +++ b/src/main/startup/login-shell-environment.ts @@ -17,9 +17,27 @@ export type LoginShellEnvironmentCapture = { env: NodeJS.ProcessEnv } -const environmentCache = new Map>() +type CachedEnvironment = { capture: Promise; captured: boolean } + +const environmentCache = new Map() const MAX_CACHED_ENVIRONMENTS = 8 +function cacheEnvironment(key: string, capture: Promise): void { + const entry: CachedEnvironment = { capture, captured: false } + void capture.then((result) => { + entry.captured = result.status === 'captured' + }) + environmentCache.delete(key) + while (environmentCache.size >= MAX_CACHED_ENVIRONMENTS) { + const oldest = environmentCache.keys().next().value + if (oldest === undefined) { + break + } + environmentCache.delete(oldest) + } + environmentCache.set(key, entry) +} + function processEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { return Object.fromEntries( Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined) @@ -156,7 +174,7 @@ export function captureLoginShellEnvironment( const shellKey = `${shell ?? ''}\0${fallback ?? ''}\0${envKey}` const cached = environmentCache.get(shellKey) if (cached && !options.force) { - return cached + return cached.capture } const fellBack: LoginShellEnvironmentCapture = { status: 'fallback', env } if (!shell) { @@ -169,18 +187,22 @@ export function captureLoginShellEnvironment( return captured ? { status: 'captured', env: captured } : fellBack }) .catch(() => fellBack) - environmentCache.delete(shellKey) - while (environmentCache.size >= MAX_CACHED_ENVIRONMENTS) { - const oldest = environmentCache.keys().next().value - if (oldest === undefined) { - break - } - environmentCache.delete(oldest) + if (cached?.captured) { + // Why: readers keep the last good env while a forced refresh runs; only a real capture replaces it. + void pending.then((capture) => { + if (capture.status === 'captured') { + cacheEnvironment(shellKey, Promise.resolve(capture)) + } + }) + return pending } - // Why: a forced capture that falls back must not replace a good one other readers share. - environmentCache.set( + // Why: a forced capture that falls back yields to whatever the earlier entry settles to. + const previous = cached?.capture + cacheEnvironment( shellKey, - cached ? pending.then((capture) => (capture.status === 'captured' ? capture : cached)) : pending + previous + ? pending.then((capture) => (capture.status === 'captured' ? capture : previous)) + : pending ) return pending } From c518623be7cb0c2dd5daa3d790d659fb6b04f904 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:42:41 -0700 Subject: [PATCH 09/21] Mark the Claude launch resolver as a reviewed router-aware module It only types the chat command resolver it injects; main's launch-routing guard (#26801) matched the name. --- src/main/claude-accounts/claude-launch-routing.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/claude-accounts/claude-launch-routing.test.ts b/src/main/claude-accounts/claude-launch-routing.test.ts index c4cc2f42c605..2e269be49367 100644 --- a/src/main/claude-accounts/claude-launch-routing.test.ts +++ b/src/main/claude-accounts/claude-launch-routing.test.ts @@ -217,6 +217,8 @@ describe('one router decision for every Claude launch', () => { 'claude/claude-structured-child-env.ts': 'chat child; its home comes from router.prepareLaunch', 'claude/claude-stream-json-connection.ts': 'spawns the chat child with that launch env', + 'claude/claude-structured-launch-resolution.ts': + "types the chat resolver it injects; the child's home comes from the router", 'claude-accounts/claude-profile-router.ts': 'the router: a `--version` probe only', 'claude-accounts/claude-command-process.ts': 'sign-in into a named account folder', 'runtime/structured-claude-runtime-adapter.ts': 'wires the routed chat resolver', From 6e6adc816e823fd450f1f3abc316ce0471501d5e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:05:48 -0700 Subject: [PATCH 10/21] Keep a settled login-shell env for readers during a refresh and share the first run - Any settled cached entry, a fallback included, now serves other readers while a forced capture runs; only a real capture replaces it. - Native chat's first capture shares a run a startup reader already began, so the login shell runs once per process at startup on macOS/Linux. - Type the injected Claude resolver without naming resolveClaudeCommand. - Cover the runtime's Claude command options, the owning-host --host check, and the PTY flag drop. --- .../index-local-command-routing-flags.test.ts | 18 ++++++++ .../claude-launch-routing.test.ts | 2 - .../claude-structured-launch-resolution.ts | 3 +- .../pty-daemon-spawn-agent-home-env.test.ts | 10 +++-- ...-structured-launch-settings-wiring.test.ts | 33 ++++++++++++++ ...structured-agent-shell-environment.test.ts | 43 ++++++++++++++++++- .../structured-agent-shell-environment.ts | 7 ++- .../login-shell-environment-cache.test.ts | 18 ++++++++ src/main/startup/login-shell-environment.ts | 14 +++--- 9 files changed, 131 insertions(+), 17 deletions(-) diff --git a/src/cli/index-local-command-routing-flags.test.ts b/src/cli/index-local-command-routing-flags.test.ts index f6e9389fdfac..7017652bfa83 100644 --- a/src/cli/index-local-command-routing-flags.test.ts +++ b/src/cli/index-local-command-routing-flags.test.ts @@ -167,6 +167,24 @@ describe('runtime-selector flags on locally pinned CLI commands', () => { expect(runtimeClientConstructorMock).toHaveBeenCalledWith(undefined, 'm4air') }) + it('lets an owning-host invocation pick --host runtime: despite a stale ambient selector', async () => { + process.env.ORCA_CLI_OWNING_HOST = '1' + process.env.ORCA_ENVIRONMENT = 'stale-shell-selection' + pairRuntimeEnvironment(listEnvironmentsMock, 'gpu') + resolveEnvironmentMock.mockReturnValue({ id: 'stale-shell-selection' }) + queueFixtures(callMock, okFixture('req_project_setups', { setups: [] })) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const err = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + await main(['project', 'setups', '--host', 'runtime:gpu', '--json'], '/tmp/repo') + expect([...log.mock.calls, ...err.mock.calls].flat().join('\n')).not.toContain( + 'name different Orca servers' + ) + expect(callMock).toHaveBeenCalledWith('projectHostSetup.list') + expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, 'gpu') + process.exitCode = priorExitCode + }) + it('routes `host name` through an ambient ORCA_ENVIRONMENT, unlike the pinned `host list`', async () => { // Why: the pin used to cover the whole `host` family, which silently answered for this machine // when the shell was pointed at another one. `host name` describes one runtime, so it routes. diff --git a/src/main/claude-accounts/claude-launch-routing.test.ts b/src/main/claude-accounts/claude-launch-routing.test.ts index 2e269be49367..c4cc2f42c605 100644 --- a/src/main/claude-accounts/claude-launch-routing.test.ts +++ b/src/main/claude-accounts/claude-launch-routing.test.ts @@ -217,8 +217,6 @@ describe('one router decision for every Claude launch', () => { 'claude/claude-structured-child-env.ts': 'chat child; its home comes from router.prepareLaunch', 'claude/claude-stream-json-connection.ts': 'spawns the chat child with that launch env', - 'claude/claude-structured-launch-resolution.ts': - "types the chat resolver it injects; the child's home comes from the router", 'claude-accounts/claude-profile-router.ts': 'the router: a `--version` probe only', 'claude-accounts/claude-command-process.ts': 'sign-in into a named account folder', 'runtime/structured-claude-runtime-adapter.ts': 'wires the routed chat resolver', diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index d6f7a3cf3009..39bcca43523f 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -1,6 +1,5 @@ import { activeProviderContext } from '../../shared/agent-session-provider-context' import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' -import type { resolveClaudeCommand } from '../codex-cli/command' import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { Options as ClaudeAgentSdkOptions, @@ -109,7 +108,7 @@ export type ClaudeStructuredLaunchResolverDeps = { store: Pick resolveLaunchArgs: () => Promise | string[] resolveWorkspacePath: (workspaceId: string) => Promise - resolveCommand?: typeof resolveClaudeCommand + resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string resolveEnv?: () => | Promise | undefined> | Record diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index f16e74c374d2..025ab49db58c 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -445,9 +445,13 @@ describe('registerPtyHandlers', () => { }) try { // Why: overriding process.platform doesn't change the loaded node:path dialect; keep this synthetic PATH consistent. - const env = await daemonSpawnAndGetEnv({ - PATH: ['/usr/local/bin', '/usr/bin'].join(delimiter) - }) + // An inherited owning-host flag (Orca started from a server's terminal) must not reach the pane. + const env = await daemonSpawnAndGetEnv( + { PATH: ['/usr/local/bin', '/usr/bin'].join(delimiter) }, + undefined, + undefined, + { ORCA_CLI_OWNING_HOST: '1' } + ) const entries = env.PATH.split(delimiter) const shimDir = join('/tmp/orca-user-data', 'linux-orca-cli-shim') // Why: bare `orca` must resolve to the Orca CLI before /usr/bin/orca (the GNOME screen reader) in Orca terminals (#7904). diff --git a/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts b/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts index dd81e303b41d..25dcad8f8e8e 100644 --- a/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts +++ b/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts @@ -1,3 +1,6 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { resolveCliCommand } from '../../shared/node-cli-command-resolution' import type { GlobalSettings } from '../../shared/global-settings-types' @@ -46,4 +49,34 @@ describe('execution host structured launch settings wiring', () => { expect(deps?.resolveClaudeCommand?.()).toBe(resolveCliCommand('claude')) expect(deps?.resolveCodexCommand?.()).toBe(resolveCliCommand('codex')) }) + + // The chat child's PATH and home decide which binary runs, for a saved bare command and the stock one. + it.skipIf(process.platform === 'win32')( + 'resolves both agents against the PATH and home the chat child will run with', + async () => { + installed.mockClear() + const bin = mkdtempSync(join(tmpdir(), 'orca-chat-child-bin-')) + for (const name of ['custom-claude', 'custom-codex', 'claude', 'codex']) { + writeFileSync(join(bin, name), '#!/bin/sh\n') + chmodSync(join(bin, name), 0o755) + } + try { + const settings: Partial = { + agentCmdOverrides: { claude: 'custom-claude', codex: 'custom-codex' } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked installer only reads the store's getSettings method. + const runtime = new OrcaRuntimeService({ getSettings: () => settings } as never) + await runtime.ensureStructuredAgentSessionHost() + const deps = installed.mock.calls[0]?.[0] + const childLookup = { pathEnv: bin, homePath: bin } + expect(deps?.resolveClaudeCommand?.(childLookup)).toBe(join(bin, 'custom-claude')) + expect(deps?.resolveCodexCommand?.(childLookup)).toBe(join(bin, 'custom-codex')) + settings.agentCmdOverrides = {} + expect(deps?.resolveClaudeCommand?.(childLookup)).toBe(join(bin, 'claude')) + expect(deps?.resolveCodexCommand?.(childLookup)).toBe(join(bin, 'codex')) + } finally { + rmSync(bin, { recursive: true, force: true }) + } + } + ) }) diff --git a/src/main/runtime/structured-agent-shell-environment.test.ts b/src/main/runtime/structured-agent-shell-environment.test.ts index 4d17f502f0b2..a3c53eb0c85b 100644 --- a/src/main/runtime/structured-agent-shell-environment.test.ts +++ b/src/main/runtime/structured-agent-shell-environment.test.ts @@ -281,6 +281,45 @@ describe('createStructuredAgentEnvironmentResolvers', () => { } ) + it.skipIf(process.platform === 'win32')( + 'shares the login-shell run an earlier reader started instead of running the shell again', + async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-structured-shell-runs-')) + const counter = join(dir, 'count') + const shell = join(dir, 'shell.sh') + await writeFile( + shell, + [ + '#!/bin/sh', + `echo run >> '${counter}'`, + "printf '\\000__ORCA_LOGIN_SHELL_ENV_START__\\000PATH=/profile/bin\\000\\000__ORCA_LOGIN_SHELL_ENV_END__\\000'", + '' + ].join('\n') + ) + await chmod(shell, 0o755) + const originalShell = process.env.SHELL + process.env.SHELL = shell + try { + // Startup readers such as the Claude account router capture before the runtime exists. + const early = loginShell.resolveLoginShellEnvironment() + const resolvers = createStructuredAgentEnvironmentResolvers({ + resolveShellEnvironmentPolicy: () => INHERIT_ALL + }) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/profile/bin') + expect((await early).PATH).toBe('/profile/bin') + expect((await readFile(counter, 'utf8')).trim().split('\n')).toHaveLength(1) + } finally { + if (originalShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = originalShell + } + loginShell.resetLoginShellEnvironmentCacheForTests() + await rm(dir, { recursive: true, force: true }) + } + } + ) + it('starts the first capture when created, before any chat asks for it', () => { const capture = vi.fn(async () => ({ PATH: '/shell/A' })) createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) @@ -301,11 +340,13 @@ describe('createStructuredAgentEnvironmentResolvers', () => { expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/A') expect((await resolvers.resolveClaudeInheritedEnv()).PATH).toBe('/shell/A') expect(capture).toHaveBeenCalledOnce() - expect(capture).toHaveBeenCalledWith(expect.objectContaining({ force: true })) + // The first capture may share a startup reader's run; refreshes must run the shell again. + expect(capture).toHaveBeenCalledWith(expect.objectContaining({ force: false })) vi.advanceTimersByTime(10_000) // The stale start still gets the old snapshot; the install shows up on the next one. expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/A') expect(capture).toHaveBeenCalledTimes(2) + expect(capture).toHaveBeenLastCalledWith(expect.objectContaining({ force: true })) await new Promise((resolve) => setImmediate(resolve)) expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/B') } finally { diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index 7b8495c28c82..55751091198a 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -108,7 +108,8 @@ async function captureHostShellEnvironment( previous: NodeJS.ProcessEnv | null ): Promise { if (process.platform !== 'win32') { - return captureLoginShellEnvironment({ force: true }) + // Why: the first capture shares a run a startup reader already began; later ones need a fresh run. + return captureLoginShellEnvironment({ force: previous !== null }) } const env = { ...(previous ?? process.env) } await mergePersistedWindowsPathAsync(env, { forceRefresh: true }) @@ -122,7 +123,9 @@ const SHELL_ENVIRONMENT_TTL_MS = 10_000 /** * Stale-while-refresh: the first capture starts at creation; later acquisitions get the last * snapshot at once and, once it is older than the TTL, refresh it in the background. A capture - * that fell back to Orca's own env replaces only the first, empty snapshot. + * that fell back to Orca's own env never replaces a snapshot. macOS/Linux retry the shell on the + * next refresh; Windows loads the profile once, so a failed first PowerShell capture stays until + * restart, as before. */ export function createStructuredAgentEnvironmentResolvers( sources: StructuredAgentEnvironmentSources diff --git a/src/main/startup/login-shell-environment-cache.test.ts b/src/main/startup/login-shell-environment-cache.test.ts index 21cafa10302e..b02d41c72e5e 100644 --- a/src/main/startup/login-shell-environment-cache.test.ts +++ b/src/main/startup/login-shell-environment-cache.test.ts @@ -107,3 +107,21 @@ it('serves the cached env to other readers while a forced capture runs', async ( await expect(resolveLoginShellEnvironment(options)).resolves.toEqual({ PATH: '/second' }) expect(spawner).toHaveBeenCalledTimes(2) }) + +it('serves a cached fallback to other readers while a forced capture runs', async () => { + const release = Promise.withResolvers() + const spawner = vi + .fn<(shell: string, env: NodeJS.ProcessEnv) => Promise>() + .mockResolvedValueOnce(null) + .mockReturnValueOnce(release.promise) + const env = { HOME: '/host' } + const options = { shellOverride: '/bin/bash', env, spawner } + await expect(captureLoginShellEnvironment(options)).resolves.toMatchObject({ status: 'fallback' }) + const forced = resolveLoginShellEnvironment({ ...options, force: true }) + const tick = new Promise((resolve) => setImmediate(() => resolve('still waiting'))) + await expect(Promise.race([resolveLoginShellEnvironment(options), tick])).resolves.toEqual(env) + release.resolve({ PATH: '/profile/bin' }) + await expect(forced).resolves.toEqual({ PATH: '/profile/bin' }) + await expect(resolveLoginShellEnvironment(options)).resolves.toEqual({ PATH: '/profile/bin' }) + expect(spawner).toHaveBeenCalledTimes(2) +}) diff --git a/src/main/startup/login-shell-environment.ts b/src/main/startup/login-shell-environment.ts index d8062a942cd8..2a2f7bf023f0 100644 --- a/src/main/startup/login-shell-environment.ts +++ b/src/main/startup/login-shell-environment.ts @@ -17,15 +17,15 @@ export type LoginShellEnvironmentCapture = { env: NodeJS.ProcessEnv } -type CachedEnvironment = { capture: Promise; captured: boolean } +type CachedEnvironment = { capture: Promise; settled: boolean } const environmentCache = new Map() const MAX_CACHED_ENVIRONMENTS = 8 function cacheEnvironment(key: string, capture: Promise): void { - const entry: CachedEnvironment = { capture, captured: false } - void capture.then((result) => { - entry.captured = result.status === 'captured' + const entry: CachedEnvironment = { capture, settled: false } + void capture.then(() => { + entry.settled = true }) environmentCache.delete(key) while (environmentCache.size >= MAX_CACHED_ENVIRONMENTS) { @@ -187,8 +187,8 @@ export function captureLoginShellEnvironment( return captured ? { status: 'captured', env: captured } : fellBack }) .catch(() => fellBack) - if (cached?.captured) { - // Why: readers keep the last good env while a forced refresh runs; only a real capture replaces it. + if (cached?.settled) { + // Why: readers keep any settled env, a fallback too, during a forced refresh; only a capture replaces it. void pending.then((capture) => { if (capture.status === 'captured') { cacheEnvironment(shellKey, Promise.resolve(capture)) @@ -196,7 +196,7 @@ export function captureLoginShellEnvironment( }) return pending } - // Why: a forced capture that falls back yields to whatever the earlier entry settles to. + // Why: a forced capture that falls back yields to whatever the earlier in-flight entry settles to. const previous = cached?.capture cacheEnvironment( shellKey, From 9d83dca4b8dcfd48f122363ab64b6739001108a6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:08:39 -0700 Subject: [PATCH 11/21] Drop an inherited owning-host flag from daemon terminal panes The persistent terminal daemon merges its own environment into every pane, so an inherited ORCA_CLI_OWNING_HOST reached desktop panes even though main strips it from the spawn env. Drop it with the other per-launch CLI variables. --- .../daemon/pty-subprocess-env-inheritance.test.ts | 5 ++++- src/main/daemon/pty-subprocess/spawn-environment.ts | 1 + src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts | 12 +++++------- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/daemon/pty-subprocess-env-inheritance.test.ts b/src/main/daemon/pty-subprocess-env-inheritance.test.ts index 362b9423c1b6..6257ad30eb4d 100644 --- a/src/main/daemon/pty-subprocess-env-inheritance.test.ts +++ b/src/main/daemon/pty-subprocess-env-inheritance.test.ts @@ -129,12 +129,14 @@ describe('createPtySubprocess', () => { ORCA_PANE_KEY: process.env.ORCA_PANE_KEY, ORCA_TAB_ID: process.env.ORCA_TAB_ID, ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID, - ORCA_WSL_CLI_DIR: process.env.ORCA_WSL_CLI_DIR + ORCA_WSL_CLI_DIR: process.env.ORCA_WSL_CLI_DIR, + ORCA_CLI_OWNING_HOST: process.env.ORCA_CLI_OWNING_HOST } process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf' process.env.ORCA_TAB_ID = 'parent-tab' process.env.ORCA_WORKTREE_ID = 'parent-worktree' process.env.ORCA_WSL_CLI_DIR = 'C:/parent/wsl-managed-cli' + process.env.ORCA_CLI_OWNING_HOST = '1' try { await createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) @@ -153,6 +155,7 @@ describe('createPtySubprocess', () => { expect(env.ORCA_TAB_ID).toBeUndefined() expect(env.ORCA_WORKTREE_ID).toBeUndefined() expect(env.ORCA_WSL_CLI_DIR).toBeUndefined() + expect(env.ORCA_CLI_OWNING_HOST).toBeUndefined() }) it('preserves explicit child Orca pane identity over parent env', async () => { diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index 428178cf43a2..17b803e95c17 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -36,6 +36,7 @@ const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_AGENT_LAUNCH_TOKEN', // Not identity but equally per-spawn: an inherited copy names another launch's CLI. 'ORCA_WSL_CLI_DIR', + 'ORCA_CLI_OWNING_HOST', 'JCODE_RUNTIME_DIR' ] as const const WINDOWS_PATH_ENV_KEY_RE = /^path$/i diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index 025ab49db58c..29c06591be14 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -445,13 +445,11 @@ describe('registerPtyHandlers', () => { }) try { // Why: overriding process.platform doesn't change the loaded node:path dialect; keep this synthetic PATH consistent. - // An inherited owning-host flag (Orca started from a server's terminal) must not reach the pane. - const env = await daemonSpawnAndGetEnv( - { PATH: ['/usr/local/bin', '/usr/bin'].join(delimiter) }, - undefined, - undefined, - { ORCA_CLI_OWNING_HOST: '1' } - ) + // A copied owning-host flag must not reach a desktop pane; its CLI honours ambient selectors. + const env = await daemonSpawnAndGetEnv({ + PATH: ['/usr/local/bin', '/usr/bin'].join(delimiter), + ORCA_CLI_OWNING_HOST: '1' + }) const entries = env.PATH.split(delimiter) const shimDir = join('/tmp/orca-user-data', 'linux-orca-cli-shim') // Why: bare `orca` must resolve to the Orca CLI before /usr/bin/orca (the GNOME screen reader) in Orca terminals (#7904). From ff3e9d9183aa1d862054b2c6b65ff7b92b5108d3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:05:30 -0700 Subject: [PATCH 12/21] Route agents to their own server through ordinary launch defaults; start the first chat after an install The native-chat agent environment now sets the owning server's ORCA_USER_DATA_PATH and empty ORCA_ENVIRONMENT / ORCA_PAIRING_CODE / ORCA_REMOTE_PAIRING, which the CLI already reads as unset; empty rather than deleted, because the spawn merges the launch overlay over the server's own env. The ORCA_CLI_OWNING_HOST flag, the CLI's skip of ambient selectors, the launchers' export of it, the terminal clean-up, the help-text exception and the WSL launcher guard are gone, so a person's terminal follows their own settings as on main. A launch that finds no program in the shell snapshot waits for one shared refresh (at most one shell timeout) and resolves again; create support rereads the shell once before refusing. On Windows a failed PowerShell profile load is retried on the next refresh instead of being kept until restart. --- .../build-windows-cli-launcher.test.mjs | 2 - native/windows-cli-launcher/src/main.rs | 5 +- .../index-local-command-routing-flags.test.ts | 18 ++-- src/cli/index-test-harness.ts | 15 +-- src/cli/index.ts | 6 +- src/cli/root-help-text-secondary.ts | 4 +- src/cli/runtime/client-execution-host.test.ts | 50 +++++----- src/cli/runtime/client.ts | 5 +- .../acp/acp-structured-launch-resolution.ts | 17 +++- .../claude/claude-structured-child-env.ts | 14 ++- .../claude-structured-launch-resolution.ts | 2 + src/main/cli/cli-dev-launcher.ts | 2 - src/main/cli/orca-cli-child-path.test.ts | 13 --- src/main/cli/orca-cli-child-path.ts | 7 +- ...codex-structured-child-environment.test.ts | 21 +++- .../codex-structured-launch-resolution.ts | 17 +++- .../codex-structured-session-adapter.test.ts | 4 +- .../pty-subprocess-env-inheritance.test.ts | 5 +- .../pty-subprocess/spawn-environment.ts | 1 - .../pty-daemon-spawn-agent-home-env.test.ts | 5 +- src/main/ipc/pty/host-env/assembly.ts | 5 +- .../structured-agent-command-resolution.ts | 30 +++++- .../orcad-cli-launcher.integration.test.ts | 14 ++- src/main/orcad/orcad-cli-launcher.test.ts | 33 ++++++- ...rcad-cli-orchestration.integration.test.ts | 2 - src/main/pi/rpc-launch-resolution.test.ts | 21 ++++ src/main/pi/rpc-launch-resolution.ts | 22 +++-- src/main/pi/rpc-runtime-registration.ts | 1 + ...-agent-create-support-opencode-rig.test.ts | 4 +- .../structured-agent-install-refresh.test.ts | 98 +++++++++++++++++++ ...red-agent-launch-support-probe-omp.test.ts | 4 +- ...uctured-agent-launch-support-probe.test.ts | 4 +- .../structured-agent-launch-support.test.ts | 27 ++++- .../structured-agent-launch-support.ts | 23 +++-- .../structured-agent-runtime-registrations.ts | 11 ++- ...structured-agent-shell-environment.test.ts | 86 ++++++++++++++-- .../structured-agent-shell-environment.ts | 64 +++++++++--- .../structured-claude-runtime-adapter.ts | 5 + ...uctured-session-child-identity-env.test.ts | 36 ++++++- .../structured-session-child-identity-env.ts | 22 +++-- src/main/startup/login-shell-environment.ts | 5 +- src/shared/cli-execution-host-env.ts | 23 ----- 42 files changed, 568 insertions(+), 185 deletions(-) create mode 100644 src/main/runtime/structured-agent-install-refresh.test.ts delete mode 100644 src/shared/cli-execution-host-env.ts diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 7590a6a3c6ad..8b4447691c54 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -267,7 +267,6 @@ describe('Windows CLI launcher', () => { profile: process.env.ORCA_USER_DATA_PATH, electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE ?? null, nodeOptions: process.env.NODE_OPTIONS ?? null, - owningHost: process.env.ORCA_CLI_OWNING_HOST ?? null, desktopMarker: process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER ?? null }))\n`, 'utf8' @@ -296,7 +295,6 @@ describe('Windows CLI launcher', () => { profile, electronRunAsNode: null, nodeOptions: null, - owningHost: '1', desktopMarker: null }) // Windows PowerShell 5.1 itself drops embedded double quotes, so its body has none. diff --git a/native/windows-cli-launcher/src/main.rs b/native/windows-cli-launcher/src/main.rs index 47ccb08adc6b..0bbdcf6c5aff 100644 --- a/native/windows-cli-launcher/src/main.rs +++ b/native/windows-cli-launcher/src/main.rs @@ -111,13 +111,12 @@ fn run_server_cli(config_path: &Path) -> ! { } } env::set_var("ORCA_USER_DATA_PATH", user_data_path); - // Binds the CLI to this server over an inherited ORCA_ENVIRONMENT, as the POSIX launcher does. - env::set_var("ORCA_CLI_OWNING_HOST", "1"); for name in [ "NODE_OPTIONS", "NODE_REPL_EXTERNAL_MODULE", "ELECTRON_RUN_AS_NODE", - // The desktop marker would make orchestration demand a bare `orca` resume command. + // A server started by the desktop's `orca serve` inherits this marker, which would make + // orchestration demand a bare `orca` resume command. "ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", ] { env::remove_var(name); diff --git a/src/cli/index-local-command-routing-flags.test.ts b/src/cli/index-local-command-routing-flags.test.ts index 7017652bfa83..22cb6cc9c3e3 100644 --- a/src/cli/index-local-command-routing-flags.test.ts +++ b/src/cli/index-local-command-routing-flags.test.ts @@ -156,9 +156,15 @@ describe('runtime-selector flags on locally pinned CLI commands', () => { expect(runtimeClientConstructorMock).toHaveBeenCalledWith(undefined, 'm4air') }) - it('preserves explicit routing for an owning-host invocation with a stale ambient selector', async () => { - process.env.ORCA_CLI_OWNING_HOST = '1' - process.env.ORCA_ENVIRONMENT = 'stale-shell-selection' + /** The selectors an agent's environment carries: set empty, which the CLI reads as unset. */ + function agentSelectors(): void { + process.env.ORCA_ENVIRONMENT = '' + process.env.ORCA_PAIRING_CODE = '' + process.env.ORCA_REMOTE_PAIRING = '' + } + + it("keeps explicit --environment routing under an agent's empty selectors", async () => { + agentSelectors() pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air') fakeMachineNameRuntime('M4 Air') const log = vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -167,11 +173,9 @@ describe('runtime-selector flags on locally pinned CLI commands', () => { expect(runtimeClientConstructorMock).toHaveBeenCalledWith(undefined, 'm4air') }) - it('lets an owning-host invocation pick --host runtime: despite a stale ambient selector', async () => { - process.env.ORCA_CLI_OWNING_HOST = '1' - process.env.ORCA_ENVIRONMENT = 'stale-shell-selection' + it("keeps --host runtime: routing under an agent's empty selectors", async () => { + agentSelectors() pairRuntimeEnvironment(listEnvironmentsMock, 'gpu') - resolveEnvironmentMock.mockReturnValue({ id: 'stale-shell-selection' }) queueFixtures(callMock, okFixture('req_project_setups', { setups: [] })) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const err = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/src/cli/index-test-harness.ts b/src/cli/index-test-harness.ts index 7333cdcb40e4..bb8c2d613667 100644 --- a/src/cli/index-test-harness.ts +++ b/src/cli/index-test-harness.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, vi, type Mock } from 'vitest' -import { readCliRemoteSelectionDefaults } from '../shared/cli-execution-host-env' export type RuntimeClientModuleMocks = { callMock: Mock @@ -37,11 +36,12 @@ export async function createRuntimeClientModuleMock(mocks: RuntimeClientModuleMo environmentSelector?: string | null ) { mocks.runtimeClientConstructorMock(remotePairingCode, environmentSelector) - const defaults = readCliRemoteSelectionDefaults() const effectivePairingCode = - remotePairingCode === undefined ? defaults.pairingCode : remotePairingCode + remotePairingCode === undefined + ? (process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING) + : remotePairingCode const effectiveEnvironment = - environmentSelector === undefined ? defaults.environment : environmentSelector + environmentSelector === undefined ? process.env.ORCA_ENVIRONMENT : environmentSelector if (effectivePairingCode && effectiveEnvironment) { throw new RuntimeClientError( 'invalid_argument', @@ -116,7 +116,6 @@ export function useWorktreeAwarenessEnvironment(mocks: WorktreeAwarenessMocks): const originalPairingCode = process.env.ORCA_PAIRING_CODE const originalRemotePairing = process.env.ORCA_REMOTE_PAIRING const originalEnvironment = process.env.ORCA_ENVIRONMENT - const originalOwningHost = process.env.ORCA_CLI_OWNING_HOST const originalWorkspaceId = process.env.ORCA_WORKSPACE_ID const originalWorktreeId = process.env.ORCA_WORKTREE_ID @@ -124,7 +123,6 @@ export function useWorktreeAwarenessEnvironment(mocks: WorktreeAwarenessMocks): mocks.callMock.mockReset() delete process.env.ORCA_TERMINAL_HANDLE delete process.env.ORCA_USER_DATA_PATH - delete process.env.ORCA_CLI_OWNING_HOST delete process.env.ORCA_DEV_CLI_INVOCATION delete process.env.ORCA_WORKSPACE_ID delete process.env.ORCA_WORKTREE_ID @@ -190,11 +188,6 @@ export function useWorktreeAwarenessEnvironment(mocks: WorktreeAwarenessMocks): } else { process.env.ORCA_ENVIRONMENT = originalEnvironment } - if (originalOwningHost === undefined) { - delete process.env.ORCA_CLI_OWNING_HOST - } else { - process.env.ORCA_CLI_OWNING_HOST = originalOwningHost - } if (originalWorkspaceId === undefined) { delete process.env.ORCA_WORKSPACE_ID } else { diff --git a/src/cli/index.ts b/src/cli/index.ts index b91187505b27..91731d94d497 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,7 +9,6 @@ import { validateCommandAndFlags } from './args' import { readOrcaCliVersion } from './cli-version' -import { readCliRemoteSelectionDefaults } from '../shared/cli-execution-host-env' import { dispatch } from './dispatch' import { assertEnvironmentSelectorResolvable, @@ -119,7 +118,6 @@ export async function main( const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code') const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment') - const ambientSelection = readCliRemoteSelectionDefaults() // Why: only the explicit flag is asserted eagerly. An ambient ORCA_ENVIRONMENT is background // config, and failing local-only commands because of a stale one would be a regression; the // explicit flag means the caller named that machine, so a bad name should fail immediately @@ -145,8 +143,8 @@ export async function main( environmentSelector: typeof environmentSelector === 'string' ? { value: environmentSelector, label: '--environment' } - : ambientSelection.environment - ? { value: ambientSelection.environment, label: 'ORCA_ENVIRONMENT' } + : process.env.ORCA_ENVIRONMENT + ? { value: process.env.ORCA_ENVIRONMENT, label: 'ORCA_ENVIRONMENT' } : null }) // Why: --host runtime: is canonicalized to the environment's id so downstream host-id diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts index 716d39622183..c34ab0884af1 100644 --- a/src/cli/root-help-text-secondary.ts +++ b/src/cli/root-help-text-secondary.ts @@ -111,9 +111,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [ '', 'Behavior:', ' Most commands require a running Orca runtime. If Orca is not open yet, run `orca open` first.', - ' Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT,', - ' except inside an Orca-managed server terminal or agent, which uses its own server;', - ' pass --environment or --pairing-code there to reach another runtime.', + ' Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.', ' Use selectors for discovery and handles for repeated live terminal operations.', '', 'Agent Sessions And Worktrees:', diff --git a/src/cli/runtime/client-execution-host.test.ts b/src/cli/runtime/client-execution-host.test.ts index 35833168ee22..82c608c39115 100644 --- a/src/cli/runtime/client-execution-host.test.ts +++ b/src/cli/runtime/client-execution-host.test.ts @@ -6,7 +6,7 @@ import { RuntimeClient } from './client' import { addEnvironmentFromPairingCode } from './environments' import { encodePairingOffer } from '../../shared/pairing' -const roots: string[] = [] +const SELECTORS = ['ORCA_ENVIRONMENT', 'ORCA_PAIRING_CODE', 'ORCA_REMOTE_PAIRING'] as const const pairingCode = encodePairingOffer({ v: 2, endpoint: 'ws://127.0.0.1:6768', @@ -14,38 +14,44 @@ const pairingCode = encodePairingOffer({ publicKeyB64: 'fixture-key', scope: 'runtime' }) +// Each selector names a real paired server, so honouring it routes there. +const selection = { + ORCA_ENVIRONMENT: 'other-server', + ORCA_PAIRING_CODE: pairingCode, + ORCA_REMOTE_PAIRING: pairingCode +} +const roots: string[] = [] afterEach(() => { vi.unstubAllEnvs() roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) }) -function owningHost(): string { +/** This server's profile, with another server paired in it; the CLI starts with `env`. */ +function serverProfileWith(env: Partial>): string { const root = mkdtempSync(join(tmpdir(), 'cli-owner-')) roots.push(root) - vi.stubEnv('ORCA_CLI_OWNING_HOST', '1') - vi.stubEnv('ORCA_ENVIRONMENT', undefined) - vi.stubEnv('ORCA_PAIRING_CODE', undefined) - vi.stubEnv('ORCA_REMOTE_PAIRING', undefined) + addEnvironmentFromPairingCode(root, { name: 'other-server', pairingCode }) + vi.stubEnv('ORCA_USER_DATA_PATH', root) + for (const key of SELECTORS) { + vi.stubEnv(key, env[key]) + } return root } -it.each(['ORCA_ENVIRONMENT', 'ORCA_PAIRING_CODE', 'ORCA_REMOTE_PAIRING'])( - 'an owning-host invocation ignores inherited %s', - (key) => { - const root = owningHost() - vi.stubEnv(key, 'stale-shell-selection') - expect(new RuntimeClient(root).isRemote).toBe(false) - } -) - -it('ignores a valid paired default while preserving explicit targeting and standalone defaults', () => { - const root = owningHost() - addEnvironmentFromPairingCode(root, { name: 'other-server', pairingCode }) - vi.stubEnv('ORCA_ENVIRONMENT', 'other-server') - expect(new RuntimeClient(root).isRemote).toBe(false) +it("stays on its own server with an agent's empty selectors, and explicit flags still route", () => { + // The agent environment sets all three empty so a profile's export cannot win. + const root = serverProfileWith({ + ORCA_ENVIRONMENT: '', + ORCA_PAIRING_CODE: '', + ORCA_REMOTE_PAIRING: '' + }) + expect(new RuntimeClient().isRemote).toBe(false) expect(new RuntimeClient(root, undefined, null, 'other-server').isRemote).toBe(true) expect(new RuntimeClient(root, undefined, pairingCode, null).isRemote).toBe(true) - vi.stubEnv('ORCA_CLI_OWNING_HOST', undefined) - expect(new RuntimeClient(root).isRemote).toBe(true) +}) + +it.each(SELECTORS)("follows a person's own %s, as on main", (key) => { + serverProfileWith({ [key]: selection[key] }) + expect(new RuntimeClient().isRemote).toBe(true) }) diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index c33e61a1b398..f3e1230465d0 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto' -import { readCliRemoteSelectionDefaults } from '../../shared/cli-execution-host-env' import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' @@ -66,8 +65,8 @@ export class RuntimeClient { constructor( userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 60_000, - remotePairingCode = readCliRemoteSelectionDefaults().pairingCode, - environmentSelector = readCliRemoteSelectionDefaults().environment, + remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null, + environmentSelector = process.env.ORCA_ENVIRONMENT ?? null, cliExecutable = resolveOrchestrationCliExecutable(), originalArgs?: readonly string[] ) { diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index 13e59ab127e5..0a04dcaa1df4 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -18,6 +18,7 @@ import type { AgentSessionAccountHome } from '../../shared/agent-session-account import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { + resolveAgainAfterRefreshOnMissingProgram, resolveStructuredAgentCommand, type StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' @@ -69,6 +70,8 @@ export type AcpStructuredLaunchResolverDeps = { resolveWorkspacePath: (workspaceId: string) => Promise /** The shared base env; re-read per acquisition. */ resolveEnvironment: () => Promise> + /** Refreshes that env when the agent's program was not found in it; absent never retries. */ + refreshEnvironmentAfterMiss?: () => Promise /** The user's per-agent environment overlay from settings. */ resolveLaunchEnv?: (agent: string) => Record /** The Agent Permissions setting's bypass posture for this agent, re-read per acquisition. */ @@ -145,6 +148,7 @@ export async function acpLaunchVersionSupported( export type AcpLaunchInvocationDeps = Pick< AcpStructuredLaunchResolverDeps, | 'resolveEnvironment' + | 'refreshEnvironmentAfterMiss' | 'resolveLaunchEnv' | 'resolveCommandSettings' | 'resolveCommand' @@ -154,7 +158,18 @@ export type AcpLaunchInvocationDeps = Pick< /** The binary and environment any child of this agent runs under for `accountHome`: a session's * launch and a catalog probe both resolve here, so neither can reach another account or install. */ -export async function resolveAcpLaunchInvocation( +export function resolveAcpLaunchInvocation( + spec: AcpLaunchSpec, + accountHome: AgentSessionAccountHome, + deps: AcpLaunchInvocationDeps +): Promise<{ command: string; env: Record; envToDelete: string[] }> { + return resolveAgainAfterRefreshOnMissingProgram( + () => readAcpLaunchInvocation(spec, accountHome, deps), + deps.refreshEnvironmentAfterMiss + ) +} + +async function readAcpLaunchInvocation( spec: AcpLaunchSpec, accountHome: AgentSessionAccountHome, deps: AcpLaunchInvocationDeps diff --git a/src/main/claude/claude-structured-child-env.ts b/src/main/claude/claude-structured-child-env.ts index efd96e8b3637..198ac4cc8fd4 100644 --- a/src/main/claude/claude-structured-child-env.ts +++ b/src/main/claude/claude-structured-child-env.ts @@ -4,6 +4,7 @@ import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { applyClaudeEnvPatch } from '../claude-accounts/environment' import { resolveClaudeCommand } from '../codex-cli/command' +import { resolveAgainAfterRefreshOnMissingProgram } from '../native-chat/structured-agent-command-resolution' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' import type { ClaudeStructuredLaunchResolverDeps } from './claude-structured-launch-resolution' @@ -19,7 +20,7 @@ function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): Recor export type ClaudeEnvDeps = Pick< ClaudeStructuredLaunchResolverDeps, - 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' + 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' | 'refreshEnvironmentAfterMiss' > /** What a child's env is built from, before any auth policy applies to it. */ @@ -29,9 +30,14 @@ export type ClaudeChildEnvSources = { inheritedEnv: Record } -export async function resolveClaudeChildEnvSources( - deps: ClaudeEnvDeps -): Promise { +export function resolveClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise { + return resolveAgainAfterRefreshOnMissingProgram( + () => readClaudeChildEnvSources(deps), + deps.refreshEnvironmentAfterMiss + ) +} + +async function readClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise { const overlay = await deps.resolveEnv?.() const inheritedEnv = deps.resolveInheritedEnv ? await deps.resolveInheritedEnv() diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 39bcca43523f..a40e9e97b9a4 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -115,6 +115,8 @@ export type ClaudeStructuredLaunchResolverDeps = { | undefined /** The env the child inherits before auth stripping; absent inherits Orca's own process env. */ resolveInheritedEnv?: () => Promise> + /** Refreshes the inherited env after the command was not found in it; absent never retries. */ + refreshEnvironmentAfterMiss?: () => Promise /** Required, so every caller states which login a failed sign-in names rather than inherit a * guess. Build it with claudeStructuredAuthPolicyForSettings. */ resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy diff --git a/src/main/cli/cli-dev-launcher.ts b/src/main/cli/cli-dev-launcher.ts index 83c203b8379c..e7b0f75c4b55 100644 --- a/src/main/cli/cli-dev-launcher.ts +++ b/src/main/cli/cli-dev-launcher.ts @@ -7,7 +7,6 @@ import { isAbsoluteForPlatform, quoteShell } from './cli-install-path-format' -import { ORCA_CLI_OWNING_HOST_ENV } from '../../shared/cli-execution-host-env' export async function ensureDevLauncher(args: { platform: NodeJS.Platform @@ -60,7 +59,6 @@ export function buildUnixCliLauncher( return `#!/usr/bin/env sh set -eu export ORCA_USER_DATA_PATH=${quoteShell(userDataPath)} -export ${ORCA_CLI_OWNING_HOST_ENV}=1 unset NODE_OPTIONS NODE_REPL_EXTERNAL_MODULE ELECTRON_RUN_AS_NODE exec ${quoteShell(execPathValue)} ${quoteShell(cliEntryPath)} "$@" ` diff --git a/src/main/cli/orca-cli-child-path.test.ts b/src/main/cli/orca-cli-child-path.test.ts index 0cc04ac7a600..30f049bf9956 100644 --- a/src/main/cli/orca-cli-child-path.test.ts +++ b/src/main/cli/orca-cli-child-path.test.ts @@ -46,19 +46,6 @@ describe('prependOrcaCliDirToChildPath', () => { expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() } ) - it("keeps a WSL pane off a Windows server's native launcher", () => { - installFakeAppEnvironment({ getCliLauncherPath: () => 'C:\\server\\bin\\orca.exe' }) - const env: Record = { PATH: 'C:\\Windows', ORCA_CLI_COMMAND: 'orca-ide' } - prependOrcaCliDirToChildPath(env, { - isPackaged: true, - userDataPath: USER_DATA, - resourcesPath: null, - skipHostLauncher: true, - platform: 'win32' - }) - expect(env.ORCA_CLI_COMMAND).toBe('orca-ide') - expect(env.PATH).not.toContain('server') - }) it('puts a Windows execution host CLI first on Path without a POSIX restore dir', () => { const binDir = join(USER_DATA, 'cli', 'bin') const launcher = join(binDir, 'orca.exe') diff --git a/src/main/cli/orca-cli-child-path.ts b/src/main/cli/orca-cli-child-path.ts index 96e0a834896d..270f7bb411d3 100644 --- a/src/main/cli/orca-cli-child-path.ts +++ b/src/main/cli/orca-cli-child-path.ts @@ -25,8 +25,6 @@ export type OrcaCliChildPathOptions = { isPackaged: boolean userDataPath: string resourcesPath?: string | null - /** A WSL pane keeps its Linux-side CLI; a Windows server's native launcher cannot run there. */ - skipHostLauncher?: boolean /** Test seam — production reads the real platform, which is what every branch below assumes. */ platform?: NodeJS.Platform } @@ -46,10 +44,7 @@ export function prependOrcaCliDirToChildPath( // Why: matches node:path's `delimiter` for the running platform, but stays correct when a test // drives a foreign platform through the seam. const pathDelimiter = platform === 'win32' ? ';' : delimiter - const hostLauncher = - !opts.skipHostLauncher && hasAppEnvironment() - ? getAppEnvironment().getCliLauncherPath?.() - : null + const hostLauncher = hasAppEnvironment() ? getAppEnvironment().getCliLauncherPath?.() : null if (hostLauncher) { const binDir = dirname(hostLauncher) const inheritedEntries = readInheritedPath(env, platform) diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index 47564b28d458..59322545491e 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -40,11 +40,13 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: 'session-not-a-worker', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), - ORCA_CLI_OWNING_HOST: '1', ...(process.platform !== 'win32' ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) } : {}), ORCA_USER_DATA_PATH: expect.any(String), + ORCA_ENVIRONMENT: '', + ORCA_PAIRING_CODE: '', + ORCA_REMOTE_PAIRING: '', // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) @@ -66,11 +68,13 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: sessionId, ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), - ORCA_CLI_OWNING_HOST: '1', ...(process.platform !== 'win32' ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) } : {}), ORCA_USER_DATA_PATH: expect.any(String), + ORCA_ENVIRONMENT: '', + ORCA_PAIRING_CODE: '', + ORCA_REMOTE_PAIRING: '', PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) @@ -110,7 +114,11 @@ const ENV_REPORTING_APP_SERVER = String.raw` result: { sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null, cliCommand: process.env.ORCA_CLI_COMMAND ?? null, - owningHost: process.env.ORCA_CLI_OWNING_HOST ?? null, + selectors: [ + process.env.ORCA_ENVIRONMENT, + process.env.ORCA_PAIRING_CODE, + process.env.ORCA_REMOTE_PAIRING + ], cliBinDir: process.env.ORCA_CLI_BIN_DIR ?? null, path: process.env.PATH ?? process.env.Path ?? null } @@ -128,7 +136,10 @@ describe('the spawned Codex child', () => { // The builder's output is an overlay on process.env, so only the spawned child proves the id // survives the merge — an Orca launched inside another session inherits that session's id. vi.stubEnv('ORCA_AGENT_SESSION_ID', 'a0b1c2d3-0000-4000-8000-00000000abcd') - vi.stubEnv('ORCA_CLI_OWNING_HOST', 'stale-binding') + // A server whose own env carries a profile's selectors must not hand them to its agents. + vi.stubEnv('ORCA_ENVIRONMENT', 'stale-shell-selection') + vi.stubEnv('ORCA_PAIRING_CODE', 'stale-pairing') + vi.stubEnv('ORCA_REMOTE_PAIRING', 'stale-remote-pairing') const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' const env = buildCodexStructuredChildEnvironment( { @@ -151,7 +162,7 @@ describe('the spawned Codex child', () => { await expect(connection.request('test/env')).resolves.toEqual({ sessionId, cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER), - owningHost: '1', + selectors: ['', '', ''], cliBinDir: process.platform === 'win32' ? null : expect.stringMatching(DEV_CLI_BIN_DIR), path: expect.stringMatching(DEV_CLI_BIN_FIRST) }) diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 86b3f222773d..bdac6ab24832 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -11,6 +11,7 @@ import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import { isNativeLocalLocation } from '../../shared/execution-host' import { resolveCodexCommand } from '../codex-cli/command' +import { resolveAgainAfterRefreshOnMissingProgram } from '../native-chat/structured-agent-command-resolution' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory' import type { CodexStructuredLaunch } from './codex-structured-session-adapter' @@ -30,6 +31,8 @@ export type CodexStructuredLaunchResolverDeps = { resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string /** Fresh shell/configured environment for this spawn; never written to the session record. */ resolveEnvironment?: () => Promise + /** Refreshes that environment when Codex was not found in it; absent never retries. */ + refreshEnvironmentAfterMiss?: () => Promise resolveRollout?: typeof resolvePinnedCodexRolloutProof /** The user's Agent Permissions setting as thread policy, re-read per acquisition. * States both postures outright — a resume inherits the last one for any field left absent. */ @@ -50,7 +53,19 @@ export type CodexStructuredInvocation = { * session it stands in for. Env VALUES stay out of the catalog fingerprint: * drift there heals on the next refresh. */ -export async function resolveCodexStructuredInvocation( +export function resolveCodexStructuredInvocation( + deps: Pick< + CodexStructuredLaunchResolverDeps, + 'resolveCommand' | 'resolveEnvironment' | 'refreshEnvironmentAfterMiss' + > +): Promise { + return resolveAgainAfterRefreshOnMissingProgram( + () => readCodexStructuredInvocation(deps), + deps.refreshEnvironmentAfterMiss + ) +} + +async function readCodexStructuredInvocation( deps: Pick ): Promise { const environment = await deps.resolveEnvironment?.() diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 4ad7e2012239..ee608b192aac 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -138,11 +138,13 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ORCA_AGENT_SESSION_ID: 'session-1', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), - ORCA_CLI_OWNING_HOST: '1', ...(process.platform !== 'win32' ? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) } : {}), ORCA_USER_DATA_PATH: expect.any(String), + ORCA_ENVIRONMENT: '', + ORCA_PAIRING_CODE: '', + ORCA_REMOTE_PAIRING: '', // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) }) diff --git a/src/main/daemon/pty-subprocess-env-inheritance.test.ts b/src/main/daemon/pty-subprocess-env-inheritance.test.ts index 6257ad30eb4d..362b9423c1b6 100644 --- a/src/main/daemon/pty-subprocess-env-inheritance.test.ts +++ b/src/main/daemon/pty-subprocess-env-inheritance.test.ts @@ -129,14 +129,12 @@ describe('createPtySubprocess', () => { ORCA_PANE_KEY: process.env.ORCA_PANE_KEY, ORCA_TAB_ID: process.env.ORCA_TAB_ID, ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID, - ORCA_WSL_CLI_DIR: process.env.ORCA_WSL_CLI_DIR, - ORCA_CLI_OWNING_HOST: process.env.ORCA_CLI_OWNING_HOST + ORCA_WSL_CLI_DIR: process.env.ORCA_WSL_CLI_DIR } process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf' process.env.ORCA_TAB_ID = 'parent-tab' process.env.ORCA_WORKTREE_ID = 'parent-worktree' process.env.ORCA_WSL_CLI_DIR = 'C:/parent/wsl-managed-cli' - process.env.ORCA_CLI_OWNING_HOST = '1' try { await createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) @@ -155,7 +153,6 @@ describe('createPtySubprocess', () => { expect(env.ORCA_TAB_ID).toBeUndefined() expect(env.ORCA_WORKTREE_ID).toBeUndefined() expect(env.ORCA_WSL_CLI_DIR).toBeUndefined() - expect(env.ORCA_CLI_OWNING_HOST).toBeUndefined() }) it('preserves explicit child Orca pane identity over parent env', async () => { diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index 17b803e95c17..428178cf43a2 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -36,7 +36,6 @@ const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_AGENT_LAUNCH_TOKEN', // Not identity but equally per-spawn: an inherited copy names another launch's CLI. 'ORCA_WSL_CLI_DIR', - 'ORCA_CLI_OWNING_HOST', 'JCODE_RUNTIME_DIR' ] as const const WINDOWS_PATH_ENV_KEY_RE = /^path$/i diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index 29c06591be14..48cff20c9dda 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -445,10 +445,8 @@ describe('registerPtyHandlers', () => { }) try { // Why: overriding process.platform doesn't change the loaded node:path dialect; keep this synthetic PATH consistent. - // A copied owning-host flag must not reach a desktop pane; its CLI honours ambient selectors. const env = await daemonSpawnAndGetEnv({ - PATH: ['/usr/local/bin', '/usr/bin'].join(delimiter), - ORCA_CLI_OWNING_HOST: '1' + PATH: ['/usr/local/bin', '/usr/bin'].join(delimiter) }) const entries = env.PATH.split(delimiter) const shimDir = join('/tmp/orca-user-data', 'linux-orca-cli-shim') @@ -456,7 +454,6 @@ describe('registerPtyHandlers', () => { expect(entries.indexOf(shimDir)).toBeGreaterThanOrEqual(0) expect(entries.indexOf(shimDir)).toBeLessThan(entries.indexOf('/usr/bin')) expect(env.ORCA_CLI_COMMAND).toBeUndefined() - expect(env.ORCA_CLI_OWNING_HOST).toBeUndefined() } finally { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 662ef8fe092f..91b1f55b0b0b 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -4,7 +4,6 @@ import { isPiCompatibleAgentType } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' -import { ORCA_CLI_OWNING_HOST_ENV } from '../../../../shared/cli-execution-host-env' import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer' import { mimoCodeHookService } from '../../../mimo/hook-service' import { agentHookServer } from '../../../agent-hooks/server' @@ -265,7 +264,6 @@ export function buildPtyHostEnv( // Why: an inherited copy (e.g. Orca launched from a WSL pane) names another launch's CLI. delete baseEnv.ORCA_WSL_CLI_DIR - delete baseEnv[ORCA_CLI_OWNING_HOST_ENV] // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. if (opts.isWsl) { baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath @@ -284,8 +282,7 @@ export function buildPtyHostEnv( prependOrcaCliDirToChildPath(baseEnv, { isPackaged: opts.isPackaged, userDataPath: opts.userDataPath, - resourcesPath: opts.resourcesPath, - skipHostLauncher: opts.isWsl + resourcesPath: opts.resourcesPath }) if ( diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 828fd031760c..624b0177f4ae 100644 --- a/src/main/native-chat/structured-agent-command-resolution.ts +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -1,3 +1,4 @@ +import { isAbsolute } from 'node:path' import type { GlobalSettings } from '../../shared/global-settings-types' import type { TuiAgent } from '../../shared/tui-agent' import { @@ -8,7 +9,10 @@ import { hasExplicitTuiLaunchCommand, structuredAgentCommandToken } from '../../shared/tui-agent-launch-command-override' -import { AgentSessionPreSpawnError } from './agent-session-wire/structured-agent-session-adapter' +import { + AgentSessionPreSpawnError, + isAgentSessionPreSpawnError +} from './agent-session-wire/structured-agent-session-adapter' export type StructuredAgentCommandSettings = Partial< Pick @@ -58,3 +62,27 @@ export function resolveStructuredAgentCommand( } return command } + +/** Resolves a launch's program from the shell snapshot. When none is found there (a bare name back, + * or a Command setting naming no program), waits for one refresh and resolves once more, so an + * agent installed since the last read starts. A found program never waits. */ +export async function resolveAgainAfterRefreshOnMissingProgram( + resolve: () => Promise, + refreshAfterMiss: (() => Promise) | undefined +): Promise { + if (!refreshAfterMiss) { + return resolve() + } + try { + const resolved = await resolve() + if (isAbsolute(resolved.command)) { + return resolved + } + } catch (error) { + if (!isAgentSessionPreSpawnError(error) || error.reason !== 'agentCommandNotRunnable') { + throw error + } + } + await refreshAfterMiss() + return resolve() +} diff --git a/src/main/orcad/orcad-cli-launcher.integration.test.ts b/src/main/orcad/orcad-cli-launcher.integration.test.ts index a5b3942c87a5..d723c9c64b8c 100644 --- a/src/main/orcad/orcad-cli-launcher.integration.test.ts +++ b/src/main/orcad/orcad-cli-launcher.integration.test.ts @@ -21,7 +21,7 @@ const runtime = existsSync(join(slotDir, ORCAD_NODE_RUNTIME_MARKER_FILENAME)) const skip = skipForMissingInputs('artifact', runtime ? [] : ['a Node orcad slot in out/orcad']) it.skipIf(skip)( - 'a session child runs the server CLI against its owning server', + "an agent's CLI reaches its owning server while a person's selector is honoured", async () => { const root = mkdtempSync(join(tmpdir(), 'orcad-cli-')) const home = join(root, 'home') @@ -135,6 +135,18 @@ it.skipIf(skip)( }) expect(version.code, version.stderr).toBe(0) expect(version.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/u) + // A person's own selector on the server is honoured, as on any machine. + const person = await runProcess({ + program: launcher, + args: ['status', '--json'], + env: { ...env, ORCA_ENVIRONMENT: 'stale-shell-selection' }, + cwd: home, + timeoutMs: 15_000 + }) + expect(person.code).not.toBe(0) + expect(`${person.stdout}${person.stderr}`).toContain( + 'Unknown environment: stale-shell-selection' + ) } finally { await killChildAndWait(child) await killProfileDaemons(userData) diff --git a/src/main/orcad/orcad-cli-launcher.test.ts b/src/main/orcad/orcad-cli-launcher.test.ts index 8ff37e5d3adb..ec10ff454151 100644 --- a/src/main/orcad/orcad-cli-launcher.test.ts +++ b/src/main/orcad/orcad-cli-launcher.test.ts @@ -38,7 +38,7 @@ async function writeCli(): Promise { await mkdir(dirname(path), { recursive: true }) await writeFile( path, - 'console.log(JSON.stringify({profile:process.env.ORCA_USER_DATA_PATH, owningHost:process.env.ORCA_CLI_OWNING_HOST, args:process.argv.slice(2), electron:process.env.ELECTRON_RUN_AS_NODE}))' + 'console.log(JSON.stringify({profile:process.env.ORCA_USER_DATA_PATH, args:process.argv.slice(2), electron:process.env.ELECTRON_RUN_AS_NODE}))' ) } @@ -66,7 +66,36 @@ describe('orcad profile CLI launcher', () => { timeoutMs: 5000 }) expect(result.code).toBe(0) - expect(JSON.parse(result.stdout)).toEqual({ profile: roots.profile, owningHost: '1', args }) + expect(JSON.parse(result.stdout)).toEqual({ profile: roots.profile, args }) + } + ) + + it.skipIf(process.platform === 'win32')( + "passes a person's own Orca settings through, adding only the profile", + async () => { + const path = join(roots.install, ...ORCAD_CLI_ENTRY_FILENAME.split('/')) + await mkdir(dirname(path), { recursive: true }) + await writeFile( + path, + "console.log(JSON.stringify(Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('ORCA_')))))" + ) + await prepareOrcadCliLauncher() + const result = await runProcess({ + program: getOrcadCliLauncherPath()!, + args: ['status'], + env: { + PATH: process.env.PATH, + ORCA_ENVIRONMENT: 'other-server', + ORCA_PAIRING_CODE: 'other-pairing' + }, + timeoutMs: 5000 + }) + expect(result.code).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ + ORCA_USER_DATA_PATH: roots.profile, + ORCA_ENVIRONMENT: 'other-server', + ORCA_PAIRING_CODE: 'other-pairing' + }) } ) diff --git a/src/main/orcad/orcad-cli-orchestration.integration.test.ts b/src/main/orcad/orcad-cli-orchestration.integration.test.ts index d359f28a998d..f76ece64fe62 100644 --- a/src/main/orcad/orcad-cli-orchestration.integration.test.ts +++ b/src/main/orcad/orcad-cli-orchestration.integration.test.ts @@ -137,8 +137,6 @@ it.skipIf(skip)( CLAUDE_CONFIG_DIR: join(home, 'claude'), ORCA_USER_DATA_PATH: root, ORCA_CLI_COMMAND: launcher, - ORCA_ENVIRONMENT: 'stale-shell-selection', - ORCA_PAIRING_CODE: 'stale-pairing', ORCA_BACKGROUND_LAUNCH: '1', ...(process.platform === 'win32' ? { ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER: '1' } : {}) } diff --git a/src/main/pi/rpc-launch-resolution.test.ts b/src/main/pi/rpc-launch-resolution.test.ts index fd594886053b..45b75fe25366 100644 --- a/src/main/pi/rpc-launch-resolution.test.ts +++ b/src/main/pi/rpc-launch-resolution.test.ts @@ -118,6 +118,27 @@ describe('Pi host launch resolution', () => { }) }) + it('resolves Pi again after one environment refresh when the snapshot has no Pi', async () => { + const h = await setup() + let path = '/before-install' + const refresh = vi.fn(async () => { + path = '/host/bin' + }) + const resolver = createPiRpcLaunchResolver({ + store: h.store, + resolveWorkspacePath: async () => h.workspace, + resolveEnvironment: async () => ({ PATH: path, HOME: '/host/home' }), + refreshEnvironmentAfterMiss: refresh, + resolveCommand: (name, options) => (options?.pathEnv === '/host/bin' ? '/host/bin/pi' : name), + probeVersion + }) + await expect(resolver(identity)).resolves.toMatchObject({ + command: '/host/bin/pi', + env: { PATH: '/host/bin' } + }) + expect(refresh).toHaveBeenCalledOnce() + }) + it('starts a fresh session after clear without reading the previous file', async () => { const h = await setup() const old = join(root, 'old-malformed.jsonl') diff --git a/src/main/pi/rpc-launch-resolution.ts b/src/main/pi/rpc-launch-resolution.ts index c783eb91f24c..48482e02030d 100644 --- a/src/main/pi/rpc-launch-resolution.ts +++ b/src/main/pi/rpc-launch-resolution.ts @@ -13,6 +13,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' import { isStableCliVersionOnLine, probeAgentCliVersion } from '../agent-cli-version-probe' import { + resolveAgainAfterRefreshOnMissingProgram, resolveStructuredAgentCommand, type StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' @@ -29,6 +30,8 @@ export type PiRpcLaunchResolverDeps = { store: AgentSessionRecordStore resolveWorkspacePath: (id: string) => Promise resolveEnvironment: () => Promise + /** Refreshes that environment when Pi was not found in it; absent never retries. */ + refreshEnvironmentAfterMiss?: () => Promise /** The user's Pi Command setting, re-read per acquisition; none runs the stock binary. */ resolveCommandSettings?: () => StructuredAgentCommandSettings resolveCommand?: typeof resolveCliCommand @@ -128,13 +131,19 @@ export function createPiRpcLaunchResolver( throw new Error('Pi account home does not match its binary') } const cwd = await resolveAgentSessionLaunchDirectory(deps, record) - const env: Record = {} - for (const [key, value] of Object.entries(await deps.resolveEnvironment())) { - if (value !== undefined) { - env[key] = value + const { env, command } = await resolveAgainAfterRefreshOnMissingProgram(async () => { + const env: Record = {} + for (const [key, value] of Object.entries(await deps.resolveEnvironment())) { + if (value !== undefined) { + env[key] = value + } } - } - env.PI_CODING_AGENT_DIR = accountHome.path + env.PI_CODING_AGENT_DIR = accountHome.path + return { + env, + command: resolvePiRpcCommand(env, deps.resolveCommandSettings?.(), deps.resolveCommand) + } + }, deps.refreshEnvironmentAfterMiss) const previous = agentSessionProviderHandleChainHead(record.providerHandleChain) let sessionFile: string | undefined let forkFile: string | undefined @@ -157,7 +166,6 @@ export function createPiRpcLaunchResolver( forkFile = source } } - const command = resolvePiRpcCommand(env, deps.resolveCommandSettings?.(), deps.resolveCommand) // Again at every launch: the binary on PATH may have changed since the chat was created. if (!(await piRpcVersionSupported({ command, cwd, env }, deps.probeVersion))) { throw agentSessionRefusalError('structured_agent_session_unsupported', { diff --git a/src/main/pi/rpc-runtime-registration.ts b/src/main/pi/rpc-runtime-registration.ts index ac78e88301a5..51a4e86f9453 100644 --- a/src/main/pi/rpc-runtime-registration.ts +++ b/src/main/pi/rpc-runtime-registration.ts @@ -36,6 +36,7 @@ function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredA store: context.store, resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: piEnvironment(context), + refreshEnvironmentAfterMiss: context.environment.refreshAfterMissingProgram, ...(deps.resolveAgentCommandSettings ? { resolveCommandSettings: deps.resolveAgentCommandSettings } : {}) diff --git a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts index 97f258120726..f08fb2aff886 100644 --- a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts +++ b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts @@ -15,7 +15,9 @@ const { loginShell } = vi.hoisted(() => { return { loginShell } }) vi.mock('../startup/login-shell-environment', () => ({ - resolveLoginShellEnvironment: async () => loginShell.env + LOGIN_SHELL_SPAWN_TIMEOUT_MS: 5000, + resolveLoginShellEnvironment: async () => loginShell.env, + captureLoginShellEnvironment: async () => ({ status: 'captured', env: loginShell.env }) })) let root: string diff --git a/src/main/runtime/structured-agent-install-refresh.test.ts b/src/main/runtime/structured-agent-install-refresh.test.ts new file mode 100644 index 000000000000..795b0ab251b3 --- /dev/null +++ b/src/main/runtime/structured-agent-install-refresh.test.ts @@ -0,0 +1,98 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { acpLaunchSpecFor } from '../acp/acp-launch-specs' +import { resolveAcpLaunchInvocation } from '../acp/acp-structured-launch-resolution' +import { resolveClaudeChildEnvSources } from '../claude/claude-structured-child-env' +import { resolveCodexStructuredInvocation } from '../codex/codex-structured-launch-resolution' +import { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' + +const INHERIT_ALL = { inheritAll: true, names: [] } +const GROK = acpLaunchSpecFor('grok')! +const roots: string[] = [] + +afterEach(() => { + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) + +/** + * A host whose shell profile is read once, after which an agent is installed into a folder only + * the profile puts on PATH, as in the live check over SSH. `name` is unique so nothing on the + * machine running the test can answer for it. + */ +async function hostInstallingAfterFirstCapture(name: string) { + const root = mkdtempSync(join(tmpdir(), 'orca-install-after-capture-')) + roots.push(root) + const profileBin = join(root, 'profile-bin') + let profilePath = '/usr/bin' + const capture = vi.fn(async () => ({ PATH: profilePath, HOME: root })) + const environment = createStructuredAgentEnvironmentResolvers({ + resolveEnvironment: capture, + resolveShellEnvironmentPolicy: () => INHERIT_ALL + }) + expect((await environment.resolveBaseEnvironment()).PATH).toBe('/usr/bin') + mkdirSync(profileBin) + const program = join(profileBin, process.platform === 'win32' ? `${name}.cmd` : name) + writeFileSync(program, '#!/bin/sh\nexit 0\n') + chmodSync(program, 0o755) + profilePath = profileBin + return { environment, program, profileBin } +} + +describe('an agent installed after the first shell capture', () => { + it("starts Claude's first chat, from the refreshed environment", async () => { + const name = `claude-l5-${process.pid}` + const host = await hostInstallingAfterFirstCapture(name) + const sources = await resolveClaudeChildEnvSources({ + resolveInheritedEnv: host.environment.resolveClaudeInheritedEnv, + refreshEnvironmentAfterMiss: host.environment.refreshAfterMissingProgram, + resolveCommand: (options) => resolveCliCommand(name, options) + }) + expect(sources.command).toBe(host.program) + expect(sources.inheritedEnv.PATH).toBe(host.profileBin) + }) + + it("starts Codex's first chat, from the refreshed environment", async () => { + const name = `codex-l5-${process.pid}` + const host = await hostInstallingAfterFirstCapture(name) + const invocation = await resolveCodexStructuredInvocation({ + resolveEnvironment: host.environment.resolveCodexEnvironment, + refreshEnvironmentAfterMiss: host.environment.refreshAfterMissingProgram, + resolveCommand: (options) => resolveCliCommand(name, options) + }) + expect(invocation.command).toBe(host.program) + expect(invocation.environment?.PATH).toBe(host.profileBin) + }) + + it('starts a Command setting installed after the capture, rather than refusing it', async () => { + const name = `grok-l5-${process.pid}` + const host = await hostInstallingAfterFirstCapture(name) + const invocation = await resolveAcpLaunchInvocation( + GROK, + { variable: 'GROK_HOME', path: join(host.profileBin, '..', '.grok') }, + { + resolveEnvironment: host.environment.resolveBaseEnvironment, + refreshEnvironmentAfterMiss: host.environment.refreshAfterMissingProgram, + resolveCommandSettings: () => ({ agentCmdOverrides: { grok: name } }) + } + ) + expect(invocation.command).toBe(host.program) + }) + + it('never refreshes for a program the snapshot already finds', async () => { + const name = `codex-l5-found-${process.pid}` + const host = await hostInstallingAfterFirstCapture(name) + // Puts the install in the snapshot before the launch asks. + await host.environment.refreshAfterMissingProgram() + const refresh = vi.fn(async () => {}) + const invocation = await resolveCodexStructuredInvocation({ + resolveEnvironment: host.environment.resolveCodexEnvironment, + refreshEnvironmentAfterMiss: refresh, + resolveCommand: (options) => resolveCliCommand(name, options) + }) + expect(invocation.command).toBe(host.program) + expect(refresh).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts b/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts index dc7fbe00db51..5d73700c99d9 100644 --- a/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts +++ b/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts @@ -13,7 +13,9 @@ const { loginShell } = vi.hoisted(() => { return { loginShell } }) vi.mock('../startup/login-shell-environment', () => ({ - resolveLoginShellEnvironment: async () => loginShell.env + LOGIN_SHELL_SPAWN_TIMEOUT_MS: 5000, + resolveLoginShellEnvironment: async () => loginShell.env, + captureLoginShellEnvironment: async () => ({ status: 'captured', env: loginShell.env }) })) type Settings = Pick< diff --git a/src/main/runtime/structured-agent-launch-support-probe.test.ts b/src/main/runtime/structured-agent-launch-support-probe.test.ts index d02690261c9d..c1bf9c06a7a7 100644 --- a/src/main/runtime/structured-agent-launch-support-probe.test.ts +++ b/src/main/runtime/structured-agent-launch-support-probe.test.ts @@ -13,7 +13,9 @@ const { loginShell } = vi.hoisted(() => { return { loginShell } }) vi.mock('../startup/login-shell-environment', () => ({ - resolveLoginShellEnvironment: async () => loginShell.env + LOGIN_SHELL_SPAWN_TIMEOUT_MS: 5000, + resolveLoginShellEnvironment: async () => loginShell.env, + captureLoginShellEnvironment: async () => ({ status: 'captured', env: loginShell.env }) })) type Settings = Pick< diff --git a/src/main/runtime/structured-agent-launch-support.test.ts b/src/main/runtime/structured-agent-launch-support.test.ts index 7a4c0cfbcbec..99c890250e26 100644 --- a/src/main/runtime/structured-agent-launch-support.test.ts +++ b/src/main/runtime/structured-agent-launch-support.test.ts @@ -8,7 +8,10 @@ import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch- import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' const { probeAgentCliVersion, loginShell } = vi.hoisted(() => { - const loginShell: { env: Record } = { env: {} } + const loginShell: { env: Record; reread: () => void } = { + env: {}, + reread: () => {} + } return { probeAgentCliVersion: vi.fn(), loginShell } }) vi.mock('../agent-cli-version-probe', async (importOriginal) => ({ @@ -16,7 +19,12 @@ vi.mock('../agent-cli-version-probe', async (importOriginal) => ({ probeAgentCliVersion })) vi.mock('../startup/login-shell-environment', () => ({ - resolveLoginShellEnvironment: async () => loginShell.env + LOGIN_SHELL_SPAWN_TIMEOUT_MS: 5000, + resolveLoginShellEnvironment: async () => loginShell.env, + captureLoginShellEnvironment: async () => { + loginShell.reread() + return { status: 'captured', env: loginShell.env } + } })) let bin: string @@ -45,6 +53,7 @@ beforeEach(async () => { await writeFile(opencode, '#!/bin/sh\n') await chmod(opencode, 0o755) loginShell.env = { PATH: bin, HOME: '/home/user' } + loginShell.reread = () => {} }) afterEach(async () => { probeAgentCliVersion.mockReset() @@ -80,6 +89,20 @@ describe('structuredAgentSupportsLaunch', () => { }) }) + it('asks again after rereading the shell when the last read finds no runnable OpenCode', async () => { + // Only the profile's newer PATH reaches an OpenCode that answers its version. + probeAgentCliVersion.mockImplementation(async (input, supports) => { + asked.push(input) + return input.env.PATH === bin && supports('1.18.31') + }) + loginShell.env = { PATH: '/usr/bin', HOME: '/home/user' } + loginShell.reread = () => { + loginShell.env = { PATH: bin, HOME: '/home/user' } + } + expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(true) + expect(asked.map(({ env }) => env.PATH)).toEqual(['/usr/bin', bin]) + }) + it('refuses when the version cannot be read', async () => { probeAgentCliVersion.mockResolvedValue(false) expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(false) diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index 12e65aca5747..983dd92f3ed3 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -9,7 +9,10 @@ import { type StructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support' import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' -import { resolveHostAgentBaseEnvironment } from './structured-agent-shell-environment' +import { + refreshHostAgentBaseEnvironmentAfterMiss, + resolveHostAgentBaseEnvironment +} from './structured-agent-shell-environment' type LaunchEnvironmentSettings = Pick< GlobalSettings, @@ -36,12 +39,20 @@ export async function structuredAgentSupportsLaunch( return true } const settings = runtime.requireStore().getSettings() - const env = { - ...(await resolveHostAgentBaseEnvironment(settings)), - ...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) : {}) + const ask = async (): Promise => { + const env = { + ...(await resolveHostAgentBaseEnvironment(settings)), + ...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) : {}) + } + const cwd = (await runtime.resolveRuntimeFileTarget(worktreeSelector)).worktree.path + return supportsLaunch({ cwd, env, commandSettings: settings }) + } + if (await ask()) { + return true } - const cwd = (await runtime.resolveRuntimeFileTarget(worktreeSelector)).worktree.path - return supportsLaunch({ cwd, env, commandSettings: settings }) + // An agent installed since the shell was last read is found once that read is refreshed. + await refreshHostAgentBaseEnvironmentAfterMiss() + return ask() } /** `agentSession.createSupport` on this host: every refusal it can know before spawning. The diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index ef919351db4f..4cfc703ee01b 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -97,10 +97,10 @@ export type StructuredAgentAccountHomeServices = { } /** What a catalog probe is built from: the same runtime resolvers its sessions launch through. */ -export type StructuredAgentModelCatalogContext = Pick< - StructuredAgentAdapterContext, - 'deps' | 'environment' -> +export type StructuredAgentModelCatalogContext = Pick & { + /** A listing never retries a missing program; only a launch does. */ + environment: Omit +} export type StructuredAgentRuntimeRegistration = { definition: StructuredAgentDefinition @@ -146,6 +146,7 @@ function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredA store, resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: context.environment.resolveCodexEnvironment, + refreshEnvironmentAfterMiss: context.environment.refreshAfterMissingProgram, resolveLaunchArgs: () => deps.resolveLaunchArgs('codex'), ...(deps.resolveCodexPermissionPolicy ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } @@ -182,6 +183,7 @@ function createClaudeAdapter( ...nativeChatVisualsFor(deps), ...(deps.resolveClaudeLaunchEnv ? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv } : {}), resolveClaudeInheritedEnv: context.environment.resolveClaudeInheritedEnv, + refreshClaudeEnvironmentAfterMiss: context.environment.refreshAfterMissingProgram, resolveClaudeLaunchArgs: () => deps.resolveLaunchArgs('claude'), resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy, ...(deps.resolveClaudePermissionMode @@ -235,6 +237,7 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio readJournal, resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: context.environment.resolveBaseEnvironment, + refreshEnvironmentAfterMiss: context.environment.refreshAfterMissingProgram, ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), ...(deps.resolveAgentCommandSettings ? { resolveCommandSettings: deps.resolveAgentCommandSettings } diff --git a/src/main/runtime/structured-agent-shell-environment.test.ts b/src/main/runtime/structured-agent-shell-environment.test.ts index a3c53eb0c85b..f04509f1bc70 100644 --- a/src/main/runtime/structured-agent-shell-environment.test.ts +++ b/src/main/runtime/structured-agent-shell-environment.test.ts @@ -45,8 +45,9 @@ function pathSegments(env: Record): string[] { return key ? (env[key]?.split(';') ?? []) : [] } -/** A powershell.exe child that prints the env it was given plus one profile-only variable. */ -function fakePowerShell(spec: { env: NodeJS.ProcessEnv }): EventEmitter { +/** A powershell.exe child that prints the env it was given plus one profile-only variable, or, + * when `failed`, exits without printing it, as a profile that errors does. */ +function fakePowerShell(spec: { env: NodeJS.ProcessEnv }, failed = false): EventEmitter { const stdout = new EventEmitter() const child = Object.assign(new EventEmitter(), { stdin: Object.assign(new EventEmitter(), { end: () => {} }), @@ -56,11 +57,13 @@ function fakePowerShell(spec: { env: NodeJS.ProcessEnv }): EventEmitter { }) const printed = JSON.stringify({ ...spec.env, PROFILE_ONLY: '1' }) setImmediate(() => { - stdout.emit( - 'data', - Buffer.from(`__ORCA_LOGIN_SHELL_ENV_START__${printed}__ORCA_LOGIN_SHELL_ENV_END__`) - ) - child.emit('close', 0) + if (!failed) { + stdout.emit( + 'data', + Buffer.from(`__ORCA_LOGIN_SHELL_ENV_START__${printed}__ORCA_LOGIN_SHELL_ENV_END__`) + ) + } + child.emit('close', failed ? 1 : 0) }) return child } @@ -210,6 +213,40 @@ describe('createStructuredAgentEnvironmentResolvers', () => { ) }) + it('retries a failed first PowerShell profile load on a later refresh, then stops', async () => { + let profileFails = true + await withSimulatedWindowsServer( + () => 'C:\\Windows', + async () => { + spawnProcessMock.mockImplementation((spec: { env: NodeJS.ProcessEnv }) => + fakePowerShell(spec, profileFails) + ) + const resolvers = createStructuredAgentEnvironmentResolvers({ + resolveShellEnvironmentPolicy: () => INHERIT_ALL + }) + expect((await resolvers.resolveBaseEnvironment()).PROFILE_ONLY).toBeUndefined() + const failedRuns = spawnProcessMock.mock.calls.length + profileFails = false + vi.advanceTimersByTime(10_000) + await resolvers.resolveBaseEnvironment() + await vi.waitFor(async () => { + expect((await resolvers.resolveBaseEnvironment()).PROFILE_ONLY).toBe('1') + }) + expect(spawnProcessMock).toHaveBeenCalledTimes(failedRuns + 1) + // Once loaded, a refresh rereads only the saved PATH. + const registryReads = runProcessMock.mock.calls.length + vi.advanceTimersByTime(10_000) + await resolvers.resolveBaseEnvironment() + await vi.waitFor(() => { + expect(runProcessMock.mock.calls.length).toBeGreaterThan(registryReads) + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(spawnProcessMock).toHaveBeenCalledTimes(failedRuns + 1) + expect((await resolvers.resolveBaseEnvironment()).PROFILE_ONLY).toBe('1') + } + ) + }) + it('keeps the machine PATH when the user has no saved Path value and reg.exe reads it', async () => { // An Orca server slot ships no registry addon, so this is the path a Windows SSH host takes. await withSimulatedWindowsServer( @@ -412,6 +449,41 @@ describe('createStructuredAgentEnvironmentResolvers', () => { } }) + it('lets a launch that found no program wait for one shared refresh, at most one shell timeout', async () => { + vi.useFakeTimers() + try { + const capture = vi + .fn<() => Promise>(() => new Promise(() => {})) + .mockResolvedValueOnce({ PATH: '/before-install' }) + .mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ PATH: '/installed' }), 1000)) + ) + const resolvers = createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/before-install') + // Inside the TTL only a miss refreshes, and two misses share one run. + const misses = Promise.all([ + resolvers.refreshAfterMissingProgram(), + resolvers.refreshAfterMissingProgram() + ]) + await vi.advanceTimersByTimeAsync(1000) + await misses + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/installed') + expect(capture).toHaveBeenCalledTimes(2) + // A hung profile holds a miss for one shell timeout, and the snapshot stays. + let released = false + void resolvers.refreshAfterMissingProgram().then(() => { + released = true + }) + await vi.advanceTimersByTimeAsync(4999) + expect(released).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(released).toBe(true) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/installed') + } finally { + vi.useRealTimers() + } + }) + it('shares concurrent first captures and retries after a failed first capture', async () => { let release: (env: NodeJS.ProcessEnv) => void = () => {} const capture = vi.fn<() => Promise>() diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index 55751091198a..d804a730faa1 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -1,5 +1,6 @@ import { captureLoginShellEnvironment, + LOGIN_SHELL_SPAWN_TIMEOUT_MS, resolveLoginShellEnvironment, type LoginShellEnvironmentCapture } from '../startup/login-shell-environment' @@ -79,6 +80,27 @@ export function structuredAgentBaseEnvironment(input: { ) } +/** Settles when `pending` does, or after one profile-loading shell's timeout, whichever is first. */ +async function withinShellTimeout(pending: Promise): Promise { + let timer: ReturnType | undefined + await Promise.race([ + pending.then( + () => {}, + () => {} + ), + new Promise((resolve) => { + timer = setTimeout(resolve, LOGIN_SHELL_SPAWN_TIMEOUT_MS) + }) + ]) + clearTimeout(timer) +} + +/** For a check that found no runnable agent: rereads the shell `resolveHostAgentBaseEnvironment` + * serves, waiting at most one shell's timeout, so an agent installed since the last read counts. */ +export function refreshHostAgentBaseEnvironmentAfterMiss(): Promise { + return withinShellTimeout(captureLoginShellEnvironment({ force: true })) +} + /** The base env every local agent launch on this host starts from, read from current settings. */ export async function resolveHostAgentBaseEnvironment( settings: Pick< @@ -100,20 +122,28 @@ export type StructuredAgentEnvironmentSources = { resolveCodexOverrides?: () => NodeJS.ProcessEnv } +type ShellSnapshot = { + env: NodeJS.ProcessEnv + capturedAt: number + /** The profile-loading shell produced it, rather than Orca falling back to its own env. */ + profileLoaded: boolean +} + /** - * One capture of the host shell environment. Windows loads the PowerShell profile once per - * process; later refreshes only reread the saved PATH over the last snapshot. + * One capture of the host shell environment. Windows loads the PowerShell profile until a load + * succeeds; after that, refreshes only reread the saved PATH over the last snapshot. */ async function captureHostShellEnvironment( - previous: NodeJS.ProcessEnv | null + previous: ShellSnapshot | null ): Promise { if (process.platform !== 'win32') { // Why: the first capture shares a run a startup reader already began; later ones need a fresh run. return captureLoginShellEnvironment({ force: previous !== null }) } - const env = { ...(previous ?? process.env) } + const loaded = previous?.profileLoaded ? previous.env : null + const env = { ...(loaded ?? process.env) } await mergePersistedWindowsPathAsync(env, { forceRefresh: true }) - return previous ? { status: 'captured', env } : captureLoginShellEnvironment({ force: true, env }) + return loaded ? { status: 'captured', env } : captureLoginShellEnvironment({ force: true, env }) } // Why: long enough that a burst of starts shares one capture, short enough that a CLI @@ -123,9 +153,8 @@ const SHELL_ENVIRONMENT_TTL_MS = 10_000 /** * Stale-while-refresh: the first capture starts at creation; later acquisitions get the last * snapshot at once and, once it is older than the TTL, refresh it in the background. A capture - * that fell back to Orca's own env never replaces a snapshot. macOS/Linux retry the shell on the - * next refresh; Windows loads the profile once, so a failed first PowerShell capture stays until - * restart, as before. + * that fell back to Orca's own env never replaces a loaded profile, and the next refresh retries + * the shell, Windows' PowerShell profile included. */ export function createStructuredAgentEnvironmentResolvers( sources: StructuredAgentEnvironmentSources @@ -134,8 +163,11 @@ export function createStructuredAgentEnvironmentResolvers( resolveClaudeInheritedEnv: () => Promise> /** The shared base every agent's child env starts from, before its own overlay. */ resolveBaseEnvironment: () => Promise> + /** For a launch that found no program in the snapshot: joins or starts one refresh, waiting at + * most one shell's timeout. */ + refreshAfterMissingProgram: () => Promise } { - let snapshot: { env: NodeJS.ProcessEnv; capturedAt: number } | null = null + let snapshot: ShellSnapshot | null = null let pendingEnvironment: Promise | null = null const injected = sources.resolveEnvironment const capture = injected @@ -145,11 +177,16 @@ export function createStructuredAgentEnvironmentResolvers( }) : captureHostShellEnvironment const refresh = (): Promise => { - pendingEnvironment ??= capture(snapshot?.env ?? null) + pendingEnvironment ??= capture(snapshot) .then((result) => { - const env = result.status === 'captured' || !snapshot ? result.env : snapshot.env - snapshot = { env, capturedAt: Date.now() } - return env + const capturedAt = Date.now() + const profileLoaded = result.status === 'captured' + // Over another fallback a fallback still carries a newer saved Windows PATH. + snapshot = + profileLoaded || !snapshot?.profileLoaded + ? { env: result.env, capturedAt, profileLoaded } + : { ...snapshot, capturedAt } + return snapshot.env }) .finally(() => { pendingEnvironment = null @@ -173,6 +210,7 @@ export function createStructuredAgentEnvironmentResolvers( policy: sources.resolveShellEnvironmentPolicy?.() ?? nativeChatShellEnvironmentPolicy(null) }) return { + refreshAfterMissingProgram: () => withinShellTimeout(refresh()), resolveCodexEnvironment: async () => ({ ...(await resolveBase()), ...(await sources.resolveLaunchEnv?.()), diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 6c26044374f1..2f793ab7639a 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -34,6 +34,8 @@ export type StructuredClaudeRuntimeAdapterDeps = { resolveClaudeLaunchEnv?: () => Promise> | Record /** The env a Claude child inherits before auth stripping; absent inherits Orca's own. */ resolveClaudeInheritedEnv?: () => Promise> + /** Refreshes that env when Claude was not found in it; absent never retries. */ + refreshClaudeEnvironmentAfterMiss?: () => Promise /** Managed-account auth state for a Claude launch, mirroring the terminal preflight. * Required: an absent policy is what silently under-strips. */ resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy @@ -114,6 +116,9 @@ export function createStructuredClaudeRuntimeAdapter( ...(deps.resolveClaudeInheritedEnv ? { resolveInheritedEnv: deps.resolveClaudeInheritedEnv } : {}), + ...(deps.refreshClaudeEnvironmentAfterMiss + ? { refreshEnvironmentAfterMiss: deps.refreshClaudeEnvironmentAfterMiss } + : {}), resolveAuthPolicy: deps.resolveClaudeAuthPolicy, ...(deps.resolveClaudePermissionMode ? { resolvePermissionMode: deps.resolveClaudePermissionMode } diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 8ff96b49d13e..59bddbcce426 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -1,6 +1,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { resolveProviderChildEnv } from '../provider-process/provider-process-launch' const shim = vi.hoisted(() => ({ ensureLinuxTerminalOrcaCliShimDir: vi.fn() })) vi.mock('../cli/linux-terminal-orca-cli-shim', () => shim) @@ -71,9 +72,12 @@ describe('structuredSessionChildIdentityEnv', () => { ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca'), ORCA_CLI_BIN_DIR: SHIM_DIR, - ORCA_CLI_OWNING_HOST: '1', // The instance that minted the id, so any current CLI dials it rather than the default. - ORCA_USER_DATA_PATH: USER_DATA + ORCA_USER_DATA_PATH: USER_DATA, + // Empty, which the CLI reads as unset, so a profile's selector cannot send it elsewhere. + ORCA_ENVIRONMENT: '', + ORCA_PAIRING_CODE: '', + ORCA_REMOTE_PAIRING: '' }) // A chat names itself by its id alone: no handle, no pane key. expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined() @@ -81,6 +85,24 @@ describe('structuredSessionChildIdentityEnv', () => { expect(childEnv).toEqual({ PATH: '/usr/bin' }) }) + it.each(['ORCA_ENVIRONMENT', 'ORCA_PAIRING_CODE', 'ORCA_REMOTE_PAIRING'])( + "keeps an inherited %s from sending the agent's CLI to another server", + (key) => { + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + // A profile's export reaches both the shell snapshot a launch builds on and the server's + // own env, which the spawn merges under the launch's overlay. + const inherited = { PATH: '/usr/bin', [key]: 'other-server' } + const launch = { env: structuredSessionChildIdentityEnv(SESSION_ID, inherited) } + const childEnv = resolveProviderChildEnv(launch, inherited) + expect(childEnv).toMatchObject({ + ORCA_USER_DATA_PATH: USER_DATA, + ORCA_ENVIRONMENT: '', + ORCA_PAIRING_CODE: '', + ORCA_REMOTE_PAIRING: '' + }) + } + ) + it('replaces an id inherited from an Orca launched inside another session', () => { installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) const env = structuredSessionChildIdentityEnv(SESSION_ID, { @@ -111,11 +133,17 @@ describe('structuredSessionChildIdentityEnv', () => { const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin', ORCA_USER_DATA_PATH: '/other/profile', - ORCA_CLI_OWNING_HOST: 'stale' + ORCA_ENVIRONMENT: 'stale-shell-selection', + ORCA_PAIRING_CODE: 'stale-pairing', + ORCA_REMOTE_PAIRING: 'stale-remote-pairing' }) expect(env.ORCA_CLI_COMMAND).toBe(launcher) expect(env.ORCA_USER_DATA_PATH).toBe(USER_DATA) - expect(env.ORCA_CLI_OWNING_HOST).toBe('1') + expect(env).toMatchObject({ + ORCA_ENVIRONMENT: '', + ORCA_PAIRING_CODE: '', + ORCA_REMOTE_PAIRING: '' + }) expect(env.ORCA_AGENT_SESSION_ID).toBe(SESSION_ID) expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) }) diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index c37c98fc4aa1..ac04d680110f 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -27,7 +27,11 @@ * inherited value names another app. * * `ORCA_USER_DATA_PATH` pins this instance beside the identity, so any current CLI — the session's - * own or a global one — dials the Orca that minted the id instead of the production default. + * own or a global one — dials the Orca that minted the id instead of the production default. The + * remote selectors a shell profile may export are set empty, which the CLI reads as unset: empty + * rather than deleted, because the child is spawned over Orca's own env, which may carry them. + * Explicit `--environment` / `--pairing-code` flags still work, and a person's terminal keeps + * its selectors. A login shell the agent itself starts can export them again. * * Deliberately NOT `ORCA_PANE_KEY`. Claude structured sessions run hooks, and a pane key in their * environment starts flowing into hook-emitted agent-status payloads and the hook-attestation, @@ -47,10 +51,6 @@ import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' -import { - bindOrcaCliToExecutionHost, - ORCA_CLI_OWNING_HOST_ENV -} from '../../shared/cli-execution-host-env' import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' import { resolveStructuredWorkerIdentityForSession } from './structured-worker-authority' @@ -69,20 +69,28 @@ export function structuredSessionChildIdentityEnv( return env } +const REMOTE_RUNTIME_SELECTOR_ENV_KEYS = [ + 'ORCA_ENVIRONMENT', + 'ORCA_PAIRING_CODE', + 'ORCA_REMOTE_PAIRING' +] as const + /** * A host with no app environment installed — a plain-Node fork, or a unit test — has no userData * root to resolve, and inventing one would write a shim into the wrong directory. */ function applyThisAppCli(env: Record): void { delete env.ORCA_CLI_COMMAND - delete env[ORCA_CLI_OWNING_HOST_ENV] if (!hasAppEnvironment()) { return } const app = getAppEnvironment() const userDataPath = app.getPath('userData') const isPackaged = app.isPackaged() - bindOrcaCliToExecutionHost(env, userDataPath) + env.ORCA_USER_DATA_PATH = userDataPath + for (const key of REMOTE_RUNTIME_SELECTOR_ENV_KEYS) { + env[key] = '' + } const launcher = prependOrcaCliDirToChildPath(env, { isPackaged, userDataPath, diff --git a/src/main/startup/login-shell-environment.ts b/src/main/startup/login-shell-environment.ts index 2a2f7bf023f0..b93943949437 100644 --- a/src/main/startup/login-shell-environment.ts +++ b/src/main/startup/login-shell-environment.ts @@ -9,7 +9,8 @@ import { const START_MARKER = '__ORCA_LOGIN_SHELL_ENV_START__' const END_MARKER = '__ORCA_LOGIN_SHELL_ENV_END__' -const SPAWN_TIMEOUT_MS = 5000 +/** How long one profile-loading shell may run before Orca stops waiting for it. */ +export const LOGIN_SHELL_SPAWN_TIMEOUT_MS = 5000 /** Whether a profile-loading shell produced the env, or Orca fell back to its own. */ export type LoginShellEnvironmentCapture = { @@ -130,7 +131,7 @@ function spawnShellAndReadEnvironment( // Best-effort timeout cleanup. } finish(null) - }, SPAWN_TIMEOUT_MS) + }, LOGIN_SHELL_SPAWN_TIMEOUT_MS) child.stdin.on('error', () => {}) child.stdout.on('error', () => finish(null)) child.stderr.on('error', () => {}) diff --git a/src/shared/cli-execution-host-env.ts b/src/shared/cli-execution-host-env.ts deleted file mode 100644 index 220364d3f740..000000000000 --- a/src/shared/cli-execution-host-env.ts +++ /dev/null @@ -1,23 +0,0 @@ -export const ORCA_CLI_OWNING_HOST_ENV = 'ORCA_CLI_OWNING_HOST' - -export function bindOrcaCliToExecutionHost( - env: Record, - userDataPath: string -): void { - env.ORCA_USER_DATA_PATH = userDataPath - env[ORCA_CLI_OWNING_HOST_ENV] = '1' -} - -export function readCliRemoteSelectionDefaults(env: NodeJS.ProcessEnv = process.env): { - pairingCode: string | null - environment: string | null -} { - // Shell profiles cannot retarget a child's owning-server CLI; explicit flags still can. - if (env[ORCA_CLI_OWNING_HOST_ENV] === '1') { - return { pairingCode: null, environment: null } - } - return { - pairingCode: env.ORCA_PAIRING_CODE ?? env.ORCA_REMOTE_PAIRING ?? null, - environment: env.ORCA_ENVIRONMENT ?? null - } -} From 326463387488a83081e453e43f9d820b5994aeab Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:11:59 -0700 Subject: [PATCH 13/21] One shared shell reread when an agent's program is missing The pre-create check for OpenCode, OMP and Pi forced its own login-shell capture on every refusal: not shared with launches, one per concurrent check, triggered by an installed-but-too-old version too, and an older capture finishing last could overwrite a newer cached environment. On Windows, every launch miss also reran a failed PowerShell profile load, bypassing the 10-second limit. - One saved shell environment per host: chat launches, their misses, the pre-create check and AI commit messages read it and share one refresh. - The pre-create check resolves the agent's program the way a launch does, so only a missing program waits for the refresh; registrations declare how to resolve their launch and check its version. - A forced login-shell capture joins one already running for the same shell, so captures cannot land out of order. - On Windows, a miss within 10 s of a failed PowerShell load rereads only the saved PATH. --- src/main/pi/rpc-runtime-registration.test.ts | 23 ++-- src/main/pi/rpc-runtime-registration.ts | 19 +-- ...-agent-create-support-opencode-rig.test.ts | 2 + ...red-agent-launch-support-probe-omp.test.ts | 2 + ...uctured-agent-launch-support-probe.test.ts | 2 + .../structured-agent-launch-support.test.ts | 71 +++++++--- .../structured-agent-launch-support.ts | 40 ++++-- ...ed-agent-runtime-registrations-acp.test.ts | 6 +- .../structured-agent-runtime-registrations.ts | 48 +++---- ...structured-agent-shell-environment.test.ts | 33 ++++- .../structured-agent-shell-environment.ts | 126 +++++++++++------- .../login-shell-environment-cache.test.ts | 30 +++++ src/main/startup/login-shell-environment.ts | 26 ++-- 13 files changed, 291 insertions(+), 137 deletions(-) diff --git a/src/main/pi/rpc-runtime-registration.test.ts b/src/main/pi/rpc-runtime-registration.test.ts index 0fdd15aaa4a2..5b684b5f3d7e 100644 --- a/src/main/pi/rpc-runtime-registration.test.ts +++ b/src/main/pi/rpc-runtime-registration.test.ts @@ -3,17 +3,24 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runProcess } from '@orca/process-host' +import { isAgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import { PI_RPC_RUNTIME_REGISTRATION } from './rpc-runtime-registration' vi.mock('@orca/process-host', () => ({ runProcess: vi.fn() })) -type LaunchInput = Parameters>[0] -function supportsLaunch(launch: LaunchInput): Promise { - const check = PI_RPC_RUNTIME_REGISTRATION.supportsLaunch +type LaunchInput = { + cwd: string + env: Record + commandSettings: StructuredAgentCommandSettings +} +/** The registration's check as create runs it, without the host's shell refresh. */ +async function supportsLaunch({ cwd, env, commandSettings }: LaunchInput): Promise { + const check = PI_RPC_RUNTIME_REGISTRATION.launchCheck if (!check) { throw new Error('Pi registers a launch check') } - return check(launch) + return check.supportsVersion({ ...check.resolveLaunch(env, commandSettings), cwd }) } let root: string let stockPi: string @@ -98,10 +105,10 @@ describe('Pi launch support before session creation', () => { expect(runProcess).toHaveBeenCalledWith(expect.objectContaining({ program: process.execPath })) }) - it('leaves an unrunnable Command setting to the launch to refuse', async () => { - await expect(supportsLaunch(input({ agentCmdOverrides: { pi: '/missing/pi' } }))).resolves.toBe( - true - ) + it('refuses an unrunnable Command setting the way its launch does', async () => { + await expect( + supportsLaunch(input({ agentCmdOverrides: { pi: '/missing/pi' } })) + ).rejects.toSatisfy(isAgentSessionPreSpawnError) expect(runProcess).not.toHaveBeenCalled() }) }) diff --git a/src/main/pi/rpc-runtime-registration.ts b/src/main/pi/rpc-runtime-registration.ts index 51a4e86f9453..041a1b3a1957 100644 --- a/src/main/pi/rpc-runtime-registration.ts +++ b/src/main/pi/rpc-runtime-registration.ts @@ -1,7 +1,6 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { agentSessionAccountHome } from '../../shared/agent-session-account-home' -import { isAgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import type { StructuredAgentAdapterContext, @@ -74,18 +73,12 @@ export const PI_RPC_RUNTIME_REGISTRATION: StructuredAgentRuntimeRegistration = { }) }), supportsLocation: supportsSupervisedProviderChildLocation, - supportsLaunch: async ({ cwd, env, commandSettings }) => { - let command: string - try { - command = resolvePiRpcCommand(env, commandSettings) - } catch (error) { - // An unrunnable Command setting is the launch's refusal to state, not a terminal. - if (isAgentSessionPreSpawnError(error)) { - return true - } - throw error - } - return piRpcVersionSupported({ command, cwd, env }) + launchCheck: { + resolveLaunch: (env, commandSettings) => ({ + command: resolvePiRpcCommand(env, commandSettings), + env + }), + supportsVersion: (launch) => piRpcVersionSupported(launch) }, resolveAccountHome: async ({ launchEnv }) => agentSessionAccountHome( diff --git a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts index f08fb2aff886..873f3f91940a 100644 --- a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts +++ b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' import { resolveHostStructuredAgentCreateSupport } from './structured-agent-launch-support' +import { resetHostShellEnvironmentForTests } from './structured-agent-shell-environment' // Live QA run 5 on #25845: the app launched with HOME and every XDG directory pointed into the rig, // and OpenCode's Command and per-agent PATH both naming a private 1.18.31. The host's whole create @@ -73,6 +74,7 @@ function createSupport(settings: ReturnType) { } beforeEach(async () => { + resetHostShellEnvironmentForTests() root = await mkdtemp(join(tmpdir(), 'orca-opencode-rig-')) await mkdir(join(root, 'proj')) const homebrew = await fakeOpencode(join(root, 'homebrew'), '2.0.21') diff --git a/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts b/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts index 5d73700c99d9..fc6ab41d34e8 100644 --- a/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts +++ b/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts @@ -7,6 +7,7 @@ import { agentSessionRecordFixture } from '../../shared/agent-session-record.tes import { acpLaunchSpecFor } from '../acp/acp-launch-specs' import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' +import { resetHostShellEnvironmentForTests } from './structured-agent-shell-environment' const { loginShell } = vi.hoisted(() => { const loginShell: { env: Record } = { env: {} } @@ -73,6 +74,7 @@ function launchWith(settings: Settings) { } beforeEach(async () => { + resetHostShellEnvironmentForTests() root = await mkdtemp(join(tmpdir(), 'orca-omp-probe-')) shellBin = await fakeOmp(join(root, 'shell-bin'), '16.2.0') privateBin = await fakeOmp(join(root, 'omp-prefix', 'bin'), '17.0.5') diff --git a/src/main/runtime/structured-agent-launch-support-probe.test.ts b/src/main/runtime/structured-agent-launch-support-probe.test.ts index c1bf9c06a7a7..5acce6171ad2 100644 --- a/src/main/runtime/structured-agent-launch-support-probe.test.ts +++ b/src/main/runtime/structured-agent-launch-support-probe.test.ts @@ -7,6 +7,7 @@ import { agentSessionRecordFixture } from '../../shared/agent-session-record.tes import { acpLaunchSpecFor } from '../acp/acp-launch-specs' import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' +import { resetHostShellEnvironmentForTests } from './structured-agent-shell-environment' const { loginShell } = vi.hoisted(() => { const loginShell: { env: Record } = { env: {} } @@ -73,6 +74,7 @@ function launchWith(settings: Settings) { } beforeEach(async () => { + resetHostShellEnvironmentForTests() root = await mkdtemp(join(tmpdir(), 'orca-opencode-probe-')) shellBin = await fakeOpencode(join(root, 'homebrew'), '1.18.30') privateBin = await fakeOpencode(join(root, 'oc-prefix', 'bin'), '1.18.31') diff --git a/src/main/runtime/structured-agent-launch-support.test.ts b/src/main/runtime/structured-agent-launch-support.test.ts index 99c890250e26..0139b3ba176f 100644 --- a/src/main/runtime/structured-agent-launch-support.test.ts +++ b/src/main/runtime/structured-agent-launch-support.test.ts @@ -6,11 +6,19 @@ import { agentSessionRecordFixture } from '../../shared/agent-session-record.tes import { acpLaunchSpecFor } from '../acp/acp-launch-specs' import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' +import { + createStructuredAgentEnvironmentResolvers, + resetHostShellEnvironmentForTests +} from './structured-agent-shell-environment' const { probeAgentCliVersion, loginShell } = vi.hoisted(() => { - const loginShell: { env: Record; reread: () => void } = { + const loginShell: { + env: Record + capture: ReturnType + } = { env: {}, - reread: () => {} + // Each call is one shell run; a test can hold one open to see who shares it. + capture: vi.fn(async () => ({ status: 'captured', env: loginShell.env })) } return { probeAgentCliVersion: vi.fn(), loginShell } }) @@ -21,10 +29,7 @@ vi.mock('../agent-cli-version-probe', async (importOriginal) => ({ vi.mock('../startup/login-shell-environment', () => ({ LOGIN_SHELL_SPAWN_TIMEOUT_MS: 5000, resolveLoginShellEnvironment: async () => loginShell.env, - captureLoginShellEnvironment: async () => { - loginShell.reread() - return { status: 'captured', env: loginShell.env } - } + captureLoginShellEnvironment: loginShell.capture })) let bin: string @@ -53,7 +58,8 @@ beforeEach(async () => { await writeFile(opencode, '#!/bin/sh\n') await chmod(opencode, 0o755) loginShell.env = { PATH: bin, HOME: '/home/user' } - loginShell.reread = () => {} + loginShell.capture.mockClear() + resetHostShellEnvironmentForTests() }) afterEach(async () => { probeAgentCliVersion.mockReset() @@ -89,18 +95,47 @@ describe('structuredAgentSupportsLaunch', () => { }) }) - it('asks again after rereading the shell when the last read finds no runnable OpenCode', async () => { - // Only the profile's newer PATH reaches an OpenCode that answers its version. - probeAgentCliVersion.mockImplementation(async (input, supports) => { - asked.push(input) - return input.env.PATH === bin && supports('1.18.31') - }) - loginShell.env = { PATH: '/usr/bin', HOME: '/home/user' } - loginShell.reread = () => { - loginShell.env = { PATH: bin, HOME: '/home/user' } + it.skipIf(process.platform === 'win32')( + 'shares one shell reread between create checks and launches that find no OpenCode', + async () => { + installedVersion('1.18.31') + // A name only the reread PATH has, so no OpenCode on the machine running the test answers. + const name = `opencode-installed-${process.pid}` + await writeFile(join(bin, name), '#!/bin/sh\n') + await chmod(join(bin, name), 0o755) + const installing = { + ...runtime, + requireStore: () => ({ + getSettings: () => ({ ...settings, agentCmdOverrides: { opencode: name } }) + }) + } + loginShell.env = { PATH: join(bin, 'not-installed'), HOME: '/home/user' } + // A chat launched from this host reads the same snapshot. + const launches = createStructuredAgentEnvironmentResolvers({}) + expect((await launches.resolveBaseEnvironment()).PATH).toBe(join(bin, 'not-installed')) + const reread = Promise.withResolvers<{ status: 'captured'; env: Record }>() + loginShell.capture.mockReturnValueOnce(reread.promise) + const checks = Array.from({ length: 8 }, () => + structuredAgentSupportsLaunch('opencode', 'id:workspace-1', installing) + ) + const launchMiss = launches.refreshAfterMissingProgram() + await vi.waitFor(() => expect(loginShell.capture).toHaveBeenCalledTimes(2)) + reread.resolve({ status: 'captured', env: { PATH: bin, HOME: '/home/user' } }) + expect(await Promise.all(checks)).toEqual(Array(8).fill(true)) + await launchMiss + expect(loginShell.capture).toHaveBeenCalledTimes(2) + expect(asked.map(({ program }) => program)).toEqual(Array(8).fill(join(bin, name))) + expect((await launches.resolveBaseEnvironment()).PATH).toBe(bin) } - expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(true) - expect(asked.map(({ env }) => env.PATH)).toEqual(['/usr/bin', bin]) + ) + + it('never rereads the shell for an OpenCode that is installed but unsupported', async () => { + installedVersion('1.18.30') + expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(false) + expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(false) + // Only the first read of the shell; neither refusal asked for another. + expect(loginShell.capture).toHaveBeenCalledOnce() + expect(asked).toHaveLength(2) }) it('refuses when the version cannot be read', async () => { diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index 983dd92f3ed3..975e339220a2 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -8,6 +8,8 @@ import { warnStructuredAgentSessionCreateUnsupported, type StructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support' +import { isAgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { resolveAgainAfterRefreshOnMissingProgram } from '../native-chat/structured-agent-command-resolution' import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' import { refreshHostAgentBaseEnvironmentAfterMiss, @@ -34,25 +36,35 @@ export async function structuredAgentSupportsLaunch( worktreeSelector: string, runtime: LaunchSupportRuntime ): Promise { - const supportsLaunch = structuredAgentRuntimeRegistration(agent)?.supportsLaunch - if (!supportsLaunch) { + const check = structuredAgentRuntimeRegistration(agent)?.launchCheck + if (!check) { return true } const settings = runtime.requireStore().getSettings() - const ask = async (): Promise => { - const env = { - ...(await resolveHostAgentBaseEnvironment(settings)), - ...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) : {}) + let launch: { command: string; env: Record } + try { + // Resolved as a launch resolves, sharing its refresh, so an agent installed since the shell + // was last read counts and a found one never waits. + launch = await resolveAgainAfterRefreshOnMissingProgram( + async () => + check.resolveLaunch( + { + ...(await resolveHostAgentBaseEnvironment(settings)), + ...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) : {}) + }, + settings + ), + refreshHostAgentBaseEnvironmentAfterMiss + ) + } catch (error) { + // An unrunnable Command setting is the launch's refusal to state, not a terminal. + if (isAgentSessionPreSpawnError(error)) { + return true } - const cwd = (await runtime.resolveRuntimeFileTarget(worktreeSelector)).worktree.path - return supportsLaunch({ cwd, env, commandSettings: settings }) + throw error } - if (await ask()) { - return true - } - // An agent installed since the shell was last read is found once that read is refreshed. - await refreshHostAgentBaseEnvironmentAfterMiss() - return ask() + const cwd = (await runtime.resolveRuntimeFileTarget(worktreeSelector)).worktree.path + return check.supportsVersion({ ...launch, cwd }) } /** `agentSession.createSupport` on this host: every refusal it can know before spawning. The diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts index 809b99aaeea3..5ce8fd3fe785 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -39,9 +39,9 @@ describe('ACP agents in the runtime registrations', () => { it('leaves the `opencode2` agent on its terminal-backed chat, and asks `opencode` its version', () => { expect(structuredAgentRuntimeRegistration('opencode2')).toBeNull() - expect(structuredAgentRuntimeRegistration('opencode')?.supportsLaunch).toBeTypeOf('function') + expect(structuredAgentRuntimeRegistration('opencode')?.launchCheck).toBeDefined() // Grok runs whatever is installed: its create asks nothing of the binary. - expect(structuredAgentRuntimeRegistration('grok')?.supportsLaunch).toBeUndefined() + expect(structuredAgentRuntimeRegistration('grok')?.launchCheck).toBeUndefined() }) it('registers OMP on the ACP lane pinning its agent directory, asking its version at create', () => { @@ -52,7 +52,7 @@ describe('ACP agents in the runtime registrations', () => { accountHomeVariable: 'PI_CODING_AGENT_DIR', capabilities: { imagePrompts: false, steering: 'queue', approvalEnforcement: 'orca' } }) - expect(registration?.supportsLaunch).toBeTypeOf('function') + expect(registration?.launchCheck).toBeDefined() }) it('opens OMP as a structured chat with Chat UI on', () => { diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 4cfc703ee01b..edf22ee557c1 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -13,10 +13,9 @@ import { import { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' -import { - isAgentSessionPreSpawnError, - type StructuredAgentSessionAdapter, - type StructuredAgentSessionLifecycleEvent +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' @@ -102,6 +101,20 @@ export type StructuredAgentModelCatalogContext = Pick } +export type StructuredAgentLaunchCheck = { + /** The binary and env a launch from `env` runs; throws the launch's own pre-spawn refusal. */ + resolveLaunch: ( + env: Record, + commandSettings: StructuredAgentCommandSettings + ) => { command: string; env: Record } + /** Whether that binary is a release this agent runs a structured chat on. */ + supportsVersion: (launch: { + command: string + cwd: string + env: Record + }) => Promise +} + export type StructuredAgentRuntimeRegistration = { definition: StructuredAgentDefinition createAdapter: (context: StructuredAgentAdapterContext) => StructuredAgentRuntimeAdapter @@ -110,13 +123,9 @@ export type StructuredAgentRuntimeRegistration = { modelCatalog: (context: StructuredAgentModelCatalogContext) => AgentModelCatalogDiscovery /** Whether this agent's chats can run at `location`; answered without building the host. */ supportsLocation: (location: AgentSessionExecutionLocation) => boolean - /** Whether the agent installed on this host runs a structured chat, asked at create with the + /** How create asks whether the agent installed on this host runs a structured chat, with the * environment a launch starts from; absent when the location alone decides. */ - supportsLaunch?: (input: { - cwd: string - env: Record - commandSettings: StructuredAgentCommandSettings - }) => Promise + launchCheck?: StructuredAgentLaunchCheck /** The account a chat of this agent pins; see `StructuredAgentAccountHomeRequest`. */ resolveAccountHome: ( request: StructuredAgentAccountHomeRequest, @@ -207,19 +216,12 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio supportsLocation: (location) => supportsSupervisedProviderChildLocation(location), ...(spec.supportsVersion ? { - supportsLaunch: async ({ cwd, env, commandSettings }) => { - const launchEnv = { ...env, ...spec.env } - let command: string - try { - command = resolveAcpLaunchCommand(spec, launchEnv, { commandSettings }) - } catch (error) { - // An unrunnable Command setting is the launch's refusal to state, not a terminal. - if (isAgentSessionPreSpawnError(error)) { - return true - } - throw error - } - return acpLaunchVersionSupported(spec, { command, cwd, env: launchEnv }) + launchCheck: { + resolveLaunch: (shellEnv, commandSettings) => { + const env = { ...shellEnv, ...spec.env } + return { command: resolveAcpLaunchCommand(spec, env, { commandSettings }), env } + }, + supportsVersion: (launch) => acpLaunchVersionSupported(spec, launch) } } : {}), diff --git a/src/main/runtime/structured-agent-shell-environment.test.ts b/src/main/runtime/structured-agent-shell-environment.test.ts index f04509f1bc70..c096f9859305 100644 --- a/src/main/runtime/structured-agent-shell-environment.test.ts +++ b/src/main/runtime/structured-agent-shell-environment.test.ts @@ -2,13 +2,14 @@ import { EventEmitter } from 'node:events' import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import * as loginShell from '../startup/login-shell-environment' import type * as runProcessModule from '@orca/process-host' import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { createStructuredAgentEnvironmentResolvers, + resetHostShellEnvironmentForTests, structuredAgentBaseEnvironment } from './structured-agent-shell-environment' @@ -30,6 +31,8 @@ vi.mock('@orca/process-host', async (importOriginal) => { const INHERIT_ALL = { inheritAll: true, names: [] } +afterEach(resetHostShellEnvironmentForTests) + /** The value if the promise settles without any timer firing; undefined if it is still waiting. */ async function settledWithoutWaiting(promise: Promise): Promise { let value: T | undefined @@ -247,6 +250,34 @@ describe('createStructuredAgentEnvironmentResolvers', () => { ) }) + it('reruns a failed PowerShell profile at most once per TTL, however often a launch misses', async () => { + let machinePath = 'C:\\Windows' + await withSimulatedWindowsServer( + (key) => (key.startsWith('HKLM') ? machinePath : null), + async () => { + spawnProcessMock.mockImplementation((spec: { env: NodeJS.ProcessEnv }) => + fakePowerShell(spec, true) + ) + const resolvers = createStructuredAgentEnvironmentResolvers({ + resolveShellEnvironmentPolicy: () => INHERIT_ALL + }) + await resolvers.resolveBaseEnvironment() + const profileRuns = spawnProcessMock.mock.calls.length + expect(profileRuns).toBeGreaterThan(0) + machinePath = 'C:\\Windows;C:\\NewTool' + for (let miss = 0; miss < 3; miss += 1) { + await resolvers.refreshAfterMissingProgram() + } + // The saved PATH still reaches a miss, which is where a Windows install lands. + expect(pathSegments(await resolvers.resolveBaseEnvironment())).toContain('C:\\NewTool') + expect(spawnProcessMock).toHaveBeenCalledTimes(profileRuns) + vi.advanceTimersByTime(10_000) + await resolvers.refreshAfterMissingProgram() + expect(spawnProcessMock).toHaveBeenCalledTimes(2 * profileRuns) + } + ) + }) + it('keeps the machine PATH when the user has no saved Path value and reg.exe reads it', async () => { // An Orca server slot ships no registry addon, so this is the path a Windows SSH host takes. await withSimulatedWindowsServer( diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index d804a730faa1..b3bd2c3840ce 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -1,7 +1,6 @@ import { captureLoginShellEnvironment, LOGIN_SHELL_SPAWN_TIMEOUT_MS, - resolveLoginShellEnvironment, type LoginShellEnvironmentCapture } from '../startup/login-shell-environment' import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' @@ -95,12 +94,6 @@ async function withinShellTimeout(pending: Promise): Promise { clearTimeout(timer) } -/** For a check that found no runnable agent: rereads the shell `resolveHostAgentBaseEnvironment` - * serves, waiting at most one shell's timeout, so an agent installed since the last read counts. */ -export function refreshHostAgentBaseEnvironmentAfterMiss(): Promise { - return withinShellTimeout(captureLoginShellEnvironment({ force: true })) -} - /** The base env every local agent launch on this host starts from, read from current settings. */ export async function resolveHostAgentBaseEnvironment( settings: Pick< @@ -109,11 +102,16 @@ export async function resolveHostAgentBaseEnvironment( > ): Promise> { return structuredAgentBaseEnvironment({ - shellEnv: await resolveLoginShellEnvironment(), + shellEnv: await hostShellEnvironment().resolve(), policy: nativeChatShellEnvironmentPolicy(settings) }) } +/** For a check that found no runnable agent: joins or starts the refresh launches share. */ +export function refreshHostAgentBaseEnvironmentAfterMiss(): Promise { + return hostShellEnvironment().refreshAfterMissingProgram() +} + export type StructuredAgentEnvironmentSources = { resolveEnvironment?: () => Promise resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy @@ -129,9 +127,17 @@ type ShellSnapshot = { profileLoaded: boolean } +// Why: long enough that a burst of starts shares one capture, short enough that a CLI +// installed on the host reaches new chats without a restart. +const SHELL_ENVIRONMENT_TTL_MS = 10_000 + +function isStale(snapshot: ShellSnapshot): boolean { + return Date.now() - snapshot.capturedAt >= SHELL_ENVIRONMENT_TTL_MS +} + /** * One capture of the host shell environment. Windows loads the PowerShell profile until a load - * succeeds; after that, refreshes only reread the saved PATH over the last snapshot. + * succeeds, at most once per TTL; otherwise a refresh only rereads the saved PATH. */ async function captureHostShellEnvironment( previous: ShellSnapshot | null @@ -143,39 +149,32 @@ async function captureHostShellEnvironment( const loaded = previous?.profileLoaded ? previous.env : null const env = { ...(loaded ?? process.env) } await mergePersistedWindowsPathAsync(env, { forceRefresh: true }) - return loaded ? { status: 'captured', env } : captureLoginShellEnvironment({ force: true, env }) + if (loaded) { + return { status: 'captured', env } + } + return previous && !isStale(previous) + ? { status: 'fallback', env } + : captureLoginShellEnvironment({ force: true, env }) } -// Why: long enough that a burst of starts shares one capture, short enough that a CLI -// installed on the host reaches new chats without a restart. -const SHELL_ENVIRONMENT_TTL_MS = 10_000 +type ShellEnvironment = { + resolve: () => Promise + /** For a launch that found no program in the snapshot: joins or starts one refresh, waiting at + * most one shell's timeout. */ + refreshAfterMissingProgram: () => Promise +} /** * Stale-while-refresh: the first capture starts at creation; later acquisitions get the last - * snapshot at once and, once it is older than the TTL, refresh it in the background. A capture - * that fell back to Orca's own env never replaces a loaded profile, and the next refresh retries - * the shell, Windows' PowerShell profile included. + * snapshot at once and, once it is older than the TTL, refresh it in the background. One refresh + * runs at a time, so captures never land out of order. A capture that fell back to Orca's own env + * never replaces a loaded profile. */ -export function createStructuredAgentEnvironmentResolvers( - sources: StructuredAgentEnvironmentSources -): { - resolveCodexEnvironment: () => Promise - resolveClaudeInheritedEnv: () => Promise> - /** The shared base every agent's child env starts from, before its own overlay. */ - resolveBaseEnvironment: () => Promise> - /** For a launch that found no program in the snapshot: joins or starts one refresh, waiting at - * most one shell's timeout. */ - refreshAfterMissingProgram: () => Promise -} { +function createShellEnvironment( + capture: (previous: ShellSnapshot | null) => Promise +): ShellEnvironment { let snapshot: ShellSnapshot | null = null let pendingEnvironment: Promise | null = null - const injected = sources.resolveEnvironment - const capture = injected - ? async (): Promise => ({ - status: 'captured', - env: await injected() - }) - : captureHostShellEnvironment const refresh = (): Promise => { pendingEnvironment ??= capture(snapshot) .then((result) => { @@ -193,24 +192,59 @@ export function createStructuredAgentEnvironmentResolvers( }) return pendingEnvironment } - const resolveShellEnvironment = (): Promise => { - if (!snapshot) { - return refresh() - } - if (Date.now() - snapshot.capturedAt >= SHELL_ENVIRONMENT_TTL_MS) { - refresh().catch(() => {}) - } - return Promise.resolve(snapshot.env) - } - // Why: start as soon as the runtime exists so the first chat rarely waits for the profile. + // Why: start as soon as it exists so the first chat rarely waits for the profile. refresh().catch(() => {}) + return { + resolve: () => { + if (!snapshot) { + return refresh() + } + if (isStale(snapshot)) { + refresh().catch(() => {}) + } + return Promise.resolve(snapshot.env) + }, + refreshAfterMissingProgram: () => withinShellTimeout(refresh()) + } +} + +// One per host, so launches, their misses and the create check all share a snapshot and a refresh. +let hostShell: ShellEnvironment | null = null + +function hostShellEnvironment(): ShellEnvironment { + hostShell ??= createShellEnvironment(captureHostShellEnvironment) + return hostShell +} + +export function resetHostShellEnvironmentForTests(): void { + hostShell = null +} + +/** Every agent's child env over the host's shell snapshot; an injected `resolveEnvironment` gets + * its own snapshot. Policy and overlays are re-read per acquisition. */ +export function createStructuredAgentEnvironmentResolvers( + sources: StructuredAgentEnvironmentSources +): { + resolveCodexEnvironment: () => Promise + resolveClaudeInheritedEnv: () => Promise> + /** The shared base every agent's child env starts from, before its own overlay. */ + resolveBaseEnvironment: () => Promise> + refreshAfterMissingProgram: () => Promise +} { + const injected = sources.resolveEnvironment + const shell = injected + ? createShellEnvironment(async () => ({ + status: 'captured', + env: await injected() + })) + : hostShellEnvironment() const resolveBase = async (): Promise> => structuredAgentBaseEnvironment({ - shellEnv: await resolveShellEnvironment(), + shellEnv: await shell.resolve(), policy: sources.resolveShellEnvironmentPolicy?.() ?? nativeChatShellEnvironmentPolicy(null) }) return { - refreshAfterMissingProgram: () => withinShellTimeout(refresh()), + refreshAfterMissingProgram: shell.refreshAfterMissingProgram, resolveCodexEnvironment: async () => ({ ...(await resolveBase()), ...(await sources.resolveLaunchEnv?.()), diff --git a/src/main/startup/login-shell-environment-cache.test.ts b/src/main/startup/login-shell-environment-cache.test.ts index b02d41c72e5e..988849029050 100644 --- a/src/main/startup/login-shell-environment-cache.test.ts +++ b/src/main/startup/login-shell-environment-cache.test.ts @@ -125,3 +125,33 @@ it('serves a cached fallback to other readers while a forced capture runs', asyn await expect(resolveLoginShellEnvironment(options)).resolves.toEqual({ PATH: '/profile/bin' }) expect(spawner).toHaveBeenCalledTimes(2) }) + +it('runs one forced capture at a time, so an older run never lands over a newer one', async () => { + const runs = [ + Promise.withResolvers(), + Promise.withResolvers() + ] + const spawner = vi + .fn<(shell: string, env: NodeJS.ProcessEnv) => Promise>() + .mockResolvedValueOnce({ PATH: '/first' }) + .mockReturnValueOnce(runs[0].promise) + .mockReturnValueOnce(runs[1].promise) + const options = { shellOverride: '/bin/bash', env: { HOME: '/host' }, spawner } + await resolveLoginShellEnvironment(options) + const forced = [ + resolveLoginShellEnvironment({ ...options, force: true }), + resolveLoginShellEnvironment({ ...options, force: true }) + ] + expect(spawner).toHaveBeenCalledTimes(2) + runs[0].resolve({ PATH: '/newest' }) + await expect(Promise.all(forced)).resolves.toEqual([{ PATH: '/newest' }, { PATH: '/newest' }]) + // Had the second call started its own run, this older answer would now replace the newest. + runs[1].resolve({ PATH: '/old' }) + await new Promise((resolve) => setImmediate(resolve)) + await expect(resolveLoginShellEnvironment(options)).resolves.toEqual({ PATH: '/newest' }) + // The next forced call after it settles runs the shell again. + await expect(resolveLoginShellEnvironment({ ...options, force: true })).resolves.toEqual({ + PATH: '/old' + }) + expect(spawner).toHaveBeenCalledTimes(3) +}) diff --git a/src/main/startup/login-shell-environment.ts b/src/main/startup/login-shell-environment.ts index b93943949437..3279601c7ec1 100644 --- a/src/main/startup/login-shell-environment.ts +++ b/src/main/startup/login-shell-environment.ts @@ -18,7 +18,12 @@ export type LoginShellEnvironmentCapture = { env: NodeJS.ProcessEnv } -type CachedEnvironment = { capture: Promise; settled: boolean } +type CachedEnvironment = { + capture: Promise + settled: boolean + /** A forced capture running behind the settled one; later forced callers join it. */ + refreshing?: Promise +} const environmentCache = new Map() const MAX_CACHED_ENVIRONMENTS = 8 @@ -174,9 +179,13 @@ export function captureLoginShellEnvironment( .digest('hex') const shellKey = `${shell ?? ''}\0${fallback ?? ''}\0${envKey}` const cached = environmentCache.get(shellKey) - if (cached && !options.force) { + // Why: one run per key at a time, so an older capture can never land over a newer one. + if (cached && (!options.force || !cached.settled)) { return cached.capture } + if (cached?.refreshing) { + return cached.refreshing + } const fellBack: LoginShellEnvironmentCapture = { status: 'fallback', env } if (!shell) { return Promise.resolve(fellBack) @@ -188,23 +197,18 @@ export function captureLoginShellEnvironment( return captured ? { status: 'captured', env: captured } : fellBack }) .catch(() => fellBack) - if (cached?.settled) { + if (cached) { // Why: readers keep any settled env, a fallback too, during a forced refresh; only a capture replaces it. + cached.refreshing = pending void pending.then((capture) => { + cached.refreshing = undefined if (capture.status === 'captured') { cacheEnvironment(shellKey, Promise.resolve(capture)) } }) return pending } - // Why: a forced capture that falls back yields to whatever the earlier in-flight entry settles to. - const previous = cached?.capture - cacheEnvironment( - shellKey, - previous - ? pending.then((capture) => (capture.status === 'captured' ? capture : previous)) - : pending - ) + cacheEnvironment(shellKey, pending) return pending } From 16af666fef080ed831453d3cb2eff7e47b53645a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:22:23 -0700 Subject: [PATCH 14/21] One owner for the cached shell environment Review found two more races in the reread-on-miss machinery, both from the host wrapper keeping its own snapshot beside the login-shell cache: - A launch that found no program could join a reread that started before the agent was installed, and spend its one retry on the old PATH. - On Windows, saved-PATH rereads renewed the snapshot's age, so a failed PowerShell profile was never retried while launches kept missing. The login-shell cache is now the only owner of the captured environment, the run in progress and which result wins (the newest run started; a fallback never replaces a captured env). It takes three options instead of `force`: - startedAfter: a lookup that found nothing waits for a run that started no earlier than the lookup did, joining one if it has, else starting one. - refreshAfterMs: serve the last result and rerun in the background once the last attempt is older. - keepCaptured: a captured env is kept for good; only a fallback is rerun (Windows' PowerShell profile). The host wrapper keeps only policy: the 10 s interval, on Windows the saved PATH laid over the profile env on every read (a miss forces a fresh registry read), and the settings overlays. Its snapshot, pending promise, staleness and Windows "loaded" bookkeeping are gone. A lookup's start time travels with the miss (resolveAgainAfterRefreshOnMissingProgram passes it). --- .../claude/claude-structured-child-env.ts | 2 +- .../structured-agent-command-resolution.ts | 9 +- ...-agent-create-support-opencode-rig.test.ts | 2 - .../structured-agent-install-refresh.test.ts | 35 ++- ...d-agent-launch-composition.test-support.ts | 2 +- .../structured-agent-launch-composition.ts | 11 +- ...red-agent-launch-support-probe-omp.test.ts | 2 - ...uctured-agent-launch-support-probe.test.ts | 2 - .../structured-agent-launch-support.test.ts | 52 ++-- ...structured-agent-shell-environment.test.ts | 235 ++++++------------ .../structured-agent-shell-environment.ts | 174 ++++--------- .../login-shell-environment-cache.test.ts | 113 ++++++--- .../startup/login-shell-environment.test.ts | 2 +- src/main/startup/login-shell-environment.ts | 102 +++++--- 14 files changed, 323 insertions(+), 420 deletions(-) diff --git a/src/main/claude/claude-structured-child-env.ts b/src/main/claude/claude-structured-child-env.ts index 0626097b998b..0878b20e24e8 100644 --- a/src/main/claude/claude-structured-child-env.ts +++ b/src/main/claude/claude-structured-child-env.ts @@ -20,7 +20,7 @@ function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): Recor export type ClaudeEnvDeps = { resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string /** Refreshes the inherited env after the command was not found in it; absent never retries. */ - refreshEnvironmentAfterMiss?: () => Promise + refreshEnvironmentAfterMiss?: (askedAt: number) => Promise resolveEnv?: () => | Promise | undefined> | Record diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 624b0177f4ae..9414db61ace1 100644 --- a/src/main/native-chat/structured-agent-command-resolution.ts +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -64,15 +64,16 @@ export function resolveStructuredAgentCommand( } /** Resolves a launch's program from the shell snapshot. When none is found there (a bare name back, - * or a Command setting naming no program), waits for one refresh and resolves once more, so an - * agent installed since the last read starts. A found program never waits. */ + * or a Command setting naming no program), waits for a reread that started after this lookup did + * and resolves once more, so an agent installed before it starts. A found program never waits. */ export async function resolveAgainAfterRefreshOnMissingProgram( resolve: () => Promise, - refreshAfterMiss: (() => Promise) | undefined + refreshAfterMiss: ((askedAt: number) => Promise) | undefined ): Promise { if (!refreshAfterMiss) { return resolve() } + const askedAt = Date.now() try { const resolved = await resolve() if (isAbsolute(resolved.command)) { @@ -83,6 +84,6 @@ export async function resolveAgainAfterRefreshOnMissingProgram) { } beforeEach(async () => { - resetHostShellEnvironmentForTests() root = await mkdtemp(join(tmpdir(), 'orca-opencode-rig-')) await mkdir(join(root, 'proj')) const homebrew = await fakeOpencode(join(root, 'homebrew'), '2.0.21') diff --git a/src/main/runtime/structured-agent-install-refresh.test.ts b/src/main/runtime/structured-agent-install-refresh.test.ts index f86c21a7a800..bf4e4a6b227c 100644 --- a/src/main/runtime/structured-agent-install-refresh.test.ts +++ b/src/main/runtime/structured-agent-install-refresh.test.ts @@ -11,9 +11,7 @@ import { createAcpStructuredLaunchResolver, createCodexStructuredLaunchResolver } from './structured-agent-launch-composition.test-support' -import { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' -const INHERIT_ALL = { inheritAll: true, names: [] } const GROK = acpLaunchSpecFor('grok')! const identity = { sessionId: 'session-alpha-1', @@ -29,26 +27,25 @@ afterEach(() => { }) /** - * A host whose shell profile is read once, after which an agent is installed into a folder only - * the profile puts on PATH, as in the live check over SSH. `name` is unique so nothing on the - * machine running the test can answer for it. + * A host whose shell env was read before an agent was installed into a folder only the profile + * puts on PATH, as in the live check over SSH; only a reread sees it. `name` is unique so nothing + * on the machine running the test can answer for it. */ -async function hostInstallingAfterFirstCapture(name: string) { +function hostInstallingAfterFirstCapture(name: string) { const root = mkdtempSync(join(tmpdir(), 'orca-install-after-capture-')) roots.push(root) const profileBin = join(root, 'profile-bin') - let profilePath = '/usr/bin' - const capture = vi.fn(async () => ({ PATH: profilePath, HOME: root })) - const environment = createStructuredAgentEnvironmentResolvers({ - resolveEnvironment: capture, - resolveShellEnvironmentPolicy: () => INHERIT_ALL - }) - expect((await environment.resolveBaseEnvironment()).PATH).toBe('/usr/bin') mkdirSync(profileBin) const program = join(profileBin, process.platform === 'win32' ? `${name}.cmd` : name) writeFileSync(program, '#!/bin/sh\nexit 0\n') chmodSync(program, 0o755) - profilePath = profileBin + let served = '/usr/bin' + const environment = { + resolveBaseEnvironment: async () => ({ PATH: served, HOME: root }), + refreshAfterMissingProgram: vi.fn(async (_askedAt: number) => { + served = profileBin + }) + } return { environment, program, profileBin } } @@ -62,7 +59,7 @@ function store(record: Partial) { describe('an agent installed after the first shell capture', () => { it("starts Claude's first chat, from the refreshed environment", async () => { const name = `claude-l5-${process.pid}` - const host = await hostInstallingAfterFirstCapture(name) + const host = hostInstallingAfterFirstCapture(name) const sources = await resolveClaudeChildEnvSources({ resolveInheritedEnv: host.environment.resolveBaseEnvironment, refreshEnvironmentAfterMiss: host.environment.refreshAfterMissingProgram, @@ -74,7 +71,7 @@ describe('an agent installed after the first shell capture', () => { it("starts Codex's first chat through the composed launch, from the refreshed environment", async () => { const name = `codex-l5-${process.pid}` - const host = await hostInstallingAfterFirstCapture(name) + const host = hostInstallingAfterFirstCapture(name) const launch = await createCodexStructuredLaunchResolver({ store: store({ provider: 'codex', @@ -91,7 +88,7 @@ describe('an agent installed after the first shell capture', () => { it('starts a Command setting installed after the capture, rather than refusing it', async () => { const name = `grok-l5-${process.pid}` - const host = await hostInstallingAfterFirstCapture(name) + const host = hostInstallingAfterFirstCapture(name) const launch = await createAcpStructuredLaunchResolver(GROK, { store: store({ provider: 'grok', @@ -107,9 +104,9 @@ describe('an agent installed after the first shell capture', () => { it('never refreshes for a program the snapshot already finds', async () => { const name = `codex-l5-found-${process.pid}` - const host = await hostInstallingAfterFirstCapture(name) + const host = hostInstallingAfterFirstCapture(name) // Puts the install in the snapshot before the launch asks. - await host.environment.refreshAfterMissingProgram() + await host.environment.refreshAfterMissingProgram(Date.now()) const refresh = vi.fn(async () => {}) const launch = await createCodexStructuredLaunchResolver({ store: store({ diff --git a/src/main/runtime/structured-agent-launch-composition.test-support.ts b/src/main/runtime/structured-agent-launch-composition.test-support.ts index 0303b55b61f3..1169ffc27586 100644 --- a/src/main/runtime/structured-agent-launch-composition.test-support.ts +++ b/src/main/runtime/structured-agent-launch-composition.test-support.ts @@ -40,7 +40,7 @@ export type LaunchTestSources = Pick & { resolveLaunchEnv?: (agent: string) => Record resolveCommandSettings?: () => StructuredAgentCommandSettings prepareVisuals?: PrepareNativeChatVisuals - refreshAfterMissingProgram?: () => Promise + refreshAfterMissingProgram?: (askedAt: number) => Promise } function definedEnv(env: NodeJS.ProcessEnv): Record { diff --git a/src/main/runtime/structured-agent-launch-composition.ts b/src/main/runtime/structured-agent-launch-composition.ts index 6bd18aaca866..047c2ca081ef 100644 --- a/src/main/runtime/structured-agent-launch-composition.ts +++ b/src/main/runtime/structured-agent-launch-composition.ts @@ -30,8 +30,9 @@ export type StructuredAgentLaunchSources = { resolveAgentLaunchEnv?: (agent: string) => Record resolveCommandSettings?: () => StructuredAgentCommandSettings prepareVisuals?: PrepareNativeChatVisuals - /** Refreshes the base environment after a launch found no program in it; absent never retries. */ - refreshAfterMissingProgram?: () => Promise + /** Rereads the base environment after a lookup that started at `askedAt` found no program in + * it; absent never retries. */ + refreshAfterMissingProgram?: (askedAt: number) => Promise } /** What an agent's part reads. Each read runs once, when the part first asks, so every agent keeps @@ -51,7 +52,7 @@ export type StructuredAgentLaunchBasis = { commandSettings(): StructuredAgentCommandSettings /** For a part that found no program: refreshes the environment, after which both environment * reads return the refreshed one. Absent when the runtime never retries. */ - refreshAfterMissingProgram?: () => Promise + refreshAfterMissingProgram?: (askedAt: number) => Promise } export type StructuredLaunchRequest = { @@ -151,8 +152,8 @@ async function composedPart( commandSettings: once(() => sources.resolveCommandSettings?.() ?? {}), ...(refresh ? { - refreshAfterMissingProgram: async () => { - await refresh() + refreshAfterMissingProgram: async (askedAt) => { + await refresh(askedAt) environments = readEnvironments() } } diff --git a/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts b/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts index b0c15d3afdf8..bf2e09ce2524 100644 --- a/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts +++ b/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts @@ -7,7 +7,6 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { acpLaunchSpecFor } from '../acp/acp-launch-specs' import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' -import { resetHostShellEnvironmentForTests } from './structured-agent-shell-environment' const { loginShell } = vi.hoisted(() => { const loginShell: { env: Record } = { env: {} } @@ -74,7 +73,6 @@ function launchWith(settings: Settings) { } beforeEach(async () => { - resetHostShellEnvironmentForTests() root = await mkdtemp(join(tmpdir(), 'orca-omp-probe-')) shellBin = await fakeOmp(join(root, 'shell-bin'), '16.2.0') privateBin = await fakeOmp(join(root, 'omp-prefix', 'bin'), '17.0.5') diff --git a/src/main/runtime/structured-agent-launch-support-probe.test.ts b/src/main/runtime/structured-agent-launch-support-probe.test.ts index 4d943a38a549..9ad594f924cc 100644 --- a/src/main/runtime/structured-agent-launch-support-probe.test.ts +++ b/src/main/runtime/structured-agent-launch-support-probe.test.ts @@ -7,7 +7,6 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { acpLaunchSpecFor } from '../acp/acp-launch-specs' import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' -import { resetHostShellEnvironmentForTests } from './structured-agent-shell-environment' const { loginShell } = vi.hoisted(() => { const loginShell: { env: Record } = { env: {} } @@ -74,7 +73,6 @@ function launchWith(settings: Settings) { } beforeEach(async () => { - resetHostShellEnvironmentForTests() root = await mkdtemp(join(tmpdir(), 'orca-opencode-probe-')) shellBin = await fakeOpencode(join(root, 'homebrew'), '1.18.30') privateBin = await fakeOpencode(join(root, 'oc-prefix', 'bin'), '1.18.31') diff --git a/src/main/runtime/structured-agent-launch-support.test.ts b/src/main/runtime/structured-agent-launch-support.test.ts index a77b74b1f441..b0937b123ecc 100644 --- a/src/main/runtime/structured-agent-launch-support.test.ts +++ b/src/main/runtime/structured-agent-launch-support.test.ts @@ -6,19 +6,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { acpLaunchSpecFor } from '../acp/acp-launch-specs' import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' +import { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' +import type * as LoginShellModule from '../startup/login-shell-environment' import { - createStructuredAgentEnvironmentResolvers, - resetHostShellEnvironmentForTests -} from './structured-agent-shell-environment' + resetLoginShellEnvironmentCacheForTests, + type ResolveLoginShellEnvironmentOptions as LoginShellOptions +} from '../startup/login-shell-environment' const { probeAgentCliVersion, loginShell } = vi.hoisted(() => { const loginShell: { - env: Record - capture: ReturnType + env: NodeJS.ProcessEnv + spawn: ReturnType Promise>> } = { env: {}, - // Each call is one shell run; a test can hold one open to see who shares it. - capture: vi.fn(async () => ({ status: 'captured', env: loginShell.env })) + // Each call is one shell run through the real cache; a test can hold one open. + spawn: vi.fn(async (): Promise => loginShell.env) } return { probeAgentCliVersion: vi.fn(), loginShell } }) @@ -26,11 +28,18 @@ vi.mock('../agent-cli-version-probe', async (importOriginal) => ({ ...(await importOriginal()), probeAgentCliVersion })) -vi.mock('../startup/login-shell-environment', () => ({ - LOGIN_SHELL_SPAWN_TIMEOUT_MS: 5000, - resolveLoginShellEnvironment: async () => loginShell.env, - captureLoginShellEnvironment: loginShell.capture -})) +vi.mock('../startup/login-shell-environment', async (importOriginal) => { + const real = await importOriginal() + return { + ...real, + captureLoginShellEnvironment: (options: LoginShellOptions = {}) => + real.captureLoginShellEnvironment({ + ...options, + shellOverride: '/bin/test-login-shell', + spawner: loginShell.spawn + }) + } +}) let bin: string const asked: { program: string; cwd: string; env: Record }[] = [] @@ -58,8 +67,8 @@ beforeEach(async () => { await writeFile(opencode, '#!/bin/sh\n') await chmod(opencode, 0o755) loginShell.env = { PATH: bin, HOME: '/home/user' } - loginShell.capture.mockClear() - resetHostShellEnvironmentForTests() + loginShell.spawn.mockClear() + resetLoginShellEnvironmentCacheForTests() }) afterEach(async () => { probeAgentCliVersion.mockReset() @@ -113,17 +122,18 @@ describe('structuredAgentSupportsLaunch', () => { // A chat launched from this host reads the same snapshot. const launches = createStructuredAgentEnvironmentResolvers({}) expect((await launches.resolveBaseEnvironment()).PATH).toBe(join(bin, 'not-installed')) - const reread = Promise.withResolvers<{ status: 'captured'; env: Record }>() - loginShell.capture.mockReturnValueOnce(reread.promise) + const reread = Promise.withResolvers() + loginShell.spawn.mockReturnValueOnce(reread.promise) + const askedAt = Date.now() const checks = Array.from({ length: 8 }, () => structuredAgentSupportsLaunch('opencode', 'id:workspace-1', installing) ) - const launchMiss = launches.refreshAfterMissingProgram() - await vi.waitFor(() => expect(loginShell.capture).toHaveBeenCalledTimes(2)) - reread.resolve({ status: 'captured', env: { PATH: bin, HOME: '/home/user' } }) + const launchMiss = launches.refreshAfterMissingProgram(askedAt) + await vi.waitFor(() => expect(loginShell.spawn).toHaveBeenCalledTimes(2)) + reread.resolve({ PATH: bin, HOME: '/home/user' }) expect(await Promise.all(checks)).toEqual(Array(8).fill(true)) await launchMiss - expect(loginShell.capture).toHaveBeenCalledTimes(2) + expect(loginShell.spawn).toHaveBeenCalledTimes(2) expect(asked.map(({ program }) => program)).toEqual(Array(8).fill(join(bin, name))) expect((await launches.resolveBaseEnvironment()).PATH).toBe(bin) } @@ -134,7 +144,7 @@ describe('structuredAgentSupportsLaunch', () => { expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(false) expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(false) // Only the first read of the shell; neither refusal asked for another. - expect(loginShell.capture).toHaveBeenCalledOnce() + expect(loginShell.spawn).toHaveBeenCalledOnce() expect(asked).toHaveLength(2) }) diff --git a/src/main/runtime/structured-agent-shell-environment.test.ts b/src/main/runtime/structured-agent-shell-environment.test.ts index 93e38ef37e2e..341a44cac47a 100644 --- a/src/main/runtime/structured-agent-shell-environment.test.ts +++ b/src/main/runtime/structured-agent-shell-environment.test.ts @@ -6,10 +6,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import * as loginShell from '../startup/login-shell-environment' import type * as runProcessModule from '@orca/process-host' import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' +import { resolveAgainAfterRefreshOnMissingProgram } from '../native-chat/structured-agent-command-resolution' import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { createStructuredAgentEnvironmentResolvers, - resetHostShellEnvironmentForTests, structuredAgentBaseEnvironment } from './structured-agent-shell-environment' @@ -31,17 +31,7 @@ vi.mock('@orca/process-host', async (importOriginal) => { const INHERIT_ALL = { inheritAll: true, names: [] } -afterEach(resetHostShellEnvironmentForTests) - -/** The value if the promise settles without any timer firing; undefined if it is still waiting. */ -async function settledWithoutWaiting(promise: Promise): Promise { - let value: T | undefined - void promise.then((resolved) => { - value = resolved - }) - await vi.advanceTimersByTimeAsync(0) - return value -} +afterEach(loginShell.resetLoginShellEnvironmentCacheForTests) function pathSegments(env: Record): string[] { const key = Object.keys(env).find((name) => name.toLowerCase() === 'path') @@ -200,7 +190,8 @@ describe('createStructuredAgentEnvironmentResolvers', () => { expect.arrayContaining(['C:\\Windows', 'C:\\Users\\me\\bin']) ) expect(pathSegments(first)).not.toContain('C:\\NewTool') - vi.advanceTimersByTime(10_000) + // Past the saved PATH cache's own 30 s. + vi.advanceTimersByTime(30_000) await resolvers.resolveBaseEnvironment() // A machine-wide install reaches new chats, and the profile's variables stay. await vi.waitFor(async () => { @@ -238,7 +229,7 @@ describe('createStructuredAgentEnvironmentResolvers', () => { expect(spawnProcessMock).toHaveBeenCalledTimes(failedRuns + 1) // Once loaded, a refresh rereads only the saved PATH. const registryReads = runProcessMock.mock.calls.length - vi.advanceTimersByTime(10_000) + vi.advanceTimersByTime(30_000) await resolvers.resolveBaseEnvironment() await vi.waitFor(() => { expect(runProcessMock.mock.calls.length).toBeGreaterThan(registryReads) @@ -250,30 +241,37 @@ describe('createStructuredAgentEnvironmentResolvers', () => { ) }) - it('reruns a failed PowerShell profile at most once per TTL, however often a launch misses', async () => { + it('retries a failed PowerShell profile 10 s after the last attempt, however often launches miss', async () => { let machinePath = 'C:\\Windows' + let profileFails = true await withSimulatedWindowsServer( (key) => (key.startsWith('HKLM') ? machinePath : null), async () => { spawnProcessMock.mockImplementation((spec: { env: NodeJS.ProcessEnv }) => - fakePowerShell(spec, true) + fakePowerShell(spec, profileFails) ) const resolvers = createStructuredAgentEnvironmentResolvers({ resolveShellEnvironmentPolicy: () => INHERIT_ALL }) await resolvers.resolveBaseEnvironment() - const profileRuns = spawnProcessMock.mock.calls.length - expect(profileRuns).toBeGreaterThan(0) + const failedRuns = spawnProcessMock.mock.calls.length + expect(failedRuns).toBeGreaterThan(0) machinePath = 'C:\\Windows;C:\\NewTool' - for (let miss = 0; miss < 3; miss += 1) { - await resolvers.refreshAfterMissingProgram() - } - // The saved PATH still reaches a miss, which is where a Windows install lands. - expect(pathSegments(await resolvers.resolveBaseEnvironment())).toContain('C:\\NewTool') - expect(spawnProcessMock).toHaveBeenCalledTimes(profileRuns) - vi.advanceTimersByTime(10_000) - await resolvers.refreshAfterMissingProgram() - expect(spawnProcessMock).toHaveBeenCalledTimes(2 * profileRuns) + profileFails = false + // A launch misses every 9 s; each miss rereads the saved PATH, and the next start reads. + vi.advanceTimersByTime(9_000) + await resolvers.refreshAfterMissingProgram(Date.now()) + const afterMiss = await resolvers.resolveBaseEnvironment() + expect(pathSegments(afterMiss)).toContain('C:\\NewTool') + expect(spawnProcessMock).toHaveBeenCalledTimes(failedRuns) + vi.advanceTimersByTime(9_000) + await resolvers.refreshAfterMissingProgram(Date.now()) + await resolvers.resolveBaseEnvironment() + // 18 s after the failed attempt, however recent the saved PATH reread. + await vi.waitFor(async () => { + expect((await resolvers.resolveBaseEnvironment()).PROFILE_ONLY).toBe('1') + }) + expect(spawnProcessMock).toHaveBeenCalledTimes(failedRuns + 1) } ) }) @@ -388,154 +386,62 @@ describe('createStructuredAgentEnvironmentResolvers', () => { } ) - it('starts the first capture when created, before any chat asks for it', () => { - const capture = vi.fn(async () => ({ PATH: '/shell/A' })) - createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) - expect(capture).toHaveBeenCalledOnce() - }) - - // Windows reloads only the saved PATH, covered by the simulated-Windows case above. it.skipIf(process.platform === 'win32')( - 'serves the last snapshot to every start after the first and refreshes it past the TTL', + 'retries a launch on a shell run started after it looked, not on one that predates an install', async () => { - const capture = vi - .spyOn(loginShell, 'captureLoginShellEnvironment') - .mockResolvedValueOnce({ status: 'captured', env: { PATH: '/shell/A' } }) - .mockResolvedValueOnce({ status: 'captured', env: { PATH: '/shell/B' } }) + const dir = await mkdtemp(join(tmpdir(), 'orca-structured-shell-install-')) + const [counter, pathFile, hold, shell] = ['count', 'path', 'hold', 'shell.sh'].map((name) => + join(dir, name) + ) + // Run 2 reads PATH, then waits while `hold` exists: a background rerun still going. + await writeFile( + shell, + [ + '#!/bin/sh', + `echo run >> '${counter}'; n=$(wc -l < '${counter}'); p=$(cat '${pathFile}')`, + `if [ $n -eq 2 ]; then while [ -f '${hold}' ]; do sleep 0.02; done; fi`, + 'printf \'\\000__ORCA_LOGIN_SHELL_ENV_START__\\000PATH=%s\\000\\000__ORCA_LOGIN_SHELL_ENV_END__\\000\' "$p"', + '' + ].join('\n') + ) + await chmod(shell, 0o755) + await writeFile(pathFile, '/old') + const originalShell = process.env.SHELL + process.env.SHELL = shell vi.useFakeTimers({ toFake: ['Date'] }) try { - const resolvers = createStructuredAgentEnvironmentResolvers({}) - expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/A') - expect((await resolvers.resolveAgentEnvironment('claude')).PATH).toBe('/shell/A') - expect(capture).toHaveBeenCalledOnce() - // The first capture may share a startup reader's run; refreshes must run the shell again. - expect(capture).toHaveBeenCalledWith(expect.objectContaining({ force: false })) + const resolvers = createStructuredAgentEnvironmentResolvers({ + resolveShellEnvironmentPolicy: () => INHERIT_ALL + }) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/old') + await writeFile(hold, '') vi.advanceTimersByTime(10_000) - // The stale start still gets the old snapshot; the install shows up on the next one. - expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/A') - expect(capture).toHaveBeenCalledTimes(2) - expect(capture).toHaveBeenLastCalledWith(expect.objectContaining({ force: true })) - await new Promise((resolve) => setImmediate(resolve)) - expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/shell/B') + await resolvers.resolveBaseEnvironment() + await vi.waitFor(async () => + expect((await readFile(counter, 'utf8')).trim().split('\n')).toHaveLength(2) + ) + await writeFile(pathFile, '/installed') + vi.advanceTimersByTime(1) + const launch = resolveAgainAfterRefreshOnMissingProgram(async () => { + const { PATH } = await resolvers.resolveBaseEnvironment() + return { command: PATH === '/installed' ? '/installed/agent' : 'agent' } + }, resolvers.refreshAfterMissingProgram) + await new Promise((resolve) => setTimeout(resolve, 300)) + await rm(hold, { force: true }) + expect((await launch).command).toBe('/installed/agent') } finally { vi.useRealTimers() - capture.mockRestore() + if (originalShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = originalShell + } + await rm(dir, { recursive: true, force: true }) } } ) - it('makes only the first start wait on a slow profile', async () => { - vi.useFakeTimers() - try { - let captures = 0 - const capture = vi.fn( - () => - new Promise((resolve) => { - captures += 1 - const path = `/capture-${captures}` - setTimeout(() => resolve({ PATH: path }), 2000) - }) - ) - const resolvers = createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) - const first = resolvers.resolveBaseEnvironment() - expect(await settledWithoutWaiting(first)).toBeUndefined() - await vi.advanceTimersByTimeAsync(2000) - expect((await first).PATH).toBe('/capture-1') - for (const expected of ['/capture-1', '/capture-2', '/capture-3']) { - // Each start is past the TTL, so it starts a 2 s refresh but must not wait for it. - await vi.advanceTimersByTimeAsync(12_000) - expect((await settledWithoutWaiting(resolvers.resolveBaseEnvironment()))?.PATH).toBe( - expected - ) - } - expect(capture).toHaveBeenCalledTimes(4) - } finally { - vi.useRealTimers() - } - }) - - it('does not hold later starts behind a hung profile', async () => { - vi.useFakeTimers() - try { - // The first capture ends at the login-shell timeout with Orca's own env; later ones hang. - const capture = vi - .fn<() => Promise>(() => new Promise(() => {})) - .mockImplementationOnce( - () => new Promise((resolve) => setTimeout(() => resolve({ PATH: '/fallback' }), 5000)) - ) - const resolvers = createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) - const first = resolvers.resolveBaseEnvironment() - await vi.advanceTimersByTimeAsync(5000) - expect((await first).PATH).toBe('/fallback') - for (let start = 0; start < 3; start += 1) { - await vi.advanceTimersByTimeAsync(10_000) - expect((await settledWithoutWaiting(resolvers.resolveBaseEnvironment()))?.PATH).toBe( - '/fallback' - ) - } - // The hung refresh is shared, not restarted per start. - expect(capture).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - - it('lets a launch that found no program wait for one shared refresh, at most one shell timeout', async () => { - vi.useFakeTimers() - try { - const capture = vi - .fn<() => Promise>(() => new Promise(() => {})) - .mockResolvedValueOnce({ PATH: '/before-install' }) - .mockImplementationOnce( - () => new Promise((resolve) => setTimeout(() => resolve({ PATH: '/installed' }), 1000)) - ) - const resolvers = createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) - expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/before-install') - // Inside the TTL only a miss refreshes, and two misses share one run. - const misses = Promise.all([ - resolvers.refreshAfterMissingProgram(), - resolvers.refreshAfterMissingProgram() - ]) - await vi.advanceTimersByTimeAsync(1000) - await misses - expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/installed') - expect(capture).toHaveBeenCalledTimes(2) - // A hung profile holds a miss for one shell timeout, and the snapshot stays. - let released = false - void resolvers.refreshAfterMissingProgram().then(() => { - released = true - }) - await vi.advanceTimersByTimeAsync(4999) - expect(released).toBe(false) - await vi.advanceTimersByTimeAsync(1) - expect(released).toBe(true) - expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/installed') - } finally { - vi.useRealTimers() - } - }) - - it('shares concurrent first captures and retries after a failed first capture', async () => { - let release: (env: NodeJS.ProcessEnv) => void = () => {} - const capture = vi.fn<() => Promise>() - capture.mockRejectedValueOnce(new Error('shell failed')) - const resolvers = createStructuredAgentEnvironmentResolvers({ resolveEnvironment: capture }) - await expect(resolvers.resolveBaseEnvironment()).rejects.toThrow('shell failed') - capture.mockImplementationOnce( - () => - new Promise((resolve) => { - release = resolve - }) - ) - const first = resolvers.resolveBaseEnvironment() - const second = resolvers.resolveAgentEnvironment('claude') - release({ PATH: '/repaired' }) - expect((await first).PATH).toBe('/repaired') - expect((await second).PATH).toBe('/repaired') - expect(capture).toHaveBeenCalledTimes(2) - }) - - it('rereads settings on every acquisition while the shell snapshot is cached', async () => { + it('rereads settings on every acquisition', async () => { let overlay = 'A' const resolveEnvironment = vi.fn(async () => ({ PATH: '/shell/A' })) const resolvers = createStructuredAgentEnvironmentResolvers({ @@ -552,7 +458,6 @@ describe('createStructuredAgentEnvironmentResolvers', () => { PATH: '/shell/A', CURRENT_SETTING: 'B' }) - expect(resolveEnvironment).toHaveBeenCalledOnce() }) it("gives every agent the same base, with each agent's own overlay on it", async () => { diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index 3ee7a18982cd..fd5d7e7d9f03 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -1,9 +1,11 @@ import { captureLoginShellEnvironment, - LOGIN_SHELL_SPAWN_TIMEOUT_MS, - type LoginShellEnvironmentCapture + LOGIN_SHELL_SPAWN_TIMEOUT_MS } from '../startup/login-shell-environment' -import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' +import { + mergePersistedWindowsPathAsync, + readPersistedWindowsPathSegmentsAsync +} from '../pty/windows-environment-path' import type { GlobalSettings } from '../../shared/global-settings-types' import { nativeChatShellEnvironmentPolicy, @@ -94,6 +96,40 @@ async function withinShellTimeout(pending: Promise): Promise { clearTimeout(timer) } +// Why: long enough that a burst of starts shares one capture, short enough that a CLI +// installed on the host reaches new chats without a restart. +const SHELL_ENVIRONMENT_TTL_MS = 10_000 + +/** + * This host's shell env, from the shared login-shell cache: the last result at once, rerun in the + * background past the TTL. Windows keeps a loaded PowerShell profile for good (only a failed load + * is retried) and lays the saved PATH, where installers add themselves, over it on every read. + */ +async function hostShellEnvironment(): Promise { + const windows = process.platform === 'win32' + const { env } = await captureLoginShellEnvironment({ + refreshAfterMs: SHELL_ENVIRONMENT_TTL_MS, + keepCaptured: windows + }) + if (!windows) { + return env + } + const withSavedPath = { ...env } + await mergePersistedWindowsPathAsync(withSavedPath) + return withSavedPath +} + +/** For a lookup that started at `askedAt` and found no program: waits, at most one shell's + * timeout, for a shell run that started no earlier (on Windows, a saved PATH reread), so an + * agent installed before the lookup counts. */ +export function refreshHostAgentBaseEnvironmentAfterMiss(askedAt: number): Promise { + return withinShellTimeout( + process.platform === 'win32' + ? readPersistedWindowsPathSegmentsAsync({ forceRefresh: true }) + : captureLoginShellEnvironment({ startedAfter: askedAt }) + ) +} + /** The base env every local agent launch on this host starts from, read from current settings. */ export async function resolveHostAgentBaseEnvironment( settings: Pick< @@ -102,125 +138,21 @@ export async function resolveHostAgentBaseEnvironment( > ): Promise> { return structuredAgentBaseEnvironment({ - shellEnv: await hostShellEnvironment().resolve(), + shellEnv: await hostShellEnvironment(), policy: nativeChatShellEnvironmentPolicy(settings) }) } -/** For a check that found no runnable agent: joins or starts the refresh launches share. */ -export function refreshHostAgentBaseEnvironmentAfterMiss(): Promise { - return hostShellEnvironment().refreshAfterMissingProgram() -} - export type StructuredAgentEnvironmentSources = { + /** Replaces the host's shell env, read on every acquisition; such a runtime never rereads. */ resolveEnvironment?: () => Promise resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy /** The user's per-agent environment overlay from settings. */ resolveAgentLaunchEnv?: (agent: string) => Record } -type ShellSnapshot = { - env: NodeJS.ProcessEnv - capturedAt: number - /** The profile-loading shell produced it, rather than Orca falling back to its own env. */ - profileLoaded: boolean -} - -// Why: long enough that a burst of starts shares one capture, short enough that a CLI -// installed on the host reaches new chats without a restart. -const SHELL_ENVIRONMENT_TTL_MS = 10_000 - -function isStale(snapshot: ShellSnapshot): boolean { - return Date.now() - snapshot.capturedAt >= SHELL_ENVIRONMENT_TTL_MS -} - -/** - * One capture of the host shell environment. Windows loads the PowerShell profile until a load - * succeeds, at most once per TTL; otherwise a refresh only rereads the saved PATH. - */ -async function captureHostShellEnvironment( - previous: ShellSnapshot | null -): Promise { - if (process.platform !== 'win32') { - // Why: the first capture shares a run a startup reader already began; later ones need a fresh run. - return captureLoginShellEnvironment({ force: previous !== null }) - } - const loaded = previous?.profileLoaded ? previous.env : null - const env = { ...(loaded ?? process.env) } - await mergePersistedWindowsPathAsync(env, { forceRefresh: true }) - if (loaded) { - return { status: 'captured', env } - } - return previous && !isStale(previous) - ? { status: 'fallback', env } - : captureLoginShellEnvironment({ force: true, env }) -} - -type ShellEnvironment = { - resolve: () => Promise - /** For a launch that found no program in the snapshot: joins or starts one refresh, waiting at - * most one shell's timeout. */ - refreshAfterMissingProgram: () => Promise -} - -/** - * Stale-while-refresh: the first capture starts at creation; later acquisitions get the last - * snapshot at once and, once it is older than the TTL, refresh it in the background. One refresh - * runs at a time, so captures never land out of order. A capture that fell back to Orca's own env - * never replaces a loaded profile. - */ -function createShellEnvironment( - capture: (previous: ShellSnapshot | null) => Promise -): ShellEnvironment { - let snapshot: ShellSnapshot | null = null - let pendingEnvironment: Promise | null = null - const refresh = (): Promise => { - pendingEnvironment ??= capture(snapshot) - .then((result) => { - const capturedAt = Date.now() - const profileLoaded = result.status === 'captured' - // Over another fallback a fallback still carries a newer saved Windows PATH. - snapshot = - profileLoaded || !snapshot?.profileLoaded - ? { env: result.env, capturedAt, profileLoaded } - : { ...snapshot, capturedAt } - return snapshot.env - }) - .finally(() => { - pendingEnvironment = null - }) - return pendingEnvironment - } - // Why: start as soon as it exists so the first chat rarely waits for the profile. - refresh().catch(() => {}) - return { - resolve: () => { - if (!snapshot) { - return refresh() - } - if (isStale(snapshot)) { - refresh().catch(() => {}) - } - return Promise.resolve(snapshot.env) - }, - refreshAfterMissingProgram: () => withinShellTimeout(refresh()) - } -} - -// One per host, so launches, their misses and the create check all share a snapshot and a refresh. -let hostShell: ShellEnvironment | null = null - -function hostShellEnvironment(): ShellEnvironment { - hostShell ??= createShellEnvironment(captureHostShellEnvironment) - return hostShell -} - -export function resetHostShellEnvironmentForTests(): void { - hostShell = null -} - -/** Every agent's child env over the host's shell snapshot; an injected `resolveEnvironment` gets - * its own snapshot. Policy and overlays are re-read per acquisition. */ +/** Every agent's child env over the host's shell env. Policy and overlays are re-read per + * acquisition, so a settings change reaches the next chat without a restart. */ export function createStructuredAgentEnvironmentResolvers( sources: StructuredAgentEnvironmentSources ): { @@ -228,22 +160,22 @@ export function createStructuredAgentEnvironmentResolvers( resolveBaseEnvironment: () => Promise> /** The base with the user's overlay for `agent` laid over it. */ resolveAgentEnvironment: (agent: string) => Promise> - refreshAfterMissingProgram: () => Promise + refreshAfterMissingProgram: (askedAt: number) => Promise } { - const injected = sources.resolveEnvironment - const shell = injected - ? createShellEnvironment(async () => ({ - status: 'captured', - env: await injected() - })) - : hostShellEnvironment() + const shellEnvironment = sources.resolveEnvironment ?? hostShellEnvironment + if (!sources.resolveEnvironment) { + // Why: start as soon as the runtime exists so the first chat rarely waits for the profile. + void hostShellEnvironment().catch(() => {}) + } const resolveBase = async (): Promise> => structuredAgentBaseEnvironment({ - shellEnv: await shell.resolve(), + shellEnv: await shellEnvironment(), policy: sources.resolveShellEnvironmentPolicy?.() ?? nativeChatShellEnvironmentPolicy(null) }) return { - refreshAfterMissingProgram: shell.refreshAfterMissingProgram, + refreshAfterMissingProgram: sources.resolveEnvironment + ? async () => {} + : refreshHostAgentBaseEnvironmentAfterMiss, resolveBaseEnvironment: resolveBase, resolveAgentEnvironment: async (agent) => ({ ...(await resolveBase()), diff --git a/src/main/startup/login-shell-environment-cache.test.ts b/src/main/startup/login-shell-environment-cache.test.ts index 988849029050..135cbe9506b8 100644 --- a/src/main/startup/login-shell-environment-cache.test.ts +++ b/src/main/startup/login-shell-environment-cache.test.ts @@ -65,12 +65,12 @@ it('bounds retained environments and supports explicit refresh', async () => { shellOverride: '/bin/bash', env: { HOME: '/host/0' }, spawner, - force: true + startedAfter: Date.now() }) expect(spawner).toHaveBeenCalledTimes(12) }) -it('says whether the shell produced the env, and a failed forced capture keeps the cached one', async () => { +it('says whether the shell produced the env, and a failed rerun keeps the cached one', async () => { const env = { HOME: '/host' } const spawner = vi .fn<(shell: string, env: NodeJS.ProcessEnv) => Promise>() @@ -81,7 +81,9 @@ it('says whether the shell produced the env, and a failed forced capture keeps t status: 'captured', env: { PATH: '/profile/bin' } }) - await expect(captureLoginShellEnvironment({ ...options, force: true })).resolves.toEqual({ + await expect( + captureLoginShellEnvironment({ ...options, startedAfter: Date.now() }) + ).resolves.toEqual({ status: 'fallback', env }) @@ -89,7 +91,7 @@ it('says whether the shell produced the env, and a failed forced capture keeps t expect(spawner).toHaveBeenCalledTimes(2) }) -it('serves the cached env to other readers while a forced capture runs', async () => { +it('serves the cached env to other readers while a rerun runs', async () => { const release = Promise.withResolvers() const spawner = vi .fn<(shell: string, env: NodeJS.ProcessEnv) => Promise>() @@ -97,7 +99,7 @@ it('serves the cached env to other readers while a forced capture runs', async ( .mockReturnValueOnce(release.promise) const options = { shellOverride: '/bin/bash', env: { HOME: '/host' }, spawner } await resolveLoginShellEnvironment(options) - const forced = resolveLoginShellEnvironment({ ...options, force: true }) + const forced = resolveLoginShellEnvironment({ ...options, startedAfter: Date.now() }) const tick = new Promise((resolve) => setImmediate(() => resolve('still waiting'))) await expect(Promise.race([resolveLoginShellEnvironment(options), tick])).resolves.toEqual({ PATH: '/first' @@ -108,7 +110,7 @@ it('serves the cached env to other readers while a forced capture runs', async ( expect(spawner).toHaveBeenCalledTimes(2) }) -it('serves a cached fallback to other readers while a forced capture runs', async () => { +it('serves a cached fallback to other readers while a rerun runs', async () => { const release = Promise.withResolvers() const spawner = vi .fn<(shell: string, env: NodeJS.ProcessEnv) => Promise>() @@ -117,7 +119,7 @@ it('serves a cached fallback to other readers while a forced capture runs', asyn const env = { HOME: '/host' } const options = { shellOverride: '/bin/bash', env, spawner } await expect(captureLoginShellEnvironment(options)).resolves.toMatchObject({ status: 'fallback' }) - const forced = resolveLoginShellEnvironment({ ...options, force: true }) + const forced = resolveLoginShellEnvironment({ ...options, startedAfter: Date.now() }) const tick = new Promise((resolve) => setImmediate(() => resolve('still waiting'))) await expect(Promise.race([resolveLoginShellEnvironment(options), tick])).resolves.toEqual(env) release.resolve({ PATH: '/profile/bin' }) @@ -126,32 +128,73 @@ it('serves a cached fallback to other readers while a forced capture runs', asyn expect(spawner).toHaveBeenCalledTimes(2) }) -it('runs one forced capture at a time, so an older run never lands over a newer one', async () => { - const runs = [ - Promise.withResolvers(), - Promise.withResolvers() - ] - const spawner = vi - .fn<(shell: string, env: NodeJS.ProcessEnv) => Promise>() - .mockResolvedValueOnce({ PATH: '/first' }) - .mockReturnValueOnce(runs[0].promise) - .mockReturnValueOnce(runs[1].promise) - const options = { shellOverride: '/bin/bash', env: { HOME: '/host' }, spawner } - await resolveLoginShellEnvironment(options) - const forced = [ - resolveLoginShellEnvironment({ ...options, force: true }), - resolveLoginShellEnvironment({ ...options, force: true }) - ] - expect(spawner).toHaveBeenCalledTimes(2) - runs[0].resolve({ PATH: '/newest' }) - await expect(Promise.all(forced)).resolves.toEqual([{ PATH: '/newest' }, { PATH: '/newest' }]) - // Had the second call started its own run, this older answer would now replace the newest. - runs[1].resolve({ PATH: '/old' }) - await new Promise((resolve) => setImmediate(resolve)) - await expect(resolveLoginShellEnvironment(options)).resolves.toEqual({ PATH: '/newest' }) - // The next forced call after it settles runs the shell again. - await expect(resolveLoginShellEnvironment({ ...options, force: true })).resolves.toEqual({ - PATH: '/old' - }) - expect(spawner).toHaveBeenCalledTimes(3) +type Spawner = (shell: string, env: NodeJS.ProcessEnv) => Promise + +it('answers a lookup only with a run that started no earlier, so a run predating an install never counts', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const predatesInstall = Promise.withResolvers() + const spawner = vi + .fn() + .mockResolvedValueOnce({ PATH: '/old' }) + .mockReturnValueOnce(predatesInstall.promise) + .mockResolvedValueOnce({ PATH: '/installed' }) + const options = { shellOverride: '/bin/bash', env: { HOME: '/host' }, spawner } + await resolveLoginShellEnvironment(options) + // A background rerun that read the shell before the agent was installed is still going. + vi.advanceTimersByTime(10_000) + await resolveLoginShellEnvironment({ ...options, refreshAfterMs: 10_000 }) + expect(spawner).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(1) + const askedAt = Date.now() + // Lookups that started together share one run. + const lookups = [ + resolveLoginShellEnvironment({ ...options, startedAfter: askedAt }), + resolveLoginShellEnvironment({ ...options, startedAfter: askedAt }) + ] + expect(spawner).toHaveBeenCalledTimes(3) + await expect(Promise.all(lookups)).resolves.toEqual([ + { PATH: '/installed' }, + { PATH: '/installed' } + ]) + // The older run finishing last never replaces the newer result. + predatesInstall.resolve({ PATH: '/old' }) + await new Promise((resolve) => setImmediate(resolve)) + await expect(resolveLoginShellEnvironment(options)).resolves.toEqual({ PATH: '/installed' }) + } finally { + vi.useRealTimers() + } +}) + +it('reruns a failed shell once its last attempt is older, and keeps a captured one when asked', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const spawner = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ PATH: '/repaired' }) + const options = { + shellOverride: '/bin/bash', + env: { HOME: '/host' }, + spawner, + refreshAfterMs: 10_000, + keepCaptured: true + } + await expect(captureLoginShellEnvironment(options)).resolves.toMatchObject({ + status: 'fallback' + }) + vi.advanceTimersByTime(9_999) + await captureLoginShellEnvironment(options) + expect(spawner).toHaveBeenCalledOnce() + vi.advanceTimersByTime(1) + await captureLoginShellEnvironment(options) + await vi.waitFor(async () => { + expect((await captureLoginShellEnvironment(options)).env).toEqual({ PATH: '/repaired' }) + }) + vi.advanceTimersByTime(60_000) + await captureLoginShellEnvironment(options) + expect(spawner).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } }) diff --git a/src/main/startup/login-shell-environment.test.ts b/src/main/startup/login-shell-environment.test.ts index 33d7e2a7ddbf..63a77f2324cb 100644 --- a/src/main/startup/login-shell-environment.test.ts +++ b/src/main/startup/login-shell-environment.test.ts @@ -78,7 +78,7 @@ describe('resolveLoginShellEnvironment', () => { const environment = await resolveLoginShellEnvironment({ shellOverride: shell.path, - force: true + startedAfter: Date.now() }) expect(environment[SHELL_ONLY_VARIABLE]).toBe('shell-only') } diff --git a/src/main/startup/login-shell-environment.ts b/src/main/startup/login-shell-environment.ts index 3279601c7ec1..1378e519be09 100644 --- a/src/main/startup/login-shell-environment.ts +++ b/src/main/startup/login-shell-environment.ts @@ -19,21 +19,22 @@ export type LoginShellEnvironmentCapture = { } type CachedEnvironment = { - capture: Promise - settled: boolean - /** A forced capture running behind the settled one; later forced callers join it. */ - refreshing?: Promise + /** The last good result: a fallback stays only until a capture replaces it. */ + last?: LoginShellEnvironmentCapture + /** When the newest finished run started; an older run finishing later is ignored. */ + attemptedAt: number + /** The newest run still going. */ + running?: { capture: Promise; startedAt: number } } const environmentCache = new Map() const MAX_CACHED_ENVIRONMENTS = 8 -function cacheEnvironment(key: string, capture: Promise): void { - const entry: CachedEnvironment = { capture, settled: false } - void capture.then(() => { - entry.settled = true - }) - environmentCache.delete(key) +function cachedEnvironment(key: string): CachedEnvironment { + const existing = environmentCache.get(key) + if (existing) { + return existing + } while (environmentCache.size >= MAX_CACHED_ENVIRONMENTS) { const oldest = environmentCache.keys().next().value if (oldest === undefined) { @@ -41,7 +42,9 @@ function cacheEnvironment(key: string, capture: Promise Promise } /** Captures the environment seen by commands launched from Orca's profile-loading terminal - * shell, saying whether the shell produced it or it is Orca's own env as a fallback. */ + * shell, saying whether the shell produced it or it is Orca's own env as a fallback. One cache + * per shell and env owns the result, the run in progress and which run's result wins. */ export function captureLoginShellEnvironment( options: ResolveLoginShellEnvironmentOptions = {} ): Promise { @@ -171,45 +180,56 @@ export function captureLoginShellEnvironment( options.shellOverride !== undefined ? options.shellOverride : resolveProfileLoadingShell() const fallback = options.shellOverride === undefined ? resolveProfileLoadingFallbackShell() : null const env = processEnvironment(options.env) + const fellBack: LoginShellEnvironmentCapture = { status: 'fallback', env } + if (!shell) { + return Promise.resolve(fellBack) + } 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) - // Why: one run per key at a time, so an older capture can never land over a newer one. - if (cached && (!options.force || !cached.settled)) { - return cached.capture + const entry = cachedEnvironment(`${shell}\0${fallback ?? ''}\0${envKey}`) + const run = (): Promise => { + const startedAt = Date.now() + const spawner = options.spawner ?? spawnShellAndReadEnvironment + const capture = spawner(shell, env) + .then(async (environment): Promise => { + const captured = environment ?? (fallback ? await spawner(fallback, env) : null) + return captured ? { status: 'captured', env: captured } : fellBack + }) + .catch(() => fellBack) + entry.running = { capture, startedAt } + void capture.then((result) => { + if (entry.running?.capture === capture) { + entry.running = undefined + } + if (startedAt < entry.attemptedAt) { + return + } + entry.attemptedAt = startedAt + if (result.status === 'captured' || entry.last?.status !== 'captured') { + entry.last = result + } + }) + return capture } - if (cached?.refreshing) { - return cached.refreshing + const { running, last } = entry + if (options.startedAfter !== undefined) { + return running && running.startedAt >= options.startedAfter ? running.capture : run() } - const fellBack: LoginShellEnvironmentCapture = { status: 'fallback', env } - if (!shell) { - return Promise.resolve(fellBack) + if (!last) { + return running?.capture ?? run() } - const spawner = options.spawner ?? spawnShellAndReadEnvironment - const pending = spawner(shell, env) - .then(async (environment): Promise => { - const captured = environment ?? (fallback ? await spawner(fallback, env) : null) - return captured ? { status: 'captured', env: captured } : fellBack - }) - .catch(() => fellBack) - if (cached) { - // Why: readers keep any settled env, a fallback too, during a forced refresh; only a capture replaces it. - cached.refreshing = pending - void pending.then((capture) => { - cached.refreshing = undefined - if (capture.status === 'captured') { - cacheEnvironment(shellKey, Promise.resolve(capture)) - } - }) - return pending + const stale = + options.refreshAfterMs !== undefined && + Date.now() - entry.attemptedAt >= options.refreshAfterMs && + !(options.keepCaptured && last.status === 'captured') + if (stale && !running) { + void run() } - cacheEnvironment(shellKey, pending) - return pending + return Promise.resolve(last) } /** Resolves the environment seen by commands launched from Orca's profile-loading terminal shell. */ From c7dcb5a6f637facb2001733d1fd0ec38bdd6ab47 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:04:47 -0700 Subject: [PATCH 15/21] Read an injected shell environment once per runtime, as main does The previous commit read an injected environment on every acquisition, which broke main's 'caches shell exports but re-reads configured overrides for a resume' contract. It is read once when the resolvers are made again. --- .../structured-agent-shell-environment.test.ts | 3 ++- .../runtime/structured-agent-shell-environment.ts | 11 ++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/structured-agent-shell-environment.test.ts b/src/main/runtime/structured-agent-shell-environment.test.ts index 341a44cac47a..b2d6044acfcf 100644 --- a/src/main/runtime/structured-agent-shell-environment.test.ts +++ b/src/main/runtime/structured-agent-shell-environment.test.ts @@ -441,7 +441,7 @@ describe('createStructuredAgentEnvironmentResolvers', () => { } ) - it('rereads settings on every acquisition', async () => { + it('rereads settings on every acquisition while the shell env is read once', async () => { let overlay = 'A' const resolveEnvironment = vi.fn(async () => ({ PATH: '/shell/A' })) const resolvers = createStructuredAgentEnvironmentResolvers({ @@ -458,6 +458,7 @@ describe('createStructuredAgentEnvironmentResolvers', () => { PATH: '/shell/A', CURRENT_SETTING: 'B' }) + expect(resolveEnvironment).toHaveBeenCalledOnce() }) it("gives every agent the same base, with each agent's own overlay on it", async () => { diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index fd5d7e7d9f03..b71e50784165 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -144,7 +144,7 @@ export async function resolveHostAgentBaseEnvironment( } export type StructuredAgentEnvironmentSources = { - /** Replaces the host's shell env, read on every acquisition; such a runtime never rereads. */ + /** Replaces the host's shell env, read once when the resolvers are made; never reread. */ resolveEnvironment?: () => Promise resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy /** The user's per-agent environment overlay from settings. */ @@ -162,9 +162,10 @@ export function createStructuredAgentEnvironmentResolvers( resolveAgentEnvironment: (agent: string) => Promise> refreshAfterMissingProgram: (askedAt: number) => Promise } { - const shellEnvironment = sources.resolveEnvironment ?? hostShellEnvironment - if (!sources.resolveEnvironment) { - // Why: start as soon as the runtime exists so the first chat rarely waits for the profile. + // Why: start as soon as the runtime exists so the first chat rarely waits for the profile. + const injected = sources.resolveEnvironment?.() + const shellEnvironment = injected ? () => injected : hostShellEnvironment + if (!injected) { void hostShellEnvironment().catch(() => {}) } const resolveBase = async (): Promise> => @@ -173,7 +174,7 @@ export function createStructuredAgentEnvironmentResolvers( policy: sources.resolveShellEnvironmentPolicy?.() ?? nativeChatShellEnvironmentPolicy(null) }) return { - refreshAfterMissingProgram: sources.resolveEnvironment + refreshAfterMissingProgram: injected ? async () => {} : refreshHostAgentBaseEnvironmentAfterMiss, resolveBaseEnvironment: resolveBase, From 828d166fc43a5a22b625cec07e3249b597d1eeea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:41:47 -0700 Subject: [PATCH 16/21] Model lists reread the environment on a missing program, as launches do Live QA: the first new chat after installing Claude showed "Claude wasn't found on the computer running this chat". The chat launch itself reread the login shell and started the new binary, but Claude's model-list check ran first, with the environment read before the install, and never retried: catalog probes were deliberately built without the miss refresh. The retry now lives in each agent's one shared lookup of its binary and environment, which a launch and a model listing both call: resolveCodexStructuredInvocation, resolveAcpLaunchInvocation, a new shared resolvePiRpcInvocation, and resolveClaudeChildEnvSources (which already had it). Each catalog probe is given the runtime's refreshAfterMissingProgram, and the catalog context no longer strips it, so a listing and a launch reread the same single-owner environment the same way. --- .../acp/acp-structured-launch-resolution.ts | 43 ++++++++++----- .../codex-structured-launch-resolution.ts | 28 +++++++--- src/main/pi/rpc-launch-resolution.ts | 38 ++++++++++--- src/main/pi/rpc-model-catalog-probe.ts | 21 ++++--- src/main/pi/rpc-runtime-registration.ts | 1 + ...ured-agent-model-catalog-discovery.test.ts | 55 ++++++++++++++++++- ...tructured-agent-model-catalog-discovery.ts | 8 ++- ...ed-agent-model-catalog-first-frame.test.ts | 3 +- .../structured-agent-runtime-registrations.ts | 8 +-- 9 files changed, 158 insertions(+), 47 deletions(-) diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index 74b37067abe0..a581caf179d9 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -82,6 +82,8 @@ export type AcpLaunchInvocationDeps = { resolveLaunchEnv?: (agent: string) => Record /** The user's per-agent Command setting, re-read per acquisition; none runs the stock binary. */ resolveCommandSettings?: () => StructuredAgentCommandSettings + /** Rereads that env when the agent's program is not found in it; absent never retries. */ + refreshEnvironmentAfterMiss?: (askedAt: number) => Promise } & Pick /** The binary a launch with `env` spawns: the user's Command setting when set, else the stock @@ -145,8 +147,20 @@ export async function acpLaunchVersionSupported( } /** The binary and environment any child of this agent runs under for `accountHome`: a session's - * launch and a catalog probe both resolve here, so neither can reach another account or install. */ -export async function resolveAcpLaunchInvocation( + * launch and a catalog probe both resolve here, so neither can reach another account or install, + * and both reread the environment once when the program is missing from it. */ +export function resolveAcpLaunchInvocation( + spec: AcpLaunchSpec, + accountHome: AgentSessionAccountHome, + deps: AcpLaunchInvocationDeps +): Promise<{ command: string; env: Record; envToDelete: string[] }> { + return resolveAgainAfterRefreshOnMissingProgram( + () => readAcpLaunchInvocation(spec, accountHome, deps), + deps.refreshEnvironmentAfterMiss + ) +} + +async function readAcpLaunchInvocation( spec: AcpLaunchSpec, accountHome: AgentSessionAccountHome, deps: AcpLaunchInvocationDeps @@ -173,17 +187,20 @@ export function acpStructuredLaunchPart( ): StructuredAgentLaunchPartResolver { return async (basis, identity) => { const { record } = basis - const { env, envToDelete, command } = await resolveAgainAfterRefreshOnMissingProgram( - () => - resolveAcpLaunchInvocation(spec, record.accountHome, { - resolveEnvironment: basis.baseEnvironment, - resolveLaunchEnv: () => basis.launchEnv(), - resolveCommandSettings: basis.commandSettings, - ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), - ...(deps.homePath ? { homePath: deps.homePath } : {}), - ...(deps.inheritedEnv ? { inheritedEnv: deps.inheritedEnv } : {}) - }), - basis.refreshAfterMissingProgram + const { env, envToDelete, command } = await resolveAcpLaunchInvocation( + spec, + record.accountHome, + { + resolveEnvironment: basis.baseEnvironment, + resolveLaunchEnv: () => basis.launchEnv(), + resolveCommandSettings: basis.commandSettings, + ...(basis.refreshAfterMissingProgram + ? { refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram } + : {}), + ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), + ...(deps.homePath ? { homePath: deps.homePath } : {}), + ...(deps.inheritedEnv ? { inheritedEnv: deps.inheritedEnv } : {}) + } ) const cwd = await basis.launchDirectory() const visuals = spec.visualsSkill ? await basis.visuals() : null diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 06622e02eaeb..473614d5b6c5 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -36,6 +36,8 @@ export type CodexStructuredInvocationDeps = Pick< > & { /** Fresh shell/configured environment for this spawn; never written to the session record. */ resolveEnvironment?: () => Promise + /** Rereads that environment when Codex is not found in it; absent never retries. */ + refreshEnvironmentAfterMiss?: (askedAt: number) => Promise } export type CodexStructuredInvocation = { @@ -50,7 +52,16 @@ export type CodexStructuredInvocation = { * session it stands in for. Env VALUES stay out of the catalog fingerprint: * drift there heals on the next refresh. */ -export async function resolveCodexStructuredInvocation( +export function resolveCodexStructuredInvocation( + deps: CodexStructuredInvocationDeps +): Promise { + return resolveAgainAfterRefreshOnMissingProgram( + () => readCodexStructuredInvocation(deps), + deps.refreshEnvironmentAfterMiss + ) +} + +async function readCodexStructuredInvocation( deps: CodexStructuredInvocationDeps ): Promise { const environment = await deps.resolveEnvironment?.() @@ -70,14 +81,13 @@ export function codexStructuredLaunchPart( return async (basis) => { const { record } = basis const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome) - const { command } = await resolveAgainAfterRefreshOnMissingProgram( - () => - resolveCodexStructuredInvocation({ - ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), - resolveEnvironment: basis.environment - }), - basis.refreshAfterMissingProgram - ) + const { command } = await resolveCodexStructuredInvocation({ + ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), + ...(basis.refreshAfterMissingProgram + ? { refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram } + : {}), + resolveEnvironment: basis.environment + }) const environment = await basis.environment() const args = codexStructuredLaunchArgs(await deps.resolveLaunchArgs()) const permissionPolicy = deps.resolvePermissionPolicy?.() diff --git a/src/main/pi/rpc-launch-resolution.ts b/src/main/pi/rpc-launch-resolution.ts index ad58e2cc1f35..ccdf4e139740 100644 --- a/src/main/pi/rpc-launch-resolution.ts +++ b/src/main/pi/rpc-launch-resolution.ts @@ -99,6 +99,28 @@ async function sameDirectory(left: string, right: string): Promise { return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b } +/** The binary and environment a Pi child runs under for the account directory `accountDir`: a + * chat's launch and a catalog listing both resolve here, rereading the environment once when Pi + * is missing from it. */ +export function resolvePiRpcInvocation(deps: { + accountDir: string + resolveEnvironment: () => Promise + commandSettings?: StructuredAgentCommandSettings + resolveCommand?: typeof resolveCliCommand + refreshEnvironmentAfterMiss?: (askedAt: number) => Promise +}): Promise<{ command: string; env: Record }> { + return resolveAgainAfterRefreshOnMissingProgram(async () => { + const env: Record = {} + for (const [key, value] of Object.entries(await deps.resolveEnvironment())) { + if (value !== undefined) { + env[key] = value + } + } + env.PI_CODING_AGENT_DIR = deps.accountDir + return { env, command: resolvePiRpcCommand(env, deps.commandSettings, deps.resolveCommand) } + }, deps.refreshEnvironmentAfterMiss) +} + /** Pi's part of a launch: its account directory, session file or fork, binary and release check. */ export function piRpcLaunchPart( deps: PiRpcLaunchPartDeps @@ -110,13 +132,15 @@ export function piRpcLaunchPart( } const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome) const cwd = await basis.launchDirectory() - const { env, command } = await resolveAgainAfterRefreshOnMissingProgram(async () => { - const env = { ...(await basis.environment()), PI_CODING_AGENT_DIR: accountHome.path } - return { - env, - command: resolvePiRpcCommand(env, basis.commandSettings(), deps.resolveCommand) - } - }, basis.refreshAfterMissingProgram) + const { env, command } = await resolvePiRpcInvocation({ + accountDir: accountHome.path, + resolveEnvironment: basis.environment, + commandSettings: basis.commandSettings(), + ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), + ...(basis.refreshAfterMissingProgram + ? { refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram } + : {}) + }) const previous = agentSessionProviderHandleChainHead(record.providerHandleChain) let sessionFile: string | undefined let forkFile: string | undefined diff --git a/src/main/pi/rpc-model-catalog-probe.ts b/src/main/pi/rpc-model-catalog-probe.ts index 6bdb1b63ae90..92edca4cbe75 100644 --- a/src/main/pi/rpc-model-catalog-probe.ts +++ b/src/main/pi/rpc-model-catalog-probe.ts @@ -10,11 +10,13 @@ import type { import { runAgentModelCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import { buildPiRpcLaunch, piRpcSessionlessEnvironment } from './rpc-launch' -import { piRpcVersionSupported, resolvePiRpcCommand } from './rpc-launch-resolution' +import { piRpcVersionSupported, resolvePiRpcInvocation } from './rpc-launch-resolution' import { PI_UNMAPPED_THINKING_LEVELS } from './rpc-options' export type PiModelCatalogProbeDeps = { resolveEnvironment: () => Promise + /** Rereads that environment when Pi is not found in it; absent never retries. */ + refreshEnvironmentAfterMiss?: (askedAt: number) => Promise resolveCommandSettings?: () => StructuredAgentCommandSettings resolveCommand?: typeof resolveCliCommand /** Test seams; production runs the shared supervised listing. */ @@ -39,14 +41,15 @@ export function piModelCatalogFromListing(stdout: string): AgentModelCatalogSucc /** Lists Pi's models with `pi --list-models`, under the binary and environment its chats run. */ export function createPiModelCatalogProbe(deps: PiModelCatalogProbeDeps): AgentModelCatalogProbe { return async (accountHome, options): Promise => { - const env: Record = {} - for (const [key, value] of Object.entries(await deps.resolveEnvironment())) { - if (value !== undefined) { - env[key] = value - } - } - env.PI_CODING_AGENT_DIR = requireLegacyAgentSessionAccountHome(accountHome).path - const command = resolvePiRpcCommand(env, deps.resolveCommandSettings?.(), deps.resolveCommand) + const { env, command } = await resolvePiRpcInvocation({ + accountDir: requireLegacyAgentSessionAccountHome(accountHome).path, + resolveEnvironment: deps.resolveEnvironment, + commandSettings: deps.resolveCommandSettings?.(), + ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), + ...(deps.refreshEnvironmentAfterMiss + ? { refreshEnvironmentAfterMiss: deps.refreshEnvironmentAfterMiss } + : {}) + }) const cwd = deps.homePath ?? homedir() if (!(await piRpcVersionSupported({ command, cwd, env }, deps.probeVersion))) { throw new Error(`pi at ${command} does not run structured chats`) diff --git a/src/main/pi/rpc-runtime-registration.ts b/src/main/pi/rpc-runtime-registration.ts index 329a017170e9..836669022022 100644 --- a/src/main/pi/rpc-runtime-registration.ts +++ b/src/main/pi/rpc-runtime-registration.ts @@ -56,6 +56,7 @@ export const PI_RPC_RUNTIME_REGISTRATION: StructuredAgentRuntimeRegistration = { probe: createPiModelCatalogProbe({ // The environment a chat's launch starts from too. resolveEnvironment: () => context.environment.resolveAgentEnvironment('pi'), + refreshEnvironmentAfterMiss: context.environment.refreshAfterMissingProgram, ...(context.deps.resolveAgentCommandSettings ? { resolveCommandSettings: context.deps.resolveAgentCommandSettings } : {}) diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts index e405e865d1f1..31608a4ff3db 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from './structured-agent-runtime-registrations' import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' import { registeredModelCatalogDiscovery } from './structured-agent-model-catalog-wiring' import { acpModelCatalogDiscovery } from './structured-agent-model-catalog-discovery' import { acpLaunchSpecFor } from '../acp/acp-launch-specs' @@ -33,7 +34,8 @@ function context(): StructuredAgentModelCatalogContext { deps: { stateDirectory: '/state' } as StructuredAgentModelCatalogContext['deps'], environment: { resolveBaseEnvironment: unused, - resolveAgentEnvironment: unused + resolveAgentEnvironment: unused, + refreshAfterMissingProgram: unused } } } @@ -117,7 +119,8 @@ describe('the model catalog contract on every registration', () => { } as StructuredAgentModelCatalogContext['deps'], environment: { resolveBaseEnvironment: env, - resolveAgentEnvironment: env + resolveAgentEnvironment: env, + refreshAfterMissingProgram: async () => {} } }) const variables = { claude: 'CLAUDE_CONFIG_DIR', codex: 'CODEX_HOME', grok: 'GROK_HOME' } @@ -128,4 +131,52 @@ describe('the model catalog contract on every registration', () => { } rmSync(rig, { recursive: true, force: true }) }) + + it.skipIf(process.platform === 'win32')( + 'rereads the environment once, as a launch does, for a program installed since it was read', + async () => { + const rig = mkdtempSync(join(tmpdir(), 'orca-catalog-installed-')) + // Names only the reread PATH has, so nothing on the machine running the test answers. + const installed = (agent: string): string => `${agent}-installed-${process.pid}` + const agents = { + claude: 'CLAUDE_CONFIG_DIR', + codex: 'CODEX_HOME', + grok: 'GROK_HOME', + pi: 'PI_CODING_AGENT_DIR' + } + for (const agent of Object.keys(agents)) { + writeFileSync(join(rig, installed(agent)), '#!/bin/sh\nexit 1\n') + chmodSync(join(rig, installed(agent)), 0o755) + } + let served = '/usr/bin' + const env = async () => ({ PATH: served, HOME: join(rig, 'home') }) + const refresh = vi.fn(async () => { + served = rig + }) + const settings = { agentCmdOverrides: { grok: installed('grok'), pi: installed('pi') } } + const { probes } = registeredModelCatalogDiscovery(STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a probe reads only the command, env and auth resolvers given here. + deps: { + stateDirectory: '/state', + resolveClaudeCommand: (options) => resolveCliCommand(installed('claude'), options), + resolveCodexCommand: (options) => resolveCliCommand(installed('codex'), options), + resolveClaudeAuthPolicy: () => ({ account: 'system' as const }), + resolveAgentCommandSettings: () => settings + } as StructuredAgentModelCatalogContext['deps'], + environment: { + resolveBaseEnvironment: env, + resolveAgentEnvironment: env, + refreshAfterMissingProgram: refresh + } + }) + for (const [agent, variable] of Object.entries(agents)) { + served = '/usr/bin' + spawned.commands.length = 0 + await expect(probes[agent]!({ variable, path: join(rig, 'home') })).rejects.toThrow() + expect(spawned.commands, agent).toEqual([join(rig, installed(agent))]) + } + expect(refresh).toHaveBeenCalledTimes(4) + rmSync(rig, { recursive: true, force: true }) + } + ) }) diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.ts b/src/main/runtime/structured-agent-model-catalog-discovery.ts index 1efd83c26358..2ebc814cfe3a 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.ts @@ -1,5 +1,6 @@ -// Each registered agent's catalog probe, built from the SAME resolvers its session launches use: -// a probe under another binary or env could list models the user's sessions cannot see. +// Each registered agent's catalog probe, built from the SAME resolvers its session launches use, +// rereading the environment on a missing program as a launch does: a probe under another binary +// or env could list models the user's sessions cannot see. import { createAcpModelCatalogProbe } from '../acp/acp-model-catalog-probe' import type { AcpLaunchSpec } from '../acp/acp-launch-specs' @@ -31,6 +32,7 @@ export function codexModelCatalogDiscovery({ ? { prepareHome: deps.prepareCodexCatalogProbeHome } : {}), resolveEnvironment: () => environment.resolveAgentEnvironment('codex'), + refreshEnvironmentAfterMiss: environment.refreshAfterMissingProgram, ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) }) } @@ -46,6 +48,7 @@ export function claudeModelCatalogDiscovery({ listingNamesConfiguredModel: false, probe: createClaudeModelCatalogProbe({ resolveInheritedEnv: environment.resolveBaseEnvironment, + refreshEnvironmentAfterMiss: environment.refreshAfterMissingProgram, resolveAuthPolicy: deps.resolveClaudeAuthPolicy, ...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}), ...claudeLaunchEnvOverlay(deps) @@ -66,6 +69,7 @@ export function acpModelCatalogDiscovery( listingNamesConfiguredModel: discovery.listingNamesConfiguredModel, probe: createAcpModelCatalogProbe(spec, { resolveEnvironment: environment.resolveBaseEnvironment, + refreshEnvironmentAfterMiss: environment.refreshAfterMissingProgram, ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), ...(deps.resolveAgentCommandSettings ? { resolveCommandSettings: deps.resolveAgentCommandSettings } diff --git a/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts index 196923df35e9..d48b342d8827 100644 --- a/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts @@ -35,7 +35,8 @@ function context(): StructuredAgentModelCatalogContext { deps: { stateDirectory: '/state' } as StructuredAgentModelCatalogContext['deps'], environment: { resolveBaseEnvironment: unused, - resolveAgentEnvironment: unused + resolveAgentEnvironment: unused, + refreshAfterMissingProgram: unused } } } diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 0c6a90dcaba1..bd31c4188b1f 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -99,10 +99,10 @@ export type StructuredAgentAccountHomeServices = { } /** What a catalog probe is built from: the same runtime resolvers its sessions launch through. */ -export type StructuredAgentModelCatalogContext = Pick & { - /** A listing never retries a missing program; only a launch does. */ - environment: Omit -} +export type StructuredAgentModelCatalogContext = Pick< + StructuredAgentAdapterContext, + 'deps' | 'environment' +> export type StructuredAgentLaunchCheck = { /** The binary and env a launch from `env` runs; throws the launch's own pre-spawn refusal. */ From 3aea6031bad8f4f034c1c6439800525d1ec2a74f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:44:52 -0700 Subject: [PATCH 17/21] Pass optional launch lookups through directly instead of conditional spreads --- src/main/acp/acp-structured-launch-resolution.ts | 4 +--- src/main/claude/claude-structured-launch-resolution.ts | 4 +--- src/main/codex/codex-structured-launch-resolution.ts | 4 +--- src/main/pi/rpc-launch-resolution.ts | 6 ++---- src/main/pi/rpc-model-catalog-probe.ts | 6 ++---- 5 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index a581caf179d9..3aee6074a241 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -194,9 +194,7 @@ export function acpStructuredLaunchPart( resolveEnvironment: basis.baseEnvironment, resolveLaunchEnv: () => basis.launchEnv(), resolveCommandSettings: basis.commandSettings, - ...(basis.refreshAfterMissingProgram - ? { refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram } - : {}), + refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram, ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), ...(deps.homePath ? { homePath: deps.homePath } : {}), ...(deps.inheritedEnv ? { inheritedEnv: deps.inheritedEnv } : {}) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 5099c6e0adf7..e8100b576d09 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -180,9 +180,7 @@ export function claudeStructuredLaunchPart( ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), resolveEnv: () => basis.launchEnv(), resolveInheritedEnv: basis.baseEnvironment, - ...(basis.refreshAfterMissingProgram - ? { refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram } - : {}), + refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram, resolveAuthPolicy: deps.resolveAuthPolicy } const sources = await resolveClaudeChildEnvSources(envDeps) diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 473614d5b6c5..21c25b0866bb 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -83,9 +83,7 @@ export function codexStructuredLaunchPart( const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome) const { command } = await resolveCodexStructuredInvocation({ ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), - ...(basis.refreshAfterMissingProgram - ? { refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram } - : {}), + refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram, resolveEnvironment: basis.environment }) const environment = await basis.environment() diff --git a/src/main/pi/rpc-launch-resolution.ts b/src/main/pi/rpc-launch-resolution.ts index ccdf4e139740..ef44d3ca318d 100644 --- a/src/main/pi/rpc-launch-resolution.ts +++ b/src/main/pi/rpc-launch-resolution.ts @@ -136,10 +136,8 @@ export function piRpcLaunchPart( accountDir: accountHome.path, resolveEnvironment: basis.environment, commandSettings: basis.commandSettings(), - ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), - ...(basis.refreshAfterMissingProgram - ? { refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram } - : {}) + resolveCommand: deps.resolveCommand, + refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram }) const previous = agentSessionProviderHandleChainHead(record.providerHandleChain) let sessionFile: string | undefined diff --git a/src/main/pi/rpc-model-catalog-probe.ts b/src/main/pi/rpc-model-catalog-probe.ts index 92edca4cbe75..4b61327cd244 100644 --- a/src/main/pi/rpc-model-catalog-probe.ts +++ b/src/main/pi/rpc-model-catalog-probe.ts @@ -45,10 +45,8 @@ export function createPiModelCatalogProbe(deps: PiModelCatalogProbeDeps): AgentM accountDir: requireLegacyAgentSessionAccountHome(accountHome).path, resolveEnvironment: deps.resolveEnvironment, commandSettings: deps.resolveCommandSettings?.(), - ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), - ...(deps.refreshEnvironmentAfterMiss - ? { refreshEnvironmentAfterMiss: deps.refreshEnvironmentAfterMiss } - : {}) + resolveCommand: deps.resolveCommand, + refreshEnvironmentAfterMiss: deps.refreshEnvironmentAfterMiss }) const cwd = deps.homePath ?? homedir() if (!(await piRpcVersionSupported({ command, cwd, env }, deps.probeVersion))) { From 2fb1ffd06c2016fae21b7c386fdef8c56da7e00f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:21:55 -0700 Subject: [PATCH 18/21] Read the host shell env through resolveLoginShellEnvironment; drop the extra wait cap CI on the merge with main failed main's new codex-workspace-relative-path test (#26684), which mocks the login-shell module with only resolveLoginShellEnvironment: the wrapper called captureLoginShellEnvironment and imported the spawn-timeout constant. The wrapper only needs the env, so it reads through resolveLoginShellEnvironment again, as on main. The miss wait no longer races its own 5 s timer: the shell run it waits for is already killed at the spawn timeout, so the second cap was a guard over the same bound. The constant goes back to private, as on main. --- ...-agent-create-support-opencode-rig.test.ts | 4 +-- ...red-agent-launch-support-probe-omp.test.ts | 4 +-- ...uctured-agent-launch-support-probe.test.ts | 4 +-- .../structured-agent-launch-support.test.ts | 4 +-- .../structured-agent-shell-environment.ts | 36 +++++-------------- src/main/startup/login-shell-environment.ts | 5 ++- 6 files changed, 16 insertions(+), 41 deletions(-) diff --git a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts index f08fb2aff886..97f258120726 100644 --- a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts +++ b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts @@ -15,9 +15,7 @@ const { loginShell } = vi.hoisted(() => { return { loginShell } }) vi.mock('../startup/login-shell-environment', () => ({ - LOGIN_SHELL_SPAWN_TIMEOUT_MS: 5000, - resolveLoginShellEnvironment: async () => loginShell.env, - captureLoginShellEnvironment: async () => ({ status: 'captured', env: loginShell.env }) + resolveLoginShellEnvironment: async () => loginShell.env })) let root: string diff --git a/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts b/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts index bf2e09ce2524..c759cfdeeb1e 100644 --- a/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts +++ b/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts @@ -13,9 +13,7 @@ const { loginShell } = vi.hoisted(() => { return { loginShell } }) vi.mock('../startup/login-shell-environment', () => ({ - LOGIN_SHELL_SPAWN_TIMEOUT_MS: 5000, - resolveLoginShellEnvironment: async () => loginShell.env, - captureLoginShellEnvironment: async () => ({ status: 'captured', env: loginShell.env }) + resolveLoginShellEnvironment: async () => loginShell.env })) type Settings = Pick< diff --git a/src/main/runtime/structured-agent-launch-support-probe.test.ts b/src/main/runtime/structured-agent-launch-support-probe.test.ts index 9ad594f924cc..02640b3bcba6 100644 --- a/src/main/runtime/structured-agent-launch-support-probe.test.ts +++ b/src/main/runtime/structured-agent-launch-support-probe.test.ts @@ -13,9 +13,7 @@ const { loginShell } = vi.hoisted(() => { return { loginShell } }) vi.mock('../startup/login-shell-environment', () => ({ - LOGIN_SHELL_SPAWN_TIMEOUT_MS: 5000, - resolveLoginShellEnvironment: async () => loginShell.env, - captureLoginShellEnvironment: async () => ({ status: 'captured', env: loginShell.env }) + resolveLoginShellEnvironment: async () => loginShell.env })) type Settings = Pick< diff --git a/src/main/runtime/structured-agent-launch-support.test.ts b/src/main/runtime/structured-agent-launch-support.test.ts index b0937b123ecc..cc0fd4d7d2d0 100644 --- a/src/main/runtime/structured-agent-launch-support.test.ts +++ b/src/main/runtime/structured-agent-launch-support.test.ts @@ -32,8 +32,8 @@ vi.mock('../startup/login-shell-environment', async (importOriginal) => { const real = await importOriginal() return { ...real, - captureLoginShellEnvironment: (options: LoginShellOptions = {}) => - real.captureLoginShellEnvironment({ + resolveLoginShellEnvironment: (options: LoginShellOptions = {}) => + real.resolveLoginShellEnvironment({ ...options, shellOverride: '/bin/test-login-shell', spawner: loginShell.spawn diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index b71e50784165..9d5bdcd3ece0 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -1,7 +1,4 @@ -import { - captureLoginShellEnvironment, - LOGIN_SHELL_SPAWN_TIMEOUT_MS -} from '../startup/login-shell-environment' +import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' import { mergePersistedWindowsPathAsync, readPersistedWindowsPathSegmentsAsync @@ -81,21 +78,6 @@ export function structuredAgentBaseEnvironment(input: { ) } -/** Settles when `pending` does, or after one profile-loading shell's timeout, whichever is first. */ -async function withinShellTimeout(pending: Promise): Promise { - let timer: ReturnType | undefined - await Promise.race([ - pending.then( - () => {}, - () => {} - ), - new Promise((resolve) => { - timer = setTimeout(resolve, LOGIN_SHELL_SPAWN_TIMEOUT_MS) - }) - ]) - clearTimeout(timer) -} - // Why: long enough that a burst of starts shares one capture, short enough that a CLI // installed on the host reaches new chats without a restart. const SHELL_ENVIRONMENT_TTL_MS = 10_000 @@ -107,7 +89,7 @@ const SHELL_ENVIRONMENT_TTL_MS = 10_000 */ async function hostShellEnvironment(): Promise { const windows = process.platform === 'win32' - const { env } = await captureLoginShellEnvironment({ + const env = await resolveLoginShellEnvironment({ refreshAfterMs: SHELL_ENVIRONMENT_TTL_MS, keepCaptured: windows }) @@ -119,15 +101,15 @@ async function hostShellEnvironment(): Promise { return withSavedPath } -/** For a lookup that started at `askedAt` and found no program: waits, at most one shell's - * timeout, for a shell run that started no earlier (on Windows, a saved PATH reread), so an - * agent installed before the lookup counts. */ -export function refreshHostAgentBaseEnvironmentAfterMiss(askedAt: number): Promise { - return withinShellTimeout( +/** For a lookup that started at `askedAt` and found no program: waits for a shell run that started + * no earlier (on Windows, a saved PATH reread), so an agent installed before the lookup counts. + * The run's own shell timeout bounds the wait. */ +export async function refreshHostAgentBaseEnvironmentAfterMiss(askedAt: number): Promise { + await ( process.platform === 'win32' ? readPersistedWindowsPathSegmentsAsync({ forceRefresh: true }) - : captureLoginShellEnvironment({ startedAfter: askedAt }) - ) + : resolveLoginShellEnvironment({ startedAfter: askedAt }) + ).catch(() => {}) } /** The base env every local agent launch on this host starts from, read from current settings. */ diff --git a/src/main/startup/login-shell-environment.ts b/src/main/startup/login-shell-environment.ts index 1378e519be09..7a301a4f3296 100644 --- a/src/main/startup/login-shell-environment.ts +++ b/src/main/startup/login-shell-environment.ts @@ -9,8 +9,7 @@ import { const START_MARKER = '__ORCA_LOGIN_SHELL_ENV_START__' const END_MARKER = '__ORCA_LOGIN_SHELL_ENV_END__' -/** How long one profile-loading shell may run before Orca stops waiting for it. */ -export const LOGIN_SHELL_SPAWN_TIMEOUT_MS = 5000 +const SPAWN_TIMEOUT_MS = 5000 /** Whether a profile-loading shell produced the env, or Orca fell back to its own. */ export type LoginShellEnvironmentCapture = { @@ -139,7 +138,7 @@ function spawnShellAndReadEnvironment( // Best-effort timeout cleanup. } finish(null) - }, LOGIN_SHELL_SPAWN_TIMEOUT_MS) + }, SPAWN_TIMEOUT_MS) child.stdin.on('error', () => {}) child.stdout.on('error', () => finish(null)) child.stderr.on('error', () => {}) From bc810866019e70be26afeff60f4e58434f877751 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:50:57 -0700 Subject: [PATCH 19/21] Test that a hung login shell holds a launch miss for at most one shell timeout The bound lives in the one owner: the shell run is killed at the spawn timeout (5 s) and settles to a fallback, which never replaces a captured snapshot. A mutation raising that timeout fails this test. --- ...structured-agent-shell-environment.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/main/runtime/structured-agent-shell-environment.test.ts b/src/main/runtime/structured-agent-shell-environment.test.ts index b2d6044acfcf..9319fc09af8e 100644 --- a/src/main/runtime/structured-agent-shell-environment.test.ts +++ b/src/main/runtime/structured-agent-shell-environment.test.ts @@ -441,6 +441,54 @@ describe('createStructuredAgentEnvironmentResolvers', () => { } ) + it.skipIf(process.platform === 'win32')( + 'holds a miss for at most one shell timeout when the login shell hangs, keeping the snapshot', + async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-structured-shell-hang-')) + const [counter, shell] = ['count', 'shell.sh'].map((name) => join(dir, name)) + // Run 1 answers; every later run hangs, as a profile stuck on a prompt does. + await writeFile( + shell, + [ + '#!/bin/sh', + `echo run >> '${counter}'; n=$(wc -l < '${counter}')`, + 'if [ $n -gt 1 ]; then exec sleep 30; fi', + "printf '\\000__ORCA_LOGIN_SHELL_ENV_START__\\000PATH=/profile/A\\000\\000__ORCA_LOGIN_SHELL_ENV_END__\\000'", + '' + ].join('\n') + ) + await chmod(shell, 0o755) + const originalShell = process.env.SHELL + process.env.SHELL = shell + try { + const resolvers = createStructuredAgentEnvironmentResolvers({ + resolveShellEnvironmentPolicy: () => INHERIT_ALL + }) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/profile/A') + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + let released = false + void resolvers.refreshAfterMissingProgram(Date.now()).then(() => { + released = true + }) + await vi.advanceTimersByTimeAsync(4_999) + expect(released).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(released).toBe(true) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/profile/A') + } finally { + vi.useRealTimers() + if (originalShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = originalShell + } + await rm(dir, { recursive: true, force: true }) + } + }, + // Real shell spawns; a loaded runner can take seconds for the first one. + 20_000 + ) + it('rereads settings on every acquisition while the shell env is read once', async () => { let overlay = 'A' const resolveEnvironment = vi.fn(async () => ({ PATH: '/shell/A' })) From 81b1f10805f991ed66165766b5d6b06165b58ce4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:09:31 -0700 Subject: [PATCH 20/21] A program found through a relative PATH entry is not missing Main's #26684 runs Codex through a workspace-relative PATH entry (`bin`, `node_modules/.bin`). The lookup searches such an entry against Orca's own directory and returns the bare name; the spawn then finds the program in the chat's directory. Counting that bare name as missing made every such launch wait for a login-shell reread. The retry now fires only when the lookup truly found nothing: a bare name back from a PATH whose entries are all absolute. Each agent's one lookup tells it which PATH it searched. --- .../acp/acp-structured-launch-resolution.ts | 3 ++- .../claude/claude-structured-child-env.ts | 6 ++++- .../codex-structured-launch-resolution.ts | 3 ++- .../structured-agent-command-resolution.ts | 25 ++++++++++++----- src/main/pi/rpc-launch-resolution.ts | 22 ++++++++------- .../structured-agent-install-refresh.test.ts | 27 ++++++++++++++++++- .../structured-agent-launch-support.ts | 3 ++- ...structured-agent-shell-environment.test.ts | 12 ++++++--- 8 files changed, 77 insertions(+), 24 deletions(-) diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index 3aee6074a241..be2a98e72f58 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -156,7 +156,8 @@ export function resolveAcpLaunchInvocation( ): Promise<{ command: string; env: Record; envToDelete: string[] }> { return resolveAgainAfterRefreshOnMissingProgram( () => readAcpLaunchInvocation(spec, accountHome, deps), - deps.refreshEnvironmentAfterMiss + deps.refreshEnvironmentAfterMiss, + ({ env }) => env.PATH ?? env.Path ) } diff --git a/src/main/claude/claude-structured-child-env.ts b/src/main/claude/claude-structured-child-env.ts index 0878b20e24e8..0bafa50a8ae5 100644 --- a/src/main/claude/claude-structured-child-env.ts +++ b/src/main/claude/claude-structured-child-env.ts @@ -39,7 +39,11 @@ export type ClaudeChildEnvSources = { export function resolveClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise { return resolveAgainAfterRefreshOnMissingProgram( () => readClaudeChildEnvSources(deps), - deps.refreshEnvironmentAfterMiss + deps.refreshEnvironmentAfterMiss, + ({ inheritedEnv, overlay }) => { + const env = { ...inheritedEnv, ...overlay } + return env.PATH ?? env.Path + } ) } diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 2b82aabf7fbc..4b05a520078f 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -54,7 +54,8 @@ export function resolveCodexStructuredInvocation( ): Promise { return resolveAgainAfterRefreshOnMissingProgram( () => readCodexStructuredInvocation(deps), - deps.refreshEnvironmentAfterMiss + deps.refreshEnvironmentAfterMiss, + ({ environment }) => environment?.PATH ?? environment?.Path ) } diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 9414db61ace1..9b680b07cb0a 100644 --- a/src/main/native-chat/structured-agent-command-resolution.ts +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -1,4 +1,4 @@ -import { isAbsolute } from 'node:path' +import { delimiter, isAbsolute } from 'node:path' import type { GlobalSettings } from '../../shared/global-settings-types' import type { TuiAgent } from '../../shared/tui-agent' import { @@ -63,12 +63,25 @@ export function resolveStructuredAgentCommand( return command } -/** Resolves a launch's program from the shell snapshot. When none is found there (a bare name back, - * or a Command setting naming no program), waits for a reread that started after this lookup did - * and resolves once more, so an agent installed before it starts. A found program never waits. */ +/** Whether a lookup found nothing: a bare name back from a search of absolute directories only. A + * relative entry (a workspace `bin`) is searched again by the spawn in its own directory. */ +function foundNothing(command: string, searchedPath: string | undefined): boolean { + if (isAbsolute(command) || /[\\/]/.test(command)) { + return false + } + return (searchedPath ?? '') + .split(delimiter) + .every((entry) => isAbsolute(entry.trim()) || !entry.trim()) +} + +/** Resolves a launch's program from the shell snapshot. When nothing is found there (a bare name + * from `searchedPath`, or a Command setting naming no program), waits for a reread that started + * after this lookup did and resolves once more, so an agent installed before it starts. A found + * program never waits. */ export async function resolveAgainAfterRefreshOnMissingProgram( resolve: () => Promise, - refreshAfterMiss: ((askedAt: number) => Promise) | undefined + refreshAfterMiss: ((askedAt: number) => Promise) | undefined, + searchedPath: (resolved: T) => string | undefined ): Promise { if (!refreshAfterMiss) { return resolve() @@ -76,7 +89,7 @@ export async function resolveAgainAfterRefreshOnMissingProgram Promise }): Promise<{ command: string; env: Record }> { - return resolveAgainAfterRefreshOnMissingProgram(async () => { - const env: Record = {} - for (const [key, value] of Object.entries(await deps.resolveEnvironment())) { - if (value !== undefined) { - env[key] = value + return resolveAgainAfterRefreshOnMissingProgram( + async () => { + const env: Record = {} + for (const [key, value] of Object.entries(await deps.resolveEnvironment())) { + if (value !== undefined) { + env[key] = value + } } - } - env.PI_CODING_AGENT_DIR = deps.accountDir - return { env, command: resolvePiRpcCommand(env, deps.commandSettings, deps.resolveCommand) } - }, deps.refreshEnvironmentAfterMiss) + env.PI_CODING_AGENT_DIR = deps.accountDir + return { env, command: resolvePiRpcCommand(env, deps.commandSettings, deps.resolveCommand) } + }, + deps.refreshEnvironmentAfterMiss, + ({ env }) => env.PATH ?? env.Path + ) } /** Pi's part of a launch: its account directory, session file or fork, binary and release check. */ diff --git a/src/main/runtime/structured-agent-install-refresh.test.ts b/src/main/runtime/structured-agent-install-refresh.test.ts index bf4e4a6b227c..76b25aa1cf97 100644 --- a/src/main/runtime/structured-agent-install-refresh.test.ts +++ b/src/main/runtime/structured-agent-install-refresh.test.ts @@ -1,6 +1,6 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { delimiter, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' @@ -121,4 +121,29 @@ describe('an agent installed after the first shell capture', () => { expect(launch.command).toBe(host.program) expect(refresh).not.toHaveBeenCalled() }) + + it('never rereads for a program found through a relative PATH entry, and rereads once for none', async () => { + // A workspace `bin` on PATH: the spawn finds the program in the chat's directory, so the + // lookup's bare name is not a miss. + const name = `codex-relative-${process.pid}` + const launchFrom = async (PATH: string) => { + const refresh = vi.fn(async (_askedAt: number) => {}) + const launch = await createCodexStructuredLaunchResolver({ + store: store({ + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' } + }), + resolveEnvironment: async () => ({ PATH, HOME: '/home/user' }), + refreshAfterMissingProgram: refresh, + resolveCommand: (options) => resolveCliCommand(name, options), + resolveLaunchArgs: () => [] + })({ identity: { ...identity, agent: 'codex' } }) + return { command: launch.command, rereads: refresh.mock.calls.length } + } + expect(await launchFrom(['/usr/bin', 'node_modules/.bin'].join(delimiter))).toEqual({ + command: name, + rereads: 0 + }) + expect(await launchFrom('/usr/bin')).toEqual({ command: name, rereads: 1 }) + }) }) diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index 975e339220a2..3466523f9906 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -54,7 +54,8 @@ export async function structuredAgentSupportsLaunch( }, settings ), - refreshHostAgentBaseEnvironmentAfterMiss + refreshHostAgentBaseEnvironmentAfterMiss, + ({ env }) => env.PATH ?? env.Path ) } catch (error) { // An unrunnable Command setting is the launch's refusal to state, not a terminal. diff --git a/src/main/runtime/structured-agent-shell-environment.test.ts b/src/main/runtime/structured-agent-shell-environment.test.ts index 9319fc09af8e..bf2b202819af 100644 --- a/src/main/runtime/structured-agent-shell-environment.test.ts +++ b/src/main/runtime/structured-agent-shell-environment.test.ts @@ -422,10 +422,14 @@ describe('createStructuredAgentEnvironmentResolvers', () => { ) await writeFile(pathFile, '/installed') vi.advanceTimersByTime(1) - const launch = resolveAgainAfterRefreshOnMissingProgram(async () => { - const { PATH } = await resolvers.resolveBaseEnvironment() - return { command: PATH === '/installed' ? '/installed/agent' : 'agent' } - }, resolvers.refreshAfterMissingProgram) + const launch = resolveAgainAfterRefreshOnMissingProgram( + async () => { + const { PATH } = await resolvers.resolveBaseEnvironment() + return { command: PATH === '/installed' ? '/installed/agent' : 'agent', PATH } + }, + resolvers.refreshAfterMissingProgram, + ({ PATH }) => PATH + ) await new Promise((resolve) => setTimeout(resolve, 300)) await rm(hold, { force: true }) expect((await launch).command).toBe('/installed/agent') From 88aadd422eebaf2147766889d1853f36fa025a8c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:02:01 -0700 Subject: [PATCH 21/21] Count a relative PATH entry as found only when it holds the program; Windows miss waits for a saved PATH read started after it looked A bare name with a relative PATH entry is checked from the launch's working directory, so an agent in neither entry still gets its one reread. On Windows the miss no longer joins a registry read that began before the lookup. --- src/main/acp/acp-model-catalog-probe.ts | 5 ++- .../acp/acp-structured-launch-resolution.ts | 5 ++- .../claude/claude-structured-child-env.ts | 8 ++-- .../claude-structured-launch-resolution.ts | 1 + .../codex-structured-launch-resolution.ts | 5 ++- .../structured-agent-command-resolution.ts | 45 ++++++++++++++----- src/main/pi/rpc-launch-resolution.ts | 7 ++- src/main/pi/rpc-model-catalog-probe.ts | 5 ++- src/main/pty/windows-environment-path.ts | 10 ++++- .../structured-agent-install-refresh.test.ts | 18 ++++++-- .../structured-agent-launch-support.ts | 10 +++-- ...structured-agent-shell-environment.test.ts | 37 ++++++++++++++- .../structured-agent-shell-environment.ts | 2 +- 13 files changed, 125 insertions(+), 33 deletions(-) diff --git a/src/main/acp/acp-model-catalog-probe.ts b/src/main/acp/acp-model-catalog-probe.ts index 36d1d3f89177..e2dae8600188 100644 --- a/src/main/acp/acp-model-catalog-probe.ts +++ b/src/main/acp/acp-model-catalog-probe.ts @@ -47,9 +47,12 @@ export function createAcpModelCatalogProbe( if (discovery.kind === 'unavailable') { throw new Error(discovery.reason) } - const { command, env, envToDelete } = await resolveAcpLaunchInvocation(spec, accountHome, deps) // A listing reads no workspace: the catalog is the account's, whichever chat asks. const cwd = deps.homePath ?? homedir() + const { command, env, envToDelete } = await resolveAcpLaunchInvocation(spec, accountHome, { + ...deps, + resolveWorkingDirectory: () => cwd + }) if ( !(await acpLaunchVersionSupported( spec, diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index be2a98e72f58..4160afcc03d6 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -84,6 +84,8 @@ export type AcpLaunchInvocationDeps = { resolveCommandSettings?: () => StructuredAgentCommandSettings /** Rereads that env when the agent's program is not found in it; absent never retries. */ refreshEnvironmentAfterMiss?: (askedAt: number) => Promise + /** Where the program starts, for a PATH entry relative to it; Orca's own when absent. */ + resolveWorkingDirectory?: () => Promise | string } & Pick /** The binary a launch with `env` spawns: the user's Command setting when set, else the stock @@ -157,7 +159,7 @@ export function resolveAcpLaunchInvocation( return resolveAgainAfterRefreshOnMissingProgram( () => readAcpLaunchInvocation(spec, accountHome, deps), deps.refreshEnvironmentAfterMiss, - ({ env }) => env.PATH ?? env.Path + { env: ({ env }) => env, cwd: deps.resolveWorkingDirectory } ) } @@ -196,6 +198,7 @@ export function acpStructuredLaunchPart( resolveLaunchEnv: () => basis.launchEnv(), resolveCommandSettings: basis.commandSettings, refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram, + resolveWorkingDirectory: basis.launchDirectory, ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), ...(deps.homePath ? { homePath: deps.homePath } : {}), ...(deps.inheritedEnv ? { inheritedEnv: deps.inheritedEnv } : {}) diff --git a/src/main/claude/claude-structured-child-env.ts b/src/main/claude/claude-structured-child-env.ts index 0bafa50a8ae5..19605b467fcd 100644 --- a/src/main/claude/claude-structured-child-env.ts +++ b/src/main/claude/claude-structured-child-env.ts @@ -21,6 +21,8 @@ export type ClaudeEnvDeps = { resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string /** Refreshes the inherited env after the command was not found in it; absent never retries. */ refreshEnvironmentAfterMiss?: (askedAt: number) => Promise + /** Where the program starts, for a PATH entry relative to it; Orca's own when absent. */ + resolveWorkingDirectory?: () => Promise | string resolveEnv?: () => | Promise | undefined> | Record @@ -40,9 +42,9 @@ export function resolveClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise readClaudeChildEnvSources(deps), deps.refreshEnvironmentAfterMiss, - ({ inheritedEnv, overlay }) => { - const env = { ...inheritedEnv, ...overlay } - return env.PATH ?? env.Path + { + env: ({ inheritedEnv, overlay }) => ({ ...inheritedEnv, ...overlay }), + cwd: deps.resolveWorkingDirectory } ) } diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index a29198caa584..7589f3abf4f3 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -184,6 +184,7 @@ export function claudeStructuredLaunchPart( resolveEnv: () => basis.launchEnv(), resolveInheritedEnv: basis.baseEnvironment, refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram, + resolveWorkingDirectory: () => cwd, resolveAuthPolicy: deps.resolveAuthPolicy } const sources = await resolveClaudeChildEnvSources(envDeps) diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 4b05a520078f..a28b985e356d 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -35,6 +35,8 @@ export type CodexStructuredInvocationDeps = Pick< resolveEnvironment?: () => Promise /** Rereads that environment when Codex is not found in it; absent never retries. */ refreshEnvironmentAfterMiss?: (askedAt: number) => Promise + /** Where the program starts, for a PATH entry relative to it; Orca's own when absent. */ + resolveWorkingDirectory?: () => Promise | string } export type CodexStructuredInvocation = { @@ -55,7 +57,7 @@ export function resolveCodexStructuredInvocation( return resolveAgainAfterRefreshOnMissingProgram( () => readCodexStructuredInvocation(deps), deps.refreshEnvironmentAfterMiss, - ({ environment }) => environment?.PATH ?? environment?.Path + { env: ({ environment }) => environment, cwd: deps.resolveWorkingDirectory } ) } @@ -82,6 +84,7 @@ export function codexStructuredLaunchPart( const { command } = await resolveCodexStructuredInvocation({ ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram, + resolveWorkingDirectory: basis.launchDirectory, resolveEnvironment: basis.environment }) const environment = await basis.environment() diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 9b680b07cb0a..fc42c7a7594a 100644 --- a/src/main/native-chat/structured-agent-command-resolution.ts +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -1,4 +1,4 @@ -import { delimiter, isAbsolute } from 'node:path' +import { delimiter, isAbsolute, resolve as resolvePath } from 'node:path' import type { GlobalSettings } from '../../shared/global-settings-types' import type { TuiAgent } from '../../shared/tui-agent' import { @@ -63,25 +63,46 @@ export function resolveStructuredAgentCommand( return command } -/** Whether a lookup found nothing: a bare name back from a search of absolute directories only. A - * relative entry (a workspace `bin`) is searched again by the spawn in its own directory. */ -function foundNothing(command: string, searchedPath: string | undefined): boolean { - if (isAbsolute(command) || /[\\/]/.test(command)) { +/** What a lookup searched: the env whose PATH it used, and the directory its program starts in. */ +export type ProgramSearch = { + env: (resolved: T) => NodeJS.ProcessEnv | undefined + /** Orca's own working directory when absent. */ + cwd?: () => Promise | string +} + +/** Whether a lookup found nothing. A bare name back may still be in a relative PATH entry (a + * workspace `bin`), which the spawn searches from its own directory and the lookup did not. */ +async function foundNothing( + resolved: T, + search: ProgramSearch +): Promise { + if (isAbsolute(resolved.command) || /[\\/]/.test(resolved.command)) { return false } - return (searchedPath ?? '') + const env = search.env(resolved) + const relative = (env?.PATH ?? env?.Path ?? '') .split(delimiter) - .every((entry) => isAbsolute(entry.trim()) || !entry.trim()) + .map((entry) => entry.trim()) + .filter((entry) => entry && !isAbsolute(entry)) + if (relative.length === 0) { + return true + } + const from = (await search.cwd?.()) ?? process.cwd() + const found = resolveCliCommand(resolved.command, { + pathEnv: relative.map((entry) => resolvePath(from, entry)).join(delimiter), + homePath: env?.HOME ?? env?.USERPROFILE + }) + return !isAbsolute(found) } -/** Resolves a launch's program from the shell snapshot. When nothing is found there (a bare name - * from `searchedPath`, or a Command setting naming no program), waits for a reread that started - * after this lookup did and resolves once more, so an agent installed before it starts. A found +/** Resolves a launch's program from the shell snapshot. When nothing is found there (see + * `foundNothing`, or a Command setting naming no program), waits for a reread that started after + * this lookup did and resolves once more, so an agent installed before it starts. A found * program never waits. */ export async function resolveAgainAfterRefreshOnMissingProgram( resolve: () => Promise, refreshAfterMiss: ((askedAt: number) => Promise) | undefined, - searchedPath: (resolved: T) => string | undefined + search: ProgramSearch ): Promise { if (!refreshAfterMiss) { return resolve() @@ -89,7 +110,7 @@ export async function resolveAgainAfterRefreshOnMissingProgram Promise + /** Where the program starts, for a PATH entry relative to it; Orca's own when absent. */ + resolveWorkingDirectory?: () => Promise | string }): Promise<{ command: string; env: Record }> { return resolveAgainAfterRefreshOnMissingProgram( async () => { @@ -121,7 +123,7 @@ export function resolvePiRpcInvocation(deps: { return { env, command: resolvePiRpcCommand(env, deps.commandSettings, deps.resolveCommand) } }, deps.refreshEnvironmentAfterMiss, - ({ env }) => env.PATH ?? env.Path + { env: ({ env }) => env, cwd: deps.resolveWorkingDirectory } ) } @@ -141,7 +143,8 @@ export function piRpcLaunchPart( resolveEnvironment: basis.environment, commandSettings: basis.commandSettings(), resolveCommand: deps.resolveCommand, - refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram + refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram, + resolveWorkingDirectory: () => cwd }) const previous = agentSessionProviderHandleChainHead(record.providerHandleChain) let sessionFile: string | undefined diff --git a/src/main/pi/rpc-model-catalog-probe.ts b/src/main/pi/rpc-model-catalog-probe.ts index 4b61327cd244..7dbdf8462d68 100644 --- a/src/main/pi/rpc-model-catalog-probe.ts +++ b/src/main/pi/rpc-model-catalog-probe.ts @@ -41,14 +41,15 @@ export function piModelCatalogFromListing(stdout: string): AgentModelCatalogSucc /** Lists Pi's models with `pi --list-models`, under the binary and environment its chats run. */ export function createPiModelCatalogProbe(deps: PiModelCatalogProbeDeps): AgentModelCatalogProbe { return async (accountHome, options): Promise => { + const cwd = deps.homePath ?? homedir() const { env, command } = await resolvePiRpcInvocation({ accountDir: requireLegacyAgentSessionAccountHome(accountHome).path, resolveEnvironment: deps.resolveEnvironment, commandSettings: deps.resolveCommandSettings?.(), resolveCommand: deps.resolveCommand, - refreshEnvironmentAfterMiss: deps.refreshEnvironmentAfterMiss + refreshEnvironmentAfterMiss: deps.refreshEnvironmentAfterMiss, + resolveWorkingDirectory: () => cwd }) - const cwd = deps.homePath ?? homedir() if (!(await piRpcVersionSupported({ command, cwd, env }, deps.probeVersion))) { throw new Error(`pi at ${command} does not run structured chats`) } diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 93e17bb5c6a9..7f6741ce2dc9 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -19,6 +19,8 @@ type ReadWindowsPathOptions = { execFileSync?: ExecFileSync env?: NodeJS.ProcessEnv forceRefresh?: boolean + /** Joins a read in progress only if it started at or after this time; else starts one. */ + startedAfter?: number platform?: NodeJS.Platform } @@ -40,6 +42,7 @@ let persistedWindowsPathCache: } | undefined let pendingPersistedWindowsPathRefresh: Promise | undefined +let pendingPersistedWindowsPathRefreshStartedAt = 0 let persistedWindowsPathCacheGeneration = 0 let persistedWindowsPathReadSequence = 0 let persistedWindowsPathCommittedSequence = 0 @@ -245,7 +248,11 @@ export async function readPersistedWindowsPathSegmentsAsync( ) { return [...persistedWindowsPathCache.segments] } - if (useProductionCache && pendingPersistedWindowsPathRefresh) { + if ( + useProductionCache && + pendingPersistedWindowsPathRefresh && + pendingPersistedWindowsPathRefreshStartedAt >= (options.startedAfter ?? -Infinity) + ) { return [...(await pendingPersistedWindowsPathRefresh)] } @@ -277,6 +284,7 @@ export async function readPersistedWindowsPathSegmentsAsync( return refresh } pendingPersistedWindowsPathRefresh = refresh + pendingPersistedWindowsPathRefreshStartedAt = now try { return [...(await refresh)] } finally { diff --git a/src/main/runtime/structured-agent-install-refresh.test.ts b/src/main/runtime/structured-agent-install-refresh.test.ts index 76b25aa1cf97..721fd2aac25c 100644 --- a/src/main/runtime/structured-agent-install-refresh.test.ts +++ b/src/main/runtime/structured-agent-install-refresh.test.ts @@ -122,10 +122,15 @@ describe('an agent installed after the first shell capture', () => { expect(refresh).not.toHaveBeenCalled() }) - it('never rereads for a program found through a relative PATH entry, and rereads once for none', async () => { - // A workspace `bin` on PATH: the spawn finds the program in the chat's directory, so the - // lookup's bare name is not a miss. + it('rereads only when no PATH entry has the program, a relative one searched from the chat', async () => { + // A workspace `bin` on PATH: the spawn finds the program in the chat's directory, where the + // lookup did not search, so its bare name is not a miss; an entry without it still is. const name = `codex-relative-${process.pid}` + const workspace = mkdtempSync(join(tmpdir(), 'orca-relative-path-')) + roots.push(workspace) + mkdirSync(join(workspace, 'node_modules', '.bin'), { recursive: true }) + writeFileSync(join(workspace, 'node_modules', '.bin', name), '#!/bin/sh\nexit 0\n') + chmodSync(join(workspace, 'node_modules', '.bin', name), 0o755) const launchFrom = async (PATH: string) => { const refresh = vi.fn(async (_askedAt: number) => {}) const launch = await createCodexStructuredLaunchResolver({ @@ -133,6 +138,7 @@ describe('an agent installed after the first shell capture', () => { provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' } }), + resolveWorkspacePath: async () => workspace, resolveEnvironment: async () => ({ PATH, HOME: '/home/user' }), refreshAfterMissingProgram: refresh, resolveCommand: (options) => resolveCliCommand(name, options), @@ -140,10 +146,14 @@ describe('an agent installed after the first shell capture', () => { })({ identity: { ...identity, agent: 'codex' } }) return { command: launch.command, rereads: refresh.mock.calls.length } } - expect(await launchFrom(['/usr/bin', 'node_modules/.bin'].join(delimiter))).toEqual({ + expect(await launchFrom(['/usr/bin', join('node_modules', '.bin')].join(delimiter))).toEqual({ command: name, rereads: 0 }) + expect(await launchFrom(['/usr/bin', 'bin'].join(delimiter))).toEqual({ + command: name, + rereads: 1 + }) expect(await launchFrom('/usr/bin')).toEqual({ command: name, rereads: 1 }) }) }) diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index 3466523f9906..f49a1e07ff1e 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -41,6 +41,11 @@ export async function structuredAgentSupportsLaunch( return true } const settings = runtime.requireStore().getSettings() + let worktreePath: Promise | undefined + const cwd = () => + (worktreePath ??= runtime + .resolveRuntimeFileTarget(worktreeSelector) + .then((target) => target.worktree.path)) let launch: { command: string; env: Record } try { // Resolved as a launch resolves, sharing its refresh, so an agent installed since the shell @@ -55,7 +60,7 @@ export async function structuredAgentSupportsLaunch( settings ), refreshHostAgentBaseEnvironmentAfterMiss, - ({ env }) => env.PATH ?? env.Path + { env: ({ env }) => env, cwd } ) } catch (error) { // An unrunnable Command setting is the launch's refusal to state, not a terminal. @@ -64,8 +69,7 @@ export async function structuredAgentSupportsLaunch( } throw error } - const cwd = (await runtime.resolveRuntimeFileTarget(worktreeSelector)).worktree.path - return check.supportsVersion({ ...launch, cwd }) + return check.supportsVersion({ ...launch, cwd: await cwd() }) } /** `agentSession.createSupport` on this host: every refusal it can know before spawning. The diff --git a/src/main/runtime/structured-agent-shell-environment.test.ts b/src/main/runtime/structured-agent-shell-environment.test.ts index bf2b202819af..99afd4c31515 100644 --- a/src/main/runtime/structured-agent-shell-environment.test.ts +++ b/src/main/runtime/structured-agent-shell-environment.test.ts @@ -5,7 +5,10 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import * as loginShell from '../startup/login-shell-environment' import type * as runProcessModule from '@orca/process-host' -import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' +import { + __resetPersistedWindowsPathCacheForTests, + readPersistedWindowsPathSegmentsAsync +} from '../pty/windows-environment-path' import { resolveAgainAfterRefreshOnMissingProgram } from '../native-chat/structured-agent-command-resolution' import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { @@ -276,6 +279,36 @@ describe('createStructuredAgentEnvironmentResolvers', () => { ) }) + it('answers a Windows miss with a saved PATH read started after it looked, not one before the install', async () => { + let machinePath = 'C:\\Windows' + await withSimulatedWindowsServer( + (key) => (key.startsWith('HKLM') ? machinePath : null), + async () => { + const resolvers = createStructuredAgentEnvironmentResolvers({ + resolveShellEnvironmentPolicy: () => INHERIT_ALL + }) + await resolvers.resolveBaseEnvironment() + // A read that queried the registry before the install is still going. + const answer = runProcessMock.getMockImplementation()! + const held = Promise.withResolvers() + runProcessMock.mockImplementation(async (spec: { args: string[] }) => { + const result = await answer(spec) + await held.promise + return result + }) + const beforeInstall = readPersistedWindowsPathSegmentsAsync({ forceRefresh: true }) + runProcessMock.mockImplementation(answer) + machinePath = 'C:\\Windows;C:\\NewTool' + vi.advanceTimersByTime(1) + const miss = resolvers.refreshAfterMissingProgram(Date.now()) + await Promise.race([miss, new Promise((resolve) => setTimeout(resolve, 500))]) + held.resolve() + await Promise.all([miss, beforeInstall]) + expect(pathSegments(await resolvers.resolveBaseEnvironment())).toContain('C:\\NewTool') + } + ) + }) + it('keeps the machine PATH when the user has no saved Path value and reg.exe reads it', async () => { // An Orca server slot ships no registry addon, so this is the path a Windows SSH host takes. await withSimulatedWindowsServer( @@ -428,7 +461,7 @@ describe('createStructuredAgentEnvironmentResolvers', () => { return { command: PATH === '/installed' ? '/installed/agent' : 'agent', PATH } }, resolvers.refreshAfterMissingProgram, - ({ PATH }) => PATH + { env: ({ PATH }) => ({ PATH }) } ) await new Promise((resolve) => setTimeout(resolve, 300)) await rm(hold, { force: true }) diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index 9d5bdcd3ece0..e3f545c994a9 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -107,7 +107,7 @@ async function hostShellEnvironment(): Promise { export async function refreshHostAgentBaseEnvironmentAfterMiss(askedAt: number): Promise { await ( process.platform === 'win32' - ? readPersistedWindowsPathSegmentsAsync({ forceRefresh: true }) + ? readPersistedWindowsPathSegmentsAsync({ forceRefresh: true, startedAfter: askedAt }) : resolveLoginShellEnvironment({ startedAfter: askedAt }) ).catch(() => {}) }