From 3f37e6d7a85239261cb0a8d9f326da9c7fac4dfd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:09:15 -0700 Subject: [PATCH] fix: resolve SSH commit/PR agents through login+interactive shell PATH OpenCode and other agents installed via shell startup files (nvm/fnm/ Homebrew) could not be found when generating commit/PR messages over SSH. The relay runs on the remote host via a non-interactive SSH command whose PATH lacks those install dirs, so the agent binary was not found and generation failed. Opt the SSH commit/PR-generation spawn into login+interactive shell (bash/zsh -ilc) PATH resolution, matching the established pattern in hydrate-shell-path.ts and preflight-handler.ts. The interactive flag is required so .bashrc/.zshrc PATH hooks get sourced. Windows and non-bash/zsh shells are unchanged. Fixes #4720 Supersedes #5197. Co-authored-by: Andy Murray Co-authored-by: Orca --- src/main/providers/ssh-git-provider.test.ts | 9 ++- src/main/providers/ssh-git-provider.ts | 6 +- src/relay/agent-exec-handler.test.ts | 63 ++++++++++++++++++++- src/relay/agent-exec-handler.ts | 32 ++++++++++- 4 files changed, 103 insertions(+), 7 deletions(-) diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 01736fb2def..59d14863422 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -382,7 +382,8 @@ describe('SshGitProvider', () => { cwd: '/home/user/repo', stdin: null, timeoutMs: 60_000, - operation: 'commit-message' + operation: 'commit-message', + shell: true }) expect(result).toEqual(execResult) }) @@ -426,7 +427,8 @@ describe('SshGitProvider', () => { cwd: '/home/user/repo', stdin: null, timeoutMs: 60_000, - operation: 'commit-message' + operation: 'commit-message', + shell: true }) expect(mux.request).toHaveBeenNthCalledWith(2, 'agent.execNonInteractive', { binary: 'codex', @@ -434,7 +436,8 @@ describe('SshGitProvider', () => { cwd: '/home/user/repo', stdin: null, timeoutMs: 60_000, - operation: 'pull-request-fields' + operation: 'pull-request-fields', + shell: true }) await provider.cancelGenerateCommitMessage('/home/user/repo') diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 751ebc1ef7c..5cea9d2d1bd 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -192,7 +192,10 @@ export class SshGitProvider implements IGitProvider { cwd, stdin: plan.stdinPayload, timeoutMs, - operation + operation, + // Why: commit/PR agents on SSH hosts are often installed by shell + // startup files, so this path opts into login-shell PATH resolution. + shell: true }, undefined, operation @@ -265,6 +268,7 @@ export class SshGitProvider implements IGitProvider { timeoutMs: number env?: Record operation?: string + shell?: boolean }, signal?: AbortSignal, operation?: string diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts index f36c99105cc..d89e8739dc0 100644 --- a/src/relay/agent-exec-handler.test.ts +++ b/src/relay/agent-exec-handler.test.ts @@ -1,7 +1,12 @@ import { exec, spawn } from 'child_process' import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as ChildProcess from 'child_process' -import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness' +import { + createFakeChild, + createHandlers, + requestContext, + withPlatform +} from './agent-exec-handler-test-harness' vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal() @@ -97,6 +102,62 @@ describe('AgentExecHandler', () => { }) }) + it('uses the default POSIX shell when requested so SSH agent commands inherit shell PATH', async () => { + const originalShell = process.env.SHELL + process.env.SHELL = '/bin/bash' + try { + await withPlatform('linux', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child as never) + const handlers = createHandlers() + + const pending = handlers.get('agent.execNonInteractive')!( + { + binary: 'opencode', + args: ['run', '--model', 'opencode/deepseek-v4-flash-free'], + cwd: '/repo', + stdin: 'PROMPT', + timeoutMs: 5_000, + shell: true + }, + requestContext() + ) + + child.emit('close', 0) + + await expect(pending).resolves.toMatchObject({ + exitCode: 0, + timedOut: false + }) + expect(spawnMock).toHaveBeenCalledWith( + '/bin/bash', + [ + '-ilc', + 'exec "$@"', + '_', + 'opencode', + 'run', + '--model', + 'opencode/deepseek-v4-flash-free' + ], + { + cwd: '/repo', + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + } + ) + expect(child.stdin.end).toHaveBeenCalledWith('PROMPT') + }) + } finally { + if (originalShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = originalShell + } + } + }) + it('cancels the in-flight command for the requested cwd', async () => { const child = createFakeChild() spawnMock.mockReturnValue(child as never) diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts index 3aab57967e1..baeee3936c1 100644 --- a/src/relay/agent-exec-handler.ts +++ b/src/relay/agent-exec-handler.ts @@ -1,7 +1,8 @@ import { exec, spawn, type ChildProcess } from 'child_process' import { existsSync } from 'fs' -import { delimiter, join } from 'path' +import { basename, delimiter, join } from 'path' import type { RelayDispatcher, RequestContext } from './dispatcher' +import { resolveDefaultShell } from './pty-shell-utils' const DEFAULT_TIMEOUT_MS = 60_000 const MAX_TIMEOUT_MS = 5 * 60 * 1000 @@ -64,6 +65,31 @@ function getWindowsSafeSpawn( return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/s', '/c', commandLine] } } +function getSpawnPlan( + binary: string, + args: string[], + env: NodeJS.ProcessEnv, + useShell: boolean +): { spawnCmd: string; spawnArgs: string[] } { + if (process.platform === 'win32' || !useShell) { + return getWindowsSafeSpawn(binary, args, env) + } + const shell = resolveDefaultShell() + const shellName = basename(shell).toLowerCase() + if (shellName !== 'bash' && shellName !== 'zsh') { + return { spawnCmd: binary, spawnArgs: args } + } + // Why: SSH relay processes are launched by a non-interactive SSH command, + // whose PATH often lacks nvm/fnm/Homebrew agent installs. The interactive + // flag (`-i`) is required so .bashrc/.zshrc — where those PATH hooks live — + // get sourced; a plain login shell (`-lc`) would skip them. This resolves + // remote agent commands like the user's Orca terminal shell does. + return { + spawnCmd: shell, + spawnArgs: ['-ilc', 'exec "$@"', '_', binary, ...args] + } +} + // Why: mirrors src/main/text-generation/commit-message-text-generation.ts. On // Windows, npm-installed CLIs like `claude`/`codex` are usually `.cmd` shims. // We route those through cmd.exe so Node can launch them, and taskkill is @@ -95,6 +121,7 @@ type ExecParams = { timeoutMs: unknown env: unknown operation: unknown + shell: unknown } type CancelParams = { @@ -169,11 +196,12 @@ export class AgentExecHandler { ? (params.env as Record) : null const spawnEnv = extraEnv ? { ...process.env, ...extraEnv } : process.env + const useShell = params.shell === true return new Promise((resolve) => { let child try { - const { spawnCmd, spawnArgs } = getWindowsSafeSpawn(binary, args, spawnEnv) + const { spawnCmd, spawnArgs } = getSpawnPlan(binary, args, spawnEnv, useShell) child = spawn(spawnCmd, spawnArgs, { cwd, env: spawnEnv,