fix(remote-ssh): route claude/opencode/codex/gemini/antigravity through login shell

remain-on-exit (previous commit) preserved dead remote panes instead of
destroying them, which revealed the real failure: `exec claude`/`exec
opencode` ran under ssh's non-interactive, non-login remote-command
shell, which only sees sshd's minimal default PATH — not the ~/.zshrc
PATH entries where these CLIs actually live (e.g. ~/.local/bin,
~/.opencode/bin). Wrap them in `$SHELL -i -l -c '<cmd>'`, mirroring the
fix shell mode already had.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
timkjr
2026-08-04 16:41:27 -05:00
co-authored by Claude Sonnet 5
parent 474efd9023
commit e803186dfe
5 changed files with 33 additions and 12 deletions
+14 -5
View File
@@ -59,6 +59,15 @@ export async function writeRemoteCases(configDir: string, cases: RemoteCase[]):
}
export function defaultRemoteCommandForMode(mode: SessionMode): string {
// Agent CLIs (claude/opencode/codex/gemini/antigravity) are typically installed
// under per-user paths like ~/.local/bin or ~/.opencode/bin, added to PATH only by
// the remote user's interactive-login shell startup files (~/.zshrc etc.). ssh's
// remote-command execution is neither interactive nor login, so a bare `exec
// claude` sees only sshd's minimal default PATH and fails with "command not
// found" (exit 127) — confirmed via `tmux capture-pane` on the
// remain-on-exit-preserved dead pane. Route through `$SHELL -i -l -c`, the same
// fix already used for shell mode below, so PATH is fully resolved before the
// CLI name is looked up.
const commands: Record<RemoteCommandMode, string> = {
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's
// /etc/passwd entry, so this launches their actual login shell (zsh,
@@ -68,11 +77,11 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string {
// Mirror the LOCAL claude default so the remote agent runs non-interactively
// (no trust-folder/permission prompt that nothing on the remote answers). The
// per-host `commands.claude` override stays the escape hatch.
claude: 'exec claude --dangerously-skip-permissions',
opencode: 'exec opencode',
codex: 'exec codex',
gemini: 'exec gemini',
antigravity: 'exec agy',
claude: `exec $SHELL -i -l -c ${shellescape('claude --dangerously-skip-permissions')}`,
opencode: `exec $SHELL -i -l -c ${shellescape('opencode')}`,
codex: `exec $SHELL -i -l -c ${shellescape('codex')}`,
gemini: `exec $SHELL -i -l -c ${shellescape('gemini')}`,
antigravity: `exec $SHELL -i -l -c ${shellescape('agy')}`,
};
return commands[mode as RemoteCommandMode] || commands.shell;
}
+4 -3
View File
@@ -852,13 +852,14 @@ export function buildRemoteLaunchCommand(options: {
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
// ignored claudeMode). A per-host `commands.claude` override stays authoritative
// (admin's explicit choice). For the DEFAULT single-user config (skip), the emitted
// command is byte-identical to before. Non-claude modes are unchanged.
// (admin's explicit choice). Wrapped in `$SHELL -i -l -c` for the same reason as
// `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH entry that
// only an interactive login shell resolves (see that function's comment).
const override = remote.commands?.[mode];
const modeCommand = override
? override
: mode === 'claude'
? `exec claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`
? `exec $SHELL -i -l -c ${shellescape(`claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`)}`
: defaultRemoteCommandForMode(mode);
const remoteName = remoteTmuxSessionName(sessionId);