diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 7cf46cd24061..5dc3569f00bd 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -242,7 +242,8 @@ describe('Windows CLI launcher', () => { argv: process.argv.slice(2), profile: process.env.ORCA_USER_DATA_PATH, electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE ?? null, - nodeOptions: process.env.NODE_OPTIONS ?? null + nodeOptions: process.env.NODE_OPTIONS ?? null, + desktopMarker: process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER ?? null }))\n`, 'utf8' ) @@ -262,13 +263,15 @@ describe('Windows CLI launcher', () => { ELECTRON_RUN_AS_NODE: '1', NODE_OPTIONS: '--require C:\\missing\\preload.js', ORCA_USER_DATA_PATH: 'C:\\another\\owner', + ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER: '1', ORCA_TEST_LAUNCHER: launcherPath } const expected = (body) => ({ argv: ['orchestration', 'send', '--body', body, '--json'], profile, electronRunAsNode: null, - nodeOptions: null + nodeOptions: null, + desktopMarker: null }) // Windows PowerShell 5.1 itself drops embedded double quotes, so its body has none. const shellBody = 'line one & %PATH% ^caret\n\nline three' diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs index 0f54418bc8bb..b66d83f64058 100644 --- a/config/scripts/node-server-test-paths.mjs +++ b/config/scripts/node-server-test-paths.mjs @@ -40,6 +40,8 @@ export function nodeServerTestPaths({ '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-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/native/windows-cli-launcher/src/main.rs b/native/windows-cli-launcher/src/main.rs index 232f223b246f..5d941e7d229b 100644 --- a/native/windows-cli-launcher/src/main.rs +++ b/native/windows-cli-launcher/src/main.rs @@ -116,7 +116,14 @@ fn run_server_cli(config_path: &Path) -> ! { } } env::set_var("ORCA_USER_DATA_PATH", user_data_path); - for name in ["NODE_OPTIONS", "NODE_REPL_EXTERNAL_MODULE", "ELECTRON_RUN_AS_NODE"] { + for name in [ + "NODE_OPTIONS", + "NODE_REPL_EXTERNAL_MODULE", + "ELECTRON_RUN_AS_NODE", + // 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); } run_cli(&node_path, &cli_path) diff --git a/src/cli/index-local-command-routing-flags.test.ts b/src/cli/index-local-command-routing-flags.test.ts index bcd6ac6840ed..22cb6cc9c3e3 100644 --- a/src/cli/index-local-command-routing-flags.test.ts +++ b/src/cli/index-local-command-routing-flags.test.ts @@ -156,6 +156,39 @@ describe('runtime-selector flags on locally pinned CLI commands', () => { expect(runtimeClientConstructorMock).toHaveBeenCalledWith(undefined, 'm4air') }) + /** 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(() => {}) + 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("keeps --host runtime: routing under an agent's empty selectors", async () => { + agentSelectors() + pairRuntimeEnvironment(listEnvironmentsMock, 'gpu') + 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/cli/runtime/client-execution-host.test.ts b/src/cli/runtime/client-execution-host.test.ts new file mode 100644 index 000000000000..82c608c39115 --- /dev/null +++ b/src/cli/runtime/client-execution-host.test.ts @@ -0,0 +1,57 @@ +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 SELECTORS = ['ORCA_ENVIRONMENT', 'ORCA_PAIRING_CODE', 'ORCA_REMOTE_PAIRING'] as const +const pairingCode = encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'fixture-token', + 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 })) +}) + +/** 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) + 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("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) +}) + +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/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 2f820c0c4196..4160afcc03d6 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -16,6 +16,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' @@ -81,6 +82,10 @@ 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 + /** 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 @@ -144,8 +149,21 @@ 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, + { env: ({ env }) => env, cwd: deps.resolveWorkingDirectory } + ) +} + +async function readAcpLaunchInvocation( spec: AcpLaunchSpec, accountHome: AgentSessionAccountHome, deps: AcpLaunchInvocationDeps @@ -179,6 +197,8 @@ export function acpStructuredLaunchPart( resolveEnvironment: basis.baseEnvironment, 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.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 8db2a6b0903a..19605b467fcd 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' function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): Record { @@ -17,7 +18,11 @@ function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): Recor } export type ClaudeEnvDeps = { - resolveCommand?: () => string + 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 @@ -33,14 +38,27 @@ export type ClaudeChildEnvSources = { inheritedEnv: Record } -export async function resolveClaudeChildEnvSources( - deps: ClaudeEnvDeps -): Promise { - const command = (deps.resolveCommand ?? resolveClaudeCommand)() +export function resolveClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise { + return resolveAgainAfterRefreshOnMissingProgram( + () => readClaudeChildEnvSources(deps), + deps.refreshEnvironmentAfterMiss, + { + env: ({ inheritedEnv, overlay }) => ({ ...inheritedEnv, ...overlay }), + cwd: deps.resolveWorkingDirectory + } + ) +} + +async function readClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise { 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 581b8e1dbcef..7589f3abf4f3 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -105,7 +105,7 @@ export type ClaudeStructuredLaunch = { export type ClaudeStructuredLaunchPartDeps = { resolveLaunchArgs: () => Promise | string[] - resolveCommand?: () => string + resolveCommand?: ClaudeEnvDeps['resolveCommand'] /** Required, so every caller states which login a failed sign-in names rather than inherit a * guess. Build it with claudeStructuredAuthPolicyForSettings. */ resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy @@ -183,6 +183,8 @@ export function claudeStructuredLaunchPart( ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), 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-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index e92742c26cea..acb2bc7330d7 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -71,6 +71,9 @@ describe('the Codex child environment', () => { ? { 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) }) @@ -88,6 +91,9 @@ describe('the Codex child environment', () => { ? { 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) }) @@ -127,6 +133,11 @@ const ENV_REPORTING_APP_SERVER = String.raw` result: { sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null, cliCommand: process.env.ORCA_CLI_COMMAND ?? 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 } @@ -144,6 +155,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') + // 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 = sealed(sessionId) const connection = await openCodexAppServerConnection({ @@ -155,6 +170,7 @@ describe('the spawned Codex child', () => { await expect(connection.request('test/env')).resolves.toEqual({ sessionId, cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER), + 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 22c30ba8f9fb..a28b985e356d 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -9,6 +9,7 @@ import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import { resolveCodexCommand } from '../codex-cli/command' +import { resolveAgainAfterRefreshOnMissingProgram } from '../native-chat/structured-agent-command-resolution' import type { StructuredAgentLaunchPartResolver } from '../runtime/structured-agent-launch-composition' import type { CodexStructuredLaunch } from './codex-structured-session-adapter' import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy' @@ -32,6 +33,10 @@ 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 + /** Where the program starts, for a PATH entry relative to it; Orca's own when absent. */ + resolveWorkingDirectory?: () => Promise | string } export type CodexStructuredInvocation = { @@ -46,7 +51,17 @@ 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, + { env: ({ environment }) => environment, cwd: deps.resolveWorkingDirectory } + ) +} + +async function readCodexStructuredInvocation( deps: CodexStructuredInvocationDeps ): Promise { const environment = await deps.resolveEnvironment?.() @@ -68,6 +83,8 @@ export function codexStructuredLaunchPart( const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome) 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/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 8f36128f7141..3a25cdbff2f2 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -157,6 +157,9 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ? { 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/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 456d7cf97ac6..07bb40f52d6d 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 { delimiter, isAbsolute, resolve as resolvePath } 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' import { guestNativePathEnv } from '../ipc/wsl-guest-windows-drive-path' export type StructuredAgentCommandSettings = Partial< @@ -64,3 +68,62 @@ export function resolveStructuredAgentCommand( } return 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 + } + const env = search.env(resolved) + const relative = (env?.PATH ?? env?.Path ?? '') + .split(delimiter) + .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 (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, + search: ProgramSearch +): Promise { + if (!refreshAfterMiss) { + return resolve() + } + const askedAt = Date.now() + try { + const resolved = await resolve() + if (!(await foundNothing(resolved, search))) { + return resolved + } + } catch (error) { + if (!isAgentSessionPreSpawnError(error) || error.reason !== 'agentCommandNotRunnable') { + throw error + } + } + await refreshAfterMiss(askedAt) + return resolve() +} 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..7dcedb29bfc9 --- /dev/null +++ b/src/main/orcad/orcad-cli-launcher.integration.test.ts @@ -0,0 +1,157 @@ +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 '@orca/process-host' +import { sealedChildEnv } from '../runtime/structured-session-child-env.test-support' +import { resolveBundledOrcadRuntime } from './orcad-bundled-runtime' +import { ORCAD_NODE_RUNTIME_MARKER_FILENAME } from '../../shared/orcad-artifacts' +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)( + "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') + 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 = join( + userData, + 'cli', + 'bin', + process.platform === 'win32' ? 'orca.exe' : 'orca' + ) + expect(existsSync(launcher)).toBe(true) + installFakeAppEnvironment({ + getPath: () => userData, + isPackaged: () => true, + getCliLauncherPath: () => launcher + }) + const childEnv = sealedChildEnv('claude_test-session', { + 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!, + 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') + 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) + // 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) + await removeTestRoot(root) + } + }, + 150_000 +) diff --git a/src/main/orcad/orcad-cli-launcher.test.ts b/src/main/orcad/orcad-cli-launcher.test.ts index 6ae5efeb52d8..ec10ff454151 100644 --- a/src/main/orcad/orcad-cli-launcher.test.ts +++ b/src/main/orcad/orcad-cli-launcher.test.ts @@ -70,6 +70,35 @@ describe('orcad profile CLI launcher', () => { } ) + 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' + }) + } + ) + it.skipIf(process.platform === 'win32')( 'refreshes the launcher when the host switches server slots', async () => { 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..f76ece64fe62 --- /dev/null +++ b/src/main/orcad/orcad-cli-orchestration.integration.test.ts @@ -0,0 +1,185 @@ +import { randomUUID } from 'node:crypto' +import { + copyFileSync, + existsSync, + 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 '@orca/process-host' +import { + ORCAD_CLI_ENTRY_FILENAME, + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME +} from '../../shared/orcad-artifacts' +import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { buildUnixCliLauncher } from '../cli/cli-dev-launcher' +import { + ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME, + buildWindowsCliLauncherConfig +} from './orcad-cli-launcher' +import { resolveBundledOrcadRuntime } from './orcad-bundled-runtime' +import { skipForMissingInputs } from './orcad-node-slot-fixture' + +const slotDir = resolve('out/orcad') +const cliEntry = join(slotDir, ...ORCAD_CLI_ENTRY_FILENAME.split('/')) +const windowsLauncher = join(slotDir, ...ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME.split('/')) +const runtime = existsSync(join(slotDir, ORCAD_NODE_RUNTIME_MARKER_FILENAME)) + ? resolveBundledOrcadRuntime(slotDir) + : null +const hasCli = + runtime !== null && + existsSync(cliEntry) && + (process.platform !== 'win32' || existsSync(windowsLauncher)) +const skip = skipForMissingInputs('artifact', hasCli ? [] : ['the packaged server CLI']) + +/** The launcher the server would hand its children, pinned to `userData`. */ +function serverCliLauncher(userData: string): string { + if (process.platform === 'win32') { + const binDir = join(userData, 'cli', 'bin') + const launcher = join(binDir, 'orca.exe') + mkdirSync(binDir, { recursive: true }) + copyFileSync(windowsLauncher, launcher) + writeFileSync( + join(binDir, ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME), + buildWindowsCliLauncherConfig(runtime!, cliEntry, userData) + ) + return launcher + } + const launcher = join(userData, 'orca') + writeFileSync(launcher, buildUnixCliLauncher(runtime!, cliEntry, userData, 'node'), { + mode: 0o700 + }) + return launcher +} + +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)) + const launcher = serverCliLauncher(root) + 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_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/pi/rpc-launch-resolution.test.ts b/src/main/pi/rpc-launch-resolution.test.ts index 8c9a28ce844f..fa1309ccd040 100644 --- a/src/main/pi/rpc-launch-resolution.test.ts +++ b/src/main/pi/rpc-launch-resolution.test.ts @@ -119,6 +119,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' }), + refreshAfterMissingProgram: 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 d1abf1af1ea8..aa639e5315a7 100644 --- a/src/main/pi/rpc-launch-resolution.ts +++ b/src/main/pi/rpc-launch-resolution.ts @@ -11,6 +11,7 @@ import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session 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' @@ -98,6 +99,34 @@ 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 + /** 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 () => { + 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: ({ env }) => env, cwd: deps.resolveWorkingDirectory } + ) +} + /** Pi's part of a launch: its account directory, session file or fork, binary and release check. */ export function piRpcLaunchPart( deps: PiRpcLaunchPartDeps @@ -109,10 +138,14 @@ export function piRpcLaunchPart( } const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome) const cwd = await basis.launchDirectory() - const env: Record = { - ...(await basis.environment()), - PI_CODING_AGENT_DIR: accountHome.path - } + const { env, command } = await resolvePiRpcInvocation({ + accountDir: accountHome.path, + resolveEnvironment: basis.environment, + commandSettings: basis.commandSettings(), + resolveCommand: deps.resolveCommand, + refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram, + resolveWorkingDirectory: () => cwd + }) const previous = agentSessionProviderHandleChainHead(record.providerHandleChain) let sessionFile: string | undefined let forkFile: string | undefined @@ -135,7 +168,6 @@ export function piRpcLaunchPart( forkFile = source } } - const command = resolvePiRpcCommand(env, basis.commandSettings(), 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-model-catalog-probe.ts b/src/main/pi/rpc-model-catalog-probe.ts index 6bdb1b63ae90..7dbdf8462d68 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,15 +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 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, + resolveWorkingDirectory: () => cwd + }) 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.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 a8952bcb417d..4239948c65e3 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, @@ -58,6 +57,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 } : {}) @@ -66,18 +66,12 @@ export const PI_RPC_RUNTIME_REGISTRATION: StructuredAgentRuntimeRegistration = { transcriptImport: null, sessionHistory: PI_SESSION_HISTORY, 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/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 554e87686129..7f6741ce2dc9 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 '@orca/process-host' import { mergeWindowsPathSegments } from './windows-path-segment-merge' import { WindowsPathRegistryFallback, @@ -18,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 } @@ -39,6 +42,7 @@ let persistedWindowsPathCache: } | undefined let pendingPersistedWindowsPathRefresh: Promise | undefined +let pendingPersistedWindowsPathRefreshStartedAt = 0 let persistedWindowsPathCacheGeneration = 0 let persistedWindowsPathReadSequence = 0 let persistedWindowsPathCommittedSequence = 0 @@ -77,11 +81,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 +120,51 @@ 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]) => { + // 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: [] } + : { + failed: false, + segments: registryOutputSegments(stdout, valueName, env, pathDelimiter) + } + }) + ) } export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions = {}): string[] { @@ -166,15 +200,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) @@ -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 @@ -217,7 +248,11 @@ export async function readPersistedWindowsPathSegmentsAsync( ) { return [...persistedWindowsPathCache.segments] } - if (useProductionCache && pendingPersistedWindowsPathRefresh) { + if ( + useProductionCache && + pendingPersistedWindowsPathRefresh && + pendingPersistedWindowsPathRefreshStartedAt >= (options.startedAfter ?? -Infinity) + ) { return [...(await pendingPersistedWindowsPathRefresh)] } @@ -227,18 +262,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) { @@ -255,6 +284,7 @@ export async function readPersistedWindowsPathSegmentsAsync( return refresh } pendingPersistedWindowsPathRefresh = refresh + pendingPersistedWindowsPathRefreshStartedAt = now try { return [...(await refresh)] } finally { 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/__snapshots__/structured-agent-launch-parity.test.ts.snap b/src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap index 6a47a2c718a7..16c8993f6683 100644 --- a/src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap +++ b/src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap @@ -56,8 +56,11 @@ exports[`native-chat launch parity > claude floating fresh 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", "ORCA_PANE_KEY": "tab_parent:parent-pane", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TAB_ID": "tab_parent", "ORCA_TERMINAL_HANDLE": "term_parent", @@ -141,8 +144,11 @@ exports[`native-chat launch parity > claude floating resumed 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", "ORCA_PANE_KEY": "tab_parent:parent-pane", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TAB_ID": "tab_parent", "ORCA_TERMINAL_HANDLE": "term_parent", @@ -224,8 +230,11 @@ exports[`native-chat launch parity > claude worktree fresh 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", "ORCA_PANE_KEY": "tab_parent:parent-pane", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TAB_ID": "tab_parent", "ORCA_TERMINAL_HANDLE": "term_parent", @@ -309,8 +318,11 @@ exports[`native-chat launch parity > claude worktree fresh configured 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", "ORCA_PANE_KEY": "tab_parent:parent-pane", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TAB_ID": "tab_parent", "ORCA_TERMINAL_HANDLE": "term_parent", @@ -384,8 +396,11 @@ exports[`native-chat launch parity > claude worktree fresh worker 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", "ORCA_PANE_KEY": "tab_parent:parent-pane", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TAB_ID": "tab_parent", "ORCA_TERMINAL_HANDLE": "structworker_00000000-0000-4000-8000-000000000005", @@ -467,8 +482,11 @@ exports[`native-chat launch parity > claude worktree resumed 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", "ORCA_PANE_KEY": "tab_parent:parent-pane", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TAB_ID": "tab_parent", "ORCA_TERMINAL_HANDLE": "term_parent", @@ -552,8 +570,11 @@ exports[`native-chat launch parity > claude worktree resumed configured 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", "ORCA_PANE_KEY": "tab_parent:parent-pane", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TAB_ID": "tab_parent", "ORCA_TERMINAL_HANDLE": "term_parent", @@ -610,8 +631,11 @@ exports[`native-chat launch parity > codex floating fresh 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", "ORCA_PANE_KEY": "tab_parent:parent-pane", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TAB_ID": "tab_parent", "ORCA_TERMINAL_HANDLE": "term_parent", @@ -679,8 +703,11 @@ exports[`native-chat launch parity > codex floating resumed 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", "ORCA_PANE_KEY": "tab_parent:parent-pane", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TAB_ID": "tab_parent", "ORCA_TERMINAL_HANDLE": "term_parent", @@ -746,8 +773,11 @@ exports[`native-chat launch parity > codex worktree fresh 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", "ORCA_PANE_KEY": "tab_parent:parent-pane", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TAB_ID": "tab_parent", "ORCA_TERMINAL_HANDLE": "term_parent", @@ -813,8 +843,11 @@ exports[`native-chat launch parity > codex worktree fresh configured 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", "ORCA_PANE_KEY": "tab_parent:parent-pane", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TAB_ID": "tab_parent", "ORCA_TERMINAL_HANDLE": "term_parent", @@ -878,8 +911,11 @@ exports[`native-chat launch parity > codex worktree fresh worker 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", "ORCA_PANE_KEY": "tab_parent:parent-pane", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TAB_ID": "tab_parent", "ORCA_TERMINAL_HANDLE": "structworker_00000000-0000-4000-8000-000000000010", @@ -947,8 +983,11 @@ exports[`native-chat launch parity > codex worktree resumed 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", "ORCA_PANE_KEY": "tab_parent:parent-pane", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TAB_ID": "tab_parent", "ORCA_TERMINAL_HANDLE": "term_parent", @@ -1014,8 +1053,11 @@ exports[`native-chat launch parity > codex worktree resumed configured 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", "ORCA_PANE_KEY": "tab_parent:parent-pane", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TAB_ID": "tab_parent", "ORCA_TERMINAL_HANDLE": "term_parent", @@ -1073,7 +1115,10 @@ exports[`native-chat launch parity > grok floating fresh 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -1142,7 +1187,10 @@ exports[`native-chat launch parity > grok floating resumed 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -1209,7 +1257,10 @@ exports[`native-chat launch parity > grok worktree fresh 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -1275,7 +1326,10 @@ exports[`native-chat launch parity > grok worktree fresh configured 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -1338,7 +1392,10 @@ exports[`native-chat launch parity > grok worktree fresh worker 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "structworker_00000000-0000-4000-8000-000000000015", "ORCA_USER_DATA_PATH": "/user-data", @@ -1406,7 +1463,10 @@ exports[`native-chat launch parity > grok worktree resumed 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -1472,7 +1532,10 @@ exports[`native-chat launch parity > grok worktree resumed configured 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -1535,7 +1598,10 @@ exports[`native-chat launch parity > omp floating fresh 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -1602,7 +1668,10 @@ exports[`native-chat launch parity > omp floating resumed 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -1667,7 +1736,10 @@ exports[`native-chat launch parity > omp worktree fresh 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -1732,7 +1804,10 @@ exports[`native-chat launch parity > omp worktree fresh configured 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -1793,7 +1868,10 @@ exports[`native-chat launch parity > omp worktree fresh worker 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "structworker_00000000-0000-4000-8000-000000000025", "ORCA_USER_DATA_PATH": "/user-data", @@ -1859,7 +1937,10 @@ exports[`native-chat launch parity > omp worktree resumed 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -1924,7 +2005,10 @@ exports[`native-chat launch parity > omp worktree resumed configured 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -1988,6 +2072,9 @@ exports[`native-chat launch parity > opencode floating fresh 1`] = ` "ORCA_CHAT_VISUALS_DIR": "/state/native-chat-visuals/d8b72865af2aa80c73fb957764c415b2", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", + "ORCA_ENVIRONMENT": "", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -2060,6 +2147,9 @@ exports[`native-chat launch parity > opencode floating resumed 1`] = ` "ORCA_CHAT_VISUALS_DIR": "/state/native-chat-visuals/142777cee33f20ee78b32b50361f8e9c", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", + "ORCA_ENVIRONMENT": "", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -2130,6 +2220,9 @@ exports[`native-chat launch parity > opencode worktree fresh 1`] = ` "ORCA_CHAT_VISUALS_DIR": "/state/native-chat-visuals/1ebe6393ce8358dbebca3fa0236d990c", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", + "ORCA_ENVIRONMENT": "", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -2200,6 +2293,9 @@ exports[`native-chat launch parity > opencode worktree fresh configured 1`] = ` "ORCA_CHAT_VISUALS_DIR": "/state/native-chat-visuals/083c5a4185dc6c2750d599f361c671d1", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", + "ORCA_ENVIRONMENT": "", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -2267,6 +2363,9 @@ exports[`native-chat launch parity > opencode worktree fresh worker 1`] = ` "ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", + "ORCA_ENVIRONMENT": "", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "structworker_00000000-0000-4000-8000-000000000020", "ORCA_USER_DATA_PATH": "/user-data", @@ -2338,6 +2437,9 @@ exports[`native-chat launch parity > opencode worktree resumed 1`] = ` "ORCA_CHAT_VISUALS_DIR": "/state/native-chat-visuals/f22d18cd47801ffbd620f7a8fd45d117", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", + "ORCA_ENVIRONMENT": "", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -2408,6 +2510,9 @@ exports[`native-chat launch parity > opencode worktree resumed configured 1`] = "ORCA_CHAT_VISUALS_DIR": "/state/native-chat-visuals/9825b24d9014b859f02ff26f814792b0", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", + "ORCA_ENVIRONMENT": "", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "term_parent", "ORCA_USER_DATA_PATH": "/user-data", @@ -2476,7 +2581,10 @@ exports[`native-chat launch parity > pi floating fresh 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_USER_DATA_PATH": "/user-data", "PARITY_OVERLAY": "pi", @@ -2544,7 +2652,10 @@ exports[`native-chat launch parity > pi floating resumed 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_USER_DATA_PATH": "/user-data", "PARITY_OVERLAY": "pi", @@ -2608,7 +2719,10 @@ exports[`native-chat launch parity > pi worktree fresh 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_USER_DATA_PATH": "/user-data", "PARITY_OVERLAY": "pi", @@ -2672,7 +2786,10 @@ exports[`native-chat launch parity > pi worktree fresh configured 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_USER_DATA_PATH": "/user-data", "PARITY_OVERLAY": "pi-configured", @@ -2735,7 +2852,10 @@ exports[`native-chat launch parity > pi worktree fresh worker 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_TERMINAL_HANDLE": "structworker_00000000-0000-4000-8000-000000000030", "ORCA_USER_DATA_PATH": "/user-data", @@ -2801,7 +2921,10 @@ exports[`native-chat launch parity > pi worktree resumed 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_USER_DATA_PATH": "/user-data", "PARITY_OVERLAY": "pi", @@ -2867,7 +2990,10 @@ exports[`native-chat launch parity > pi worktree resumed configured 1`] = ` "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", "ORCA_DATA_ACCOUNT_PROVIDER": "parent-account", + "ORCA_ENVIRONMENT": "", "ORCA_OPENCODE_CONFIG_DIR": "/parent/opencode-overlay", + "ORCA_PAIRING_CODE": "", + "ORCA_REMOTE_PAIRING": "", "ORCA_STRUCTURED_SESSION": "1", "ORCA_USER_DATA_PATH": "/user-data", "PARITY_OVERLAY": "pi-configured", diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index bf69bcac6aab..ecc029770ce4 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -214,8 +214,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/orca-runtime-structured-launch-settings-wiring.test.ts b/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts index f3442ce6f6e8..66244907ed27 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' @@ -49,4 +52,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-install-refresh.test.ts b/src/main/runtime/structured-agent-install-refresh.test.ts new file mode 100644 index 000000000000..721fd2aac25c --- /dev/null +++ b/src/main/runtime/structured-agent-install-refresh.test.ts @@ -0,0 +1,159 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +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' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { acpLaunchSpecFor } from '../acp/acp-launch-specs' +import { resolveClaudeChildEnvSources } from '../claude/claude-structured-child-env' +import { + createAcpStructuredLaunchResolver, + createCodexStructuredLaunchResolver +} from './structured-agent-launch-composition.test-support' + +const GROK = acpLaunchSpecFor('grok')! +const identity = { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: null +} +const roots: string[] = [] + +afterEach(() => { + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) + +/** + * 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. + */ +function hostInstallingAfterFirstCapture(name: string) { + const root = mkdtempSync(join(tmpdir(), 'orca-install-after-capture-')) + roots.push(root) + const profileBin = join(root, 'profile-bin') + mkdirSync(profileBin) + const program = join(profileBin, process.platform === 'win32' ? `${name}.cmd` : name) + writeFileSync(program, '#!/bin/sh\nexit 0\n') + chmodSync(program, 0o755) + let served = '/usr/bin' + const environment = { + resolveBaseEnvironment: async () => ({ PATH: served, HOME: root }), + refreshAfterMissingProgram: vi.fn(async (_askedAt: number) => { + served = profileBin + }) + } + return { environment, program, profileBin } +} + +function store(record: Partial) { + return { + getRecord: () => ({ ...agentSessionRecordFixture(), providerHandleChain: [], ...record }), + pinLaunchDirectory: vi.fn() + } +} + +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 = hostInstallingAfterFirstCapture(name) + const sources = await resolveClaudeChildEnvSources({ + resolveInheritedEnv: host.environment.resolveBaseEnvironment, + 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 through the composed launch, from the refreshed environment", async () => { + const name = `codex-l5-${process.pid}` + const host = hostInstallingAfterFirstCapture(name) + const launch = await createCodexStructuredLaunchResolver({ + store: store({ + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(host.profileBin, '..', '.codex') } + }), + resolveEnvironment: host.environment.resolveBaseEnvironment, + refreshAfterMissingProgram: host.environment.refreshAfterMissingProgram, + resolveCommand: (options) => resolveCliCommand(name, options), + resolveLaunchArgs: () => [] + })({ identity: { ...identity, agent: 'codex' } }) + expect(launch.command).toBe(host.program) + expect(launch.env.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 = hostInstallingAfterFirstCapture(name) + const launch = await createAcpStructuredLaunchResolver(GROK, { + store: store({ + provider: 'grok', + accountHome: { variable: 'GROK_HOME', path: join(host.profileBin, '..', '.grok') } + }), + readJournal: () => null, + resolveEnvironment: host.environment.resolveBaseEnvironment, + refreshAfterMissingProgram: host.environment.refreshAfterMissingProgram, + resolveCommandSettings: () => ({ agentCmdOverrides: { grok: name } }) + })({ identity }) + expect(launch.command).toBe(host.program) + }) + + it('never refreshes for a program the snapshot already finds', async () => { + const name = `codex-l5-found-${process.pid}` + const host = hostInstallingAfterFirstCapture(name) + // Puts the install in the snapshot before the launch asks. + await host.environment.refreshAfterMissingProgram(Date.now()) + const refresh = vi.fn(async () => {}) + const launch = await createCodexStructuredLaunchResolver({ + store: store({ + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(host.profileBin, '..', '.codex') } + }), + resolveEnvironment: host.environment.resolveBaseEnvironment, + refreshAfterMissingProgram: refresh, + resolveCommand: (options) => resolveCliCommand(name, options), + resolveLaunchArgs: () => [] + })({ identity: { ...identity, agent: 'codex' } }) + expect(launch.command).toBe(host.program) + expect(refresh).not.toHaveBeenCalled() + }) + + 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({ + store: store({ + 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), + resolveLaunchArgs: () => [] + })({ identity: { ...identity, agent: 'codex' } }) + return { command: launch.command, rereads: refresh.mock.calls.length } + } + 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-composition.test-support.ts b/src/main/runtime/structured-agent-launch-composition.test-support.ts index fe344bd07cbf..2c3d55753d5e 100644 --- a/src/main/runtime/structured-agent-launch-composition.test-support.ts +++ b/src/main/runtime/structured-agent-launch-composition.test-support.ts @@ -41,6 +41,7 @@ export type LaunchTestSources = Pick & { resolveLaunchEnv?: (agent: string) => Record resolveCommandSettings?: () => StructuredAgentCommandSettings prepareVisuals?: PrepareNativeChatVisuals + refreshAfterMissingProgram?: (askedAt: number) => Promise } function definedEnv(env: NodeJS.ProcessEnv): Record { @@ -62,7 +63,10 @@ function launchSources(deps: LaunchTestSources): StructuredAgentLaunchSources { }), ...(deps.resolveLaunchEnv ? { resolveAgentLaunchEnv: deps.resolveLaunchEnv } : {}), ...(deps.resolveCommandSettings ? { resolveCommandSettings: deps.resolveCommandSettings } : {}), - ...(deps.prepareVisuals ? { prepareVisuals: deps.prepareVisuals } : {}) + ...(deps.prepareVisuals ? { prepareVisuals: deps.prepareVisuals } : {}), + ...(deps.refreshAfterMissingProgram + ? { refreshAfterMissingProgram: deps.refreshAfterMissingProgram } + : {}) } } diff --git a/src/main/runtime/structured-agent-launch-composition.ts b/src/main/runtime/structured-agent-launch-composition.ts index 7c644d1fc41f..e868f0f10338 100644 --- a/src/main/runtime/structured-agent-launch-composition.ts +++ b/src/main/runtime/structured-agent-launch-composition.ts @@ -31,6 +31,9 @@ export type StructuredAgentLaunchSources = { resolveAgentLaunchEnv?: (agent: string) => Record resolveCommandSettings?: () => StructuredAgentCommandSettings prepareVisuals?: PrepareNativeChatVisuals + /** 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 @@ -48,6 +51,9 @@ export type StructuredAgentLaunchBasis = { /** Null when the chat gets no visuals. */ visuals(): Promise 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?: (askedAt: number) => Promise /** The permission mode the host decided this start runs with; absent asks. */ permissionMode?: AgentChatPermissionMode } @@ -135,14 +141,28 @@ async function composedPart( ): Promise & { cwd: string }> { const record = admittedRecord(definition, sources.store, identity.sessionId) const launchEnv = once(() => sources.resolveAgentLaunchEnv?.(definition.agent) ?? {}) + const readEnvironments = () => ({ + base: once(sources.resolveBaseEnvironment), + agent: once(() => sources.resolveAgentEnvironment(definition.agent)) + }) + let environments = readEnvironments() + const refresh = sources.refreshAfterMissingProgram const basis: StructuredAgentLaunchBasis = { record, launchDirectory: once(() => resolveAgentSessionLaunchDirectory(sources, record)), - baseEnvironment: once(sources.resolveBaseEnvironment), + baseEnvironment: () => environments.base(), launchEnv, - environment: once(() => sources.resolveAgentEnvironment(definition.agent)), + environment: () => environments.agent(), visuals: once(async () => (await sources.prepareVisuals?.(record.sessionId)) ?? null), commandSettings: once(() => sources.resolveCommandSettings?.() ?? {}), + ...(refresh + ? { + refreshAfterMissingProgram: async (askedAt) => { + await refresh(askedAt) + environments = readEnvironments() + } + } + : {}), ...(permissionMode ? { permissionMode } : {}) } const composed = await part(basis, identity) @@ -199,6 +219,7 @@ export function structuredAgentLaunchSources( resolveWorkspacePath: deps.resolveWorkspacePath, resolveBaseEnvironment: context.environment.resolveBaseEnvironment, resolveAgentEnvironment: context.environment.resolveAgentEnvironment, + refreshAfterMissingProgram: context.environment.refreshAfterMissingProgram, ...(deps.resolveAgentLaunchEnv ? { resolveAgentLaunchEnv: deps.resolveAgentLaunchEnv } : {}), ...(deps.resolveAgentCommandSettings ? { resolveCommandSettings: deps.resolveAgentCommandSettings } diff --git a/src/main/runtime/structured-agent-launch-support.test.ts b/src/main/runtime/structured-agent-launch-support.test.ts index 6fd15027c158..cc0fd4d7d2d0 100644 --- a/src/main/runtime/structured-agent-launch-support.test.ts +++ b/src/main/runtime/structured-agent-launch-support.test.ts @@ -6,18 +6,40 @@ 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 { + resetLoginShellEnvironmentCacheForTests, + type ResolveLoginShellEnvironmentOptions as LoginShellOptions +} from '../startup/login-shell-environment' const { probeAgentCliVersion, loginShell } = vi.hoisted(() => { - const loginShell: { env: Record } = { env: {} } + const loginShell: { + env: NodeJS.ProcessEnv + spawn: ReturnType Promise>> + } = { + 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 } }) vi.mock('../agent-cli-version-probe', async (importOriginal) => ({ ...(await importOriginal()), probeAgentCliVersion })) -vi.mock('../startup/login-shell-environment', () => ({ - resolveLoginShellEnvironment: async () => loginShell.env -})) +vi.mock('../startup/login-shell-environment', async (importOriginal) => { + const real = await importOriginal() + return { + ...real, + resolveLoginShellEnvironment: (options: LoginShellOptions = {}) => + real.resolveLoginShellEnvironment({ + ...options, + shellOverride: '/bin/test-login-shell', + spawner: loginShell.spawn + }) + } +}) let bin: string const asked: { program: string; cwd: string; env: Record }[] = [] @@ -45,6 +67,8 @@ beforeEach(async () => { await writeFile(opencode, '#!/bin/sh\n') await chmod(opencode, 0o755) loginShell.env = { PATH: bin, HOME: '/home/user' } + loginShell.spawn.mockClear() + resetLoginShellEnvironmentCacheForTests() }) afterEach(async () => { probeAgentCliVersion.mockReset() @@ -80,6 +104,50 @@ describe('structuredAgentSupportsLaunch', () => { }) }) + 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() + 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(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.spawn).toHaveBeenCalledTimes(2) + expect(asked.map(({ program }) => program)).toEqual(Array(8).fill(join(bin, name))) + expect((await launches.resolveBaseEnvironment()).PATH).toBe(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.spawn).toHaveBeenCalledOnce() + expect(asked).toHaveLength(2) + }) + 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..f49a1e07ff1e 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -8,8 +8,13 @@ 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 { resolveHostAgentBaseEnvironment } from './structured-agent-shell-environment' +import { + refreshHostAgentBaseEnvironmentAfterMiss, + resolveHostAgentBaseEnvironment +} from './structured-agent-shell-environment' type LaunchEnvironmentSettings = Pick< GlobalSettings, @@ -31,17 +36,40 @@ 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 env = { - ...(await resolveHostAgentBaseEnvironment(settings)), - ...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) : {}) + 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 + // 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, + { env: ({ env }) => env, cwd } + ) + } catch (error) { + // An unrunnable Command setting is the launch's refusal to state, not a terminal. + if (isAgentSessionPreSpawnError(error)) { + return true + } + throw error } - const cwd = (await runtime.resolveRuntimeFileTarget(worktreeSelector)).worktree.path - return supportsLaunch({ cwd, env, commandSettings: settings }) + 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-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-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 136f9b259752..a770451e54ae 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -18,10 +18,9 @@ import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-defin import { CLAUDE_CLI_CONVERSATIONS } from '../claude/claude-cli-conversations' import { CODEX_CLI_CONVERSATIONS } from '../codex/codex-cli-conversations' import type { StructuredAgentCliConversations } from '../native-chat/structured-agent-cli-conversations' -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' @@ -109,6 +108,20 @@ export type StructuredAgentModelCatalogContext = Pick< 'deps' | 'environment' > +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 = StructuredAgentCliConversations & { definition: StructuredAgentDefinition createAdapter: (context: StructuredAgentAdapterContext) => StructuredAgentRuntimeAdapter @@ -117,13 +130,9 @@ export type StructuredAgentRuntimeRegistration = StructuredAgentCliConversations 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, @@ -203,19 +212,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-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 34a68277adc8..dbcd4ba18c70 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -99,7 +99,7 @@ export type StructuredAgentSessionRuntimeDeps = { claimKeyId: string resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string - resolveClaudeCommand?: () => string + resolveClaudeCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string 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 ba1484d0903f..99afd4c31515 100644 --- a/src/main/runtime/structured-agent-shell-environment.test.ts +++ b/src/main/runtime/structured-agent-shell-environment.test.ts @@ -1,11 +1,116 @@ -import { describe, expect, it } from 'vitest' +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 { 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, + readPersistedWindowsPathSegmentsAsync +} from '../pty/windows-environment-path' +import { resolveAgainAfterRefreshOnMissingProgram } from '../native-chat/structured-agent-command-resolution' +import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { createStructuredAgentEnvironmentResolvers, structuredAgentBaseEnvironment } from './structured-agent-shell-environment' +const { runProcessMock, spawnProcessMock } = vi.hoisted(() => ({ + runProcessMock: vi.fn(), + spawnProcessMock: vi.fn() +})) + +vi.mock('@orca/process-host', 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: [] } +afterEach(loginShell.resetLoginShellEnvironmentCacheForTests) + +function pathSegments(env: Record): 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, 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: () => {} }), + stdout, + stderr: Object.assign(new EventEmitter(), { resume: () => {} }), + kill: () => true + }) + const printed = JSON.stringify({ ...spec.env, PROFILE_ONLY: '1' }) + setImmediate(() => { + 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 +} + +/** 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', @@ -68,6 +173,379 @@ describe('structuredAgentBaseEnvironment', () => { }) describe('createStructuredAgentEnvironmentResolvers', () => { + 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') + // 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 () => { + 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('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(30_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('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, profileFails) + ) + const resolvers = createStructuredAgentEnvironmentResolvers({ + resolveShellEnvironmentPolicy: () => INHERIT_ALL + }) + await resolvers.resolveBaseEnvironment() + const failedRuns = spawnProcessMock.mock.calls.length + expect(failedRuns).toBeGreaterThan(0) + machinePath = 'C:\\Windows;C:\\NewTool' + 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) + } + ) + }) + + 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( + (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') + ) + 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.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.skipIf(process.platform === 'win32')( + 'retries a launch on a shell run started after it looked, not on one that predates an install', + async () => { + 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({ + resolveShellEnvironmentPolicy: () => INHERIT_ALL + }) + expect((await resolvers.resolveBaseEnvironment()).PATH).toBe('/old') + await writeFile(hold, '') + vi.advanceTimersByTime(10_000) + 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', PATH } + }, + resolvers.refreshAfterMissingProgram, + { env: ({ PATH }) => ({ PATH }) } + ) + await new Promise((resolve) => setTimeout(resolve, 300)) + await rm(hold, { force: true }) + expect((await launch).command).toBe('/installed/agent') + } finally { + vi.useRealTimers() + if (originalShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = originalShell + } + await rm(dir, { recursive: true, force: true }) + } + } + ) + + 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' })) + const resolvers = createStructuredAgentEnvironmentResolvers({ + resolveEnvironment, + resolveShellEnvironmentPolicy: () => INHERIT_ALL, + resolveAgentLaunchEnv: () => ({ CURRENT_SETTING: overlay }) + }) + expect(await resolvers.resolveAgentEnvironment('codex')).toEqual({ + PATH: '/shell/A', + CURRENT_SETTING: 'A' + }) + overlay = 'B' + expect(await resolvers.resolveAgentEnvironment('codex')).toEqual({ + 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 () => { 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 d851b1df868d..e3f545c994a9 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -1,4 +1,8 @@ import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' +import { + mergePersistedWindowsPathAsync, + readPersistedWindowsPathSegmentsAsync +} from '../pty/windows-environment-path' import type { GlobalSettings } from '../../shared/global-settings-types' import { nativeChatShellEnvironmentPolicy, @@ -74,6 +78,40 @@ export function structuredAgentBaseEnvironment(input: { ) } +// 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 resolveLoginShellEnvironment({ + 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 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, startedAfter: askedAt }) + : resolveLoginShellEnvironment({ startedAfter: askedAt }) + ).catch(() => {}) +} + /** The base env every local agent launch on this host starts from, read from current settings. */ export async function resolveHostAgentBaseEnvironment( settings: Pick< @@ -82,23 +120,21 @@ export async function resolveHostAgentBaseEnvironment( > ): Promise> { return structuredAgentBaseEnvironment({ - shellEnv: await resolveLoginShellEnvironment(), + shellEnv: await hostShellEnvironment(), policy: nativeChatShellEnvironmentPolicy(settings) }) } export type StructuredAgentEnvironmentSources = { + /** 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. */ resolveAgentLaunchEnv?: (agent: string) => Record } -/** - * Every agent's child env 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. - */ +/** 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 ): { @@ -106,14 +142,23 @@ export function createStructuredAgentEnvironmentResolvers( resolveBaseEnvironment: () => Promise> /** The base with the user's overlay for `agent` laid over it. */ resolveAgentEnvironment: (agent: string) => Promise> + refreshAfterMissingProgram: (askedAt: number) => Promise } { - const shellEnvironment = (sources.resolveEnvironment ?? resolveLoginShellEnvironment)() + // 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> => structuredAgentBaseEnvironment({ - shellEnv: await shellEnvironment, + shellEnv: await shellEnvironment(), policy: sources.resolveShellEnvironmentPolicy?.() ?? nativeChatShellEnvironmentPolicy(null) }) return { + refreshAfterMissingProgram: injected + ? async () => {} + : refreshHostAgentBaseEnvironmentAfterMiss, resolveBaseEnvironment: resolveBase, resolveAgentEnvironment: async (agent) => ({ ...(await resolveBase()), diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index ae489b9a6013..463b9a36006f 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -24,7 +24,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { /** The registration's composed launch: the shared launch path with Claude's part. */ resolveLaunch: ClaudeStructuredSessionAdapterDeps['resolveLaunch'] /** The binary and env the startup version prewarm asks, as a launch would. */ - resolveClaudeCommand?: () => string + resolveClaudeCommand?: typeof resolveClaudeCommand resolveClaudeLaunchEnv?: () => Promise> | Record /** The env a Claude child inherits before auth stripping; absent inherits Orca's own. */ resolveClaudeInheritedEnv?: () => Promise> diff --git a/src/main/runtime/structured-session-child-env.test.ts b/src/main/runtime/structured-session-child-env.test.ts index 81fe971c13d9..8403fda6dbbd 100644 --- a/src/main/runtime/structured-session-child-env.test.ts +++ b/src/main/runtime/structured-session-child-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) @@ -76,6 +77,10 @@ describe('sealStructuredSessionChild identity', () => { ORCA_CLI_BIN_DIR: SHIM_DIR, // The instance that minted the id, so any current CLI dials it rather than the default. 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: '', ORCA_AGENT_SESSION_SPAWN_TOKEN: SEALED_TEST_SPAWN_TOKEN }) // A chat names itself by its id alone: no handle, no pane key. @@ -84,6 +89,24 @@ describe('sealStructuredSessionChild identity', () => { 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: sealedChildEnv(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 = sealedChildEnv(SESSION_ID, { @@ -113,10 +136,18 @@ describe('sealStructuredSessionChild identity', () => { }) const env = sealedChildEnv(SESSION_ID, { PATH: '/usr/bin', - ORCA_USER_DATA_PATH: '/other/profile' + ORCA_USER_DATA_PATH: '/other/profile', + 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).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-env.ts b/src/main/runtime/structured-session-child-env.ts index 30c51e89155a..f565a47886f4 100644 --- a/src/main/runtime/structured-session-child-env.ts +++ b/src/main/runtime/structured-session-child-env.ts @@ -30,7 +30,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, @@ -126,6 +130,12 @@ function childIdentity( return { env, workerHandle: identity?.handle ?? null } } +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. @@ -139,6 +149,9 @@ function applyThisAppCli(env: Record): void { const userDataPath = app.getPath('userData') const isPackaged = app.isPackaged() 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-cache.test.ts b/src/main/startup/login-shell-environment-cache.test.ts index 972dc35a5d01..135cbe9506b8 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' @@ -64,7 +65,136 @@ 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 rerun 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, startedAfter: Date.now() }) + ).resolves.toEqual({ + status: 'fallback', + env + }) + await expect(resolveLoginShellEnvironment(options)).resolves.toEqual({ PATH: '/profile/bin' }) + expect(spawner).toHaveBeenCalledTimes(2) +}) + +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>() + .mockResolvedValueOnce({ PATH: '/first' }) + .mockReturnValueOnce(release.promise) + const options = { shellOverride: '/bin/bash', env: { HOME: '/host' }, spawner } + await resolveLoginShellEnvironment(options) + 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' + }) + release.resolve({ PATH: '/second' }) + await expect(forced).resolves.toEqual({ PATH: '/second' }) + await expect(resolveLoginShellEnvironment(options)).resolves.toEqual({ PATH: '/second' }) + expect(spawner).toHaveBeenCalledTimes(2) +}) + +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>() + .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, 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' }) + await expect(forced).resolves.toEqual({ PATH: '/profile/bin' }) + await expect(resolveLoginShellEnvironment(options)).resolves.toEqual({ PATH: '/profile/bin' }) + expect(spawner).toHaveBeenCalledTimes(2) +}) + +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 63d98eb4b3d4..0521b16a9e5a 100644 --- a/src/main/startup/login-shell-environment.ts +++ b/src/main/startup/login-shell-environment.ts @@ -11,9 +11,41 @@ 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 +} + +type CachedEnvironment = { + /** 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 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) { + break + } + environmentCache.delete(oldest) + } + const entry: CachedEnvironment = { attemptedAt: -Infinity } + environmentCache.set(key, entry) + return 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) @@ -127,7 +159,12 @@ function spawnShellAndReadEnvironment( } export type ResolveLoginShellEnvironmentOptions = { - force?: boolean + /** Only a run that started at or after this time answers; one is started if none has. */ + startedAfter?: number + /** Serves the last result, rerunning the shell in the background once the last run is older. */ + refreshAfterMs?: number + /** A captured env is kept for good; only a fallback is rerun after `refreshAfterMs`. */ + keepCaptured?: boolean shellOverride?: string | null env?: NodeJS.ProcessEnv /** Per-shell probe budget; defaults to 5 s. */ @@ -135,50 +172,76 @@ 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. 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 { +): Promise { const shell = 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) - if (cached && !options.force) { - return cached - } - if (!shell) { - return Promise.resolve(env) - } - const spawner = - options.spawner ?? - ((probeShell: string, probeEnv: NodeJS.ProcessEnv) => - spawnShellAndReadEnvironment(probeShell, probeEnv, options.timeoutMs)) - const pending = spawner(shell, env) - .then(async (environment) => { - if (environment) { - return environment + const entry = cachedEnvironment(`${shell}\0${fallback ?? ''}\0${envKey}`) + const run = (): Promise => { + const startedAt = Date.now() + const spawner = + options.spawner ?? + ((probeShell: string, probeEnv: NodeJS.ProcessEnv) => + spawnShellAndReadEnvironment(probeShell, probeEnv, options.timeoutMs)) + 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 fallback ? ((await spawner(fallback, env)) ?? env) : env }) - .catch(() => env) - environmentCache.delete(shellKey) - while (environmentCache.size >= MAX_CACHED_ENVIRONMENTS) { - const oldest = environmentCache.keys().next().value - if (oldest === undefined) { - break - } - environmentCache.delete(oldest) + return capture + } + const { running, last } = entry + if (options.startedAfter !== undefined) { + return running && running.startedAt >= options.startedAfter ? running.capture : run() + } + if (!last) { + return running?.capture ?? run() } - environmentCache.set(shellKey, pending) - return pending + const stale = + options.refreshAfterMs !== undefined && + Date.now() - entry.attemptedAt >= options.refreshAfterMs && + !(options.keepCaptured && last.status === 'captured') + if (stale && !running) { + void run() + } + return Promise.resolve(last) +} + +/** 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 { diff --git a/src/shared/orcad-artifacts.test.ts b/src/shared/orcad-artifacts.test.ts index 68e3edfcde08..5c7eaafb04fc 100644 --- a/src/shared/orcad-artifacts.test.ts +++ b/src/shared/orcad-artifacts.test.ts @@ -10,6 +10,7 @@ import { ORCAD_NODE_RUNTIME_MARKER_FILENAME, ORCAD_RIPGREP_ARTIFACTS, ORCAD_RIPGREP_LICENSE_ARTIFACTS, + ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME, ORCAD_WINDOWS_SECURITY_FILENAME, orcadArtifactFilenames, orcadBunRuntimeFilename, @@ -19,6 +20,13 @@ import { } from './orcad-artifacts' describe('standalone runtime artifacts', () => { + it.each(SERVER_TARGETS)('ships the native CLI launcher only in Windows %s slots', (target) => { + expect(orcadArtifactFilenames(target).includes(ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME)).toBe( + target.startsWith('win32-') + ) + expect(orcadTemplateCommonFilenames()).not.toContain(ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME) + }) + it.each(SERVER_TARGETS)( 'ships the foreign SQLite reader worker the %s runtime starts', (target) => {