mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user