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 <andym3@illinois.edu>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-06-27 23:09:15 -07:00
co-authored by Andy Murray Orca
parent b45cb545c5
commit 3f37e6d7a8
4 changed files with 103 additions and 7 deletions
+6 -3
View File
@@ -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')
+5 -1
View File
@@ -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<string, string>
operation?: string
shell?: boolean
},
signal?: AbortSignal,
operation?: string
+62 -1
View File
@@ -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<typeof ChildProcess>()
@@ -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)
+30 -2
View File
@@ -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<string, string>)
: null
const spawnEnv = extraEnv ? { ...process.env, ...extraEnv } : process.env
const useShell = params.shell === true
return new Promise<ExecResult>((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,