Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
208b763
fix: refresh host environments before structured agent command resolu…
brennanb2025 Oct 8, 2026
73c1b90
fix: ship the owning server CLI for managed remote agents
brennanb2025 Oct 8, 2026
ff26c97
test: include the shipped CLI in remote install fixtures
brennanb2025 Oct 8, 2026
478c03f
Bind packaged agent CLI calls to their execution host
brennanb2025 Oct 8, 2026
075aae7
Keep owning-server CLI binding scoped to managed agents
brennanb2025 Oct 8, 2026
e14f5e5
Merge origin/main and adopt the managed-server CLI from #26539
brennanb2025 Oct 9, 2026
09b2349
Merge remote-tracking branch 'origin/main' into brennanb2025/ssh-chat…
brennanb2025 Oct 9, 2026
fe7baa5
Serve the last shell environment to chat starts and refresh it in the…
brennanb2025 Oct 9, 2026
1e24a20
Keep the last good shell environment when a refresh fails, and load t…
brennanb2025 Oct 9, 2026
b44e02b
Keep the last good login-shell env for other readers while a forced r…
brennanb2025 Oct 9, 2026
8685612
Merge remote-tracking branch 'origin/main' into brennanb2025/ssh-chat…
brennanb2025 Oct 10, 2026
c518623
Mark the Claude launch resolver as a reviewed router-aware module
brennanb2025 Oct 10, 2026
6e6adc8
Keep a settled login-shell env for readers during a refresh and share…
brennanb2025 Oct 10, 2026
9d83dca
Drop an inherited owning-host flag from daemon terminal panes
brennanb2025 Oct 10, 2026
547f29a
Merge remote-tracking branch 'origin/main' into brennanb2025/ssh-chat…
brennanb2025 Oct 11, 2026
ff3e9d9
Route agents to their own server through ordinary launch defaults; st…
brennanb2025 Oct 12, 2026
3264633
One shared shell reread when an agent's program is missing
brennanb2025 Oct 12, 2026
15b860f
Merge origin/main into brennanb2025/ssh-chat-remote-launch-env
brennanb2025 Oct 12, 2026
16af666
One owner for the cached shell environment
brennanb2025 Oct 12, 2026
c7dcb5a
Read an injected shell environment once per runtime, as main does
brennanb2025 Oct 12, 2026
828d166
Model lists reread the environment on a missing program, as launches do
brennanb2025 Oct 12, 2026
3aea603
Pass optional launch lookups through directly instead of conditional …
brennanb2025 Oct 12, 2026
2fb1ffd
Read the host shell env through resolveLoginShellEnvironment; drop th…
brennanb2025 Oct 12, 2026
54f3c7f
Merge remote-tracking branch 'origin/main' into brennanb2025/ssh-chat…
brennanb2025 Oct 12, 2026
aa1fd7e
Merge remote-tracking branch 'origin/main' into brennanb2025/ssh-chat…
brennanb2025 Oct 12, 2026
bc81086
Test that a hung login shell holds a launch miss for at most one shel…
brennanb2025 Oct 12, 2026
81b1f10
A program found through a relative PATH entry is not missing
brennanb2025 Oct 13, 2026
88aadd4
Count a relative PATH entry as found only when it holds the program; …
brennanb2025 Oct 13, 2026
0e2ad13
Merge remote-tracking branch 'origin/main' into brennanb2025/ssh-chat…
brennanb2025 Oct 13, 2026
0da44d4
Merge remote-tracking branch 'origin/main' into brennanb2025/ssh-chat…
brennanb2025 Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions config/scripts/build-windows-cli-launcher.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
Expand All @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions config/scripts/node-server-test-paths.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 8 additions & 1 deletion native/windows-cli-launcher/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions src/cli/index-local-command-routing-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id> 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.
Expand Down
57 changes: 57 additions & 0 deletions src/cli/runtime/client-execution-host.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<(typeof SELECTORS)[number], string>>): 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)
})
5 changes: 4 additions & 1 deletion src/main/acp/acp-model-catalog-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 22 additions & 2 deletions src/main/acp/acp-structured-launch-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -81,6 +82,10 @@ export type AcpLaunchInvocationDeps = {
resolveLaunchEnv?: (agent: string) => Record<string, string>
/** 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<void>
/** Where the program starts, for a PATH entry relative to it; Orca's own when absent. */
resolveWorkingDirectory?: () => Promise<string> | string
} & Pick<AcpStructuredLaunchPartDeps, 'resolveCommand' | 'homePath' | 'inheritedEnv'>

/** The binary a launch with `env` spawns: the user's Command setting when set, else the stock
Expand Down Expand Up @@ -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<string, string>; 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
Expand Down Expand Up @@ -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 } : {})
Expand Down
29 changes: 29 additions & 0 deletions src/main/claude/claude-structured-child-env.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
28 changes: 23 additions & 5 deletions src/main/claude/claude-structured-child-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): Record<string, string> {
Expand All @@ -17,7 +18,11 @@ function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record<string, string>): 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<void>
/** Where the program starts, for a PATH entry relative to it; Orca's own when absent. */
resolveWorkingDirectory?: () => Promise<string> | string
resolveEnv?: () =>
| Promise<Record<string, string> | undefined>
| Record<string, string>
Expand All @@ -33,14 +38,27 @@ export type ClaudeChildEnvSources = {
inheritedEnv: Record<string, string>
}

export async function resolveClaudeChildEnvSources(
deps: ClaudeEnvDeps
): Promise<ClaudeChildEnvSources> {
const command = (deps.resolveCommand ?? resolveClaudeCommand)()
export function resolveClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise<ClaudeChildEnvSources> {
return resolveAgainAfterRefreshOnMissingProgram(
() => readClaudeChildEnvSources(deps),
deps.refreshEnvironmentAfterMiss,
{
env: ({ inheritedEnv, overlay }) => ({ ...inheritedEnv, ...overlay }),
cwd: deps.resolveWorkingDirectory
}
)
}

async function readClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise<ClaudeChildEnvSources> {
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 }
}

Expand Down
4 changes: 3 additions & 1 deletion src/main/claude/claude-structured-launch-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export type ClaudeStructuredLaunch = {

export type ClaudeStructuredLaunchPartDeps = {
resolveLaunchArgs: () => Promise<string[]> | 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> | ClaudeStructuredAuthPolicy
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions src/main/codex/codex-structured-child-environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand All @@ -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)
})

Expand Down Expand Up @@ -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
}
Expand All @@ -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({
Expand All @@ -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)
})
Expand Down
Loading
Loading