mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
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:
co-authored by
Claude Sonnet 5
parent
474efd9023
commit
e803186dfe
+14
-5
@@ -59,6 +59,15 @@ export async function writeRemoteCases(configDir: string, cases: RemoteCase[]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
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> = {
|
const commands: Record<RemoteCommandMode, string> = {
|
||||||
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's
|
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's
|
||||||
// /etc/passwd entry, so this launches their actual login shell (zsh,
|
// /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
|
// Mirror the LOCAL claude default so the remote agent runs non-interactively
|
||||||
// (no trust-folder/permission prompt that nothing on the remote answers). The
|
// (no trust-folder/permission prompt that nothing on the remote answers). The
|
||||||
// per-host `commands.claude` override stays the escape hatch.
|
// per-host `commands.claude` override stays the escape hatch.
|
||||||
claude: 'exec claude --dangerously-skip-permissions',
|
claude: `exec $SHELL -i -l -c ${shellescape('claude --dangerously-skip-permissions')}`,
|
||||||
opencode: 'exec opencode',
|
opencode: `exec $SHELL -i -l -c ${shellescape('opencode')}`,
|
||||||
codex: 'exec codex',
|
codex: `exec $SHELL -i -l -c ${shellescape('codex')}`,
|
||||||
gemini: 'exec gemini',
|
gemini: `exec $SHELL -i -l -c ${shellescape('gemini')}`,
|
||||||
antigravity: 'exec agy',
|
antigravity: `exec $SHELL -i -l -c ${shellescape('agy')}`,
|
||||||
};
|
};
|
||||||
return commands[mode as RemoteCommandMode] || commands.shell;
|
return commands[mode as RemoteCommandMode] || commands.shell;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -852,13 +852,14 @@ export function buildRemoteLaunchCommand(options: {
|
|||||||
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
|
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
|
||||||
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
|
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
|
||||||
// ignored claudeMode). A per-host `commands.claude` override stays authoritative
|
// ignored claudeMode). A per-host `commands.claude` override stays authoritative
|
||||||
// (admin's explicit choice). For the DEFAULT single-user config (skip), the emitted
|
// (admin's explicit choice). Wrapped in `$SHELL -i -l -c` for the same reason as
|
||||||
// command is byte-identical to before. Non-claude modes are unchanged.
|
// `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 override = remote.commands?.[mode];
|
||||||
const modeCommand = override
|
const modeCommand = override
|
||||||
? override
|
? override
|
||||||
: mode === 'claude'
|
: mode === 'claude'
|
||||||
? `exec claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`
|
? `exec $SHELL -i -l -c ${shellescape(`claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`)}`
|
||||||
: defaultRemoteCommandForMode(mode);
|
: defaultRemoteCommandForMode(mode);
|
||||||
const remoteName = remoteTmuxSessionName(sessionId);
|
const remoteName = remoteTmuxSessionName(sessionId);
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ describe('Antigravity mode gates', () => {
|
|||||||
|
|
||||||
it('has docker/remote default commands', () => {
|
it('has docker/remote default commands', () => {
|
||||||
expect(defaultDockerCommandForMode('antigravity')).toBe('exec agy');
|
expect(defaultDockerCommandForMode('antigravity')).toBe('exec agy');
|
||||||
expect(defaultRemoteCommandForMode('antigravity')).toBe('exec agy');
|
// Routed through an interactive login shell so per-user PATH entries resolve —
|
||||||
|
// same fix as the other remote agent CLIs (see defaultRemoteCommandForMode).
|
||||||
|
expect(defaultRemoteCommandForMode('antigravity')).toBe("exec $SHELL -i -l -c 'agy'");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,9 +56,12 @@ describe('remote-hosts domain', () => {
|
|||||||
|
|
||||||
it('returns safe mode defaults and remote display values', () => {
|
it('returns safe mode defaults and remote display values', () => {
|
||||||
expect(defaultRemoteCommandForMode('shell')).toBe('exec $SHELL -i -l');
|
expect(defaultRemoteCommandForMode('shell')).toBe('exec $SHELL -i -l');
|
||||||
expect(defaultRemoteCommandForMode('codex')).toBe('exec codex');
|
// Routed through an interactive login shell so per-user PATH entries (e.g.
|
||||||
|
// ~/.local/bin, ~/.opencode/bin) resolve — a bare `exec codex` sees only
|
||||||
|
// sshd's minimal default PATH and fails with "command not found".
|
||||||
|
expect(defaultRemoteCommandForMode('codex')).toBe("exec $SHELL -i -l -c 'codex'");
|
||||||
// Mirrors the local claude default so the remote agent runs non-interactively.
|
// Mirrors the local claude default so the remote agent runs non-interactively.
|
||||||
expect(defaultRemoteCommandForMode('claude')).toBe('exec claude --dangerously-skip-permissions');
|
expect(defaultRemoteCommandForMode('claude')).toBe("exec $SHELL -i -l -c 'claude --dangerously-skip-permissions'");
|
||||||
expect(remoteSshTarget({ id: 'h1', label: 'H1', host: 'box.local', username: 'aamer' })).toBe('aamer@box.local');
|
expect(remoteSshTarget({ id: 'h1', label: 'H1', host: 'box.local', username: 'aamer' })).toBe('aamer@box.local');
|
||||||
expect(remoteDisplayPath({ username: 'aamer', host: 'box.local', path: '/opt/work' })).toBe(
|
expect(remoteDisplayPath({ username: 'aamer', host: 'box.local', path: '/opt/work' })).toBe(
|
||||||
'aamer@box.local:/opt/work'
|
'aamer@box.local:/opt/work'
|
||||||
|
|||||||
@@ -147,7 +147,13 @@ describe('TmuxManager (unit)', () => {
|
|||||||
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||||
sessionId: 'abc123def456',
|
sessionId: 'abc123def456',
|
||||||
});
|
});
|
||||||
expect(command).toContain('exec claude --dangerously-skip-permissions');
|
// Routed through an interactive login shell so ~/.local/bin (where `claude`
|
||||||
|
// typically lives) is on PATH — ssh's remote-command execution is neither
|
||||||
|
// interactive nor login, so a bare `exec claude` fails with "command not found".
|
||||||
|
// The inner quoting is escaped twice over (once per shellescape() layer), so
|
||||||
|
// assert on the unescaped substrings rather than the literal quoted form.
|
||||||
|
expect(command).toContain('exec $SHELL -i -l -c');
|
||||||
|
expect(command).toContain('claude --dangerously-skip-permissions');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user