mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(review): harden + wire remote-host SSH cases end-to-end (PR #145)
- UI: add the missing data-tab="case-remote" tab button; dispatch it through submitCaseModal()/switchCaseModalTab() to linkRemoteCase() (was dead code). - Restore: restoreMuxSessions() now passes remote (muxSession.remote ?? savedState.remote) into the Session constructor, so remote metadata round-trips on restart instead of reattaching from a local cwd / respawning LOCAL / being erased from state.json. Recovery tests added. - Run flows: runClaude()/runShell() route remote cases through /api/quick-start (POST /api/sessions stat-validates workingDir locally); run*() skip the /api/*/status pre-check and omit inert config/env for remote cases. - Quick-start: resolve the remote case BEFORE the local CLI availability gates and skip isCodex/Gemini/OpenCodeAvailable() when remote; REJECT envOverrides/effort/codex/gemini/openCode config for remote (they don't cross ssh) instead of silently dropping them. - Injection: reject $, backtick, $( in remotePath + identityFile at the schema layer (they survive shellescape into the bash -c launch double-quote layer). Regression tests for $(...) and backtick payloads added. - Remote socket/name: launch on a DEDICATED -L codeman-remote socket under a codeman-ssh-<id> name that fails a remote Codeman's SAFE_MUX_NAME_PATTERN, so a remote instance can't adopt the session; scope tmux set-options per-session (never -g) so they don't mutate other sessions. - Kill: best-effort ssh 'tmux -L codeman-remote kill-session' on remote session kill (fire-and-forget, never blocks/throws the local kill) so the remote agent isn't orphaned forever. - Probe: wire checkRemoteTmuxAvailable() into POST /api/quick-start (structured OPERATION_FAILED) and as courtesy validation in remote-link; add a default -o ConnectTimeout=10 to buildSshConnectionArgs (overridable via extraSshOptions). - Command default: remote claude default is now 'exec claude --dangerously-skip-permissions' (per-host override stays the escape hatch), mirroring local non-interactive semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+12
-7
@@ -60,7 +60,10 @@ export async function writeRemoteCases(configDir: string, cases: RemoteCase[]):
|
||||
export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
||||
const commands: Record<RemoteCommandMode, string> = {
|
||||
shell: 'exec bash -l',
|
||||
claude: 'exec claude',
|
||||
// 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',
|
||||
@@ -105,6 +108,7 @@ function expandIdentityPath(identityFile: string): string {
|
||||
* Returns the leading tokens of an ssh command line (NOT including `-t`, the
|
||||
* target, or any remote command). Order:
|
||||
* ssh -o BatchMode=yes
|
||||
* [-o ConnectTimeout=10] (default; suppressed if extraSshOptions sets it)
|
||||
* [-p <port>]
|
||||
* [-i <abs-identity>] (~/$HOME expanded, then shellescaped)
|
||||
* [-J <jumpHost>] (shellescaped, single token)
|
||||
@@ -115,11 +119,14 @@ function expandIdentityPath(identityFile: string): string {
|
||||
* - The ProxyCommand is emitted as a single shellescaped `-o KEY=VALUE`, so the
|
||||
* whole value (spaces + `%h`/`%p`) reaches ssh as one argument and `%h %p`
|
||||
* survive verbatim — ssh expands them to the real host/port, not the shell.
|
||||
* - Empty options ⇒ `['ssh', '-o BatchMode=yes']` (+ `-p` only when set), i.e.
|
||||
* byte-identical to the historical behavior.
|
||||
* - A default `-o ConnectTimeout=10` bounds the wait on an unreachable/blackholed
|
||||
* host (else the pane hangs on the OS TCP timeout). It is omitted when the
|
||||
* operator already set ConnectTimeout via extraSshOptions, so their value wins.
|
||||
*/
|
||||
export function buildSshConnectionArgs(remote: RemoteSshOptions & Pick<RemoteHost, 'port'>): string[] {
|
||||
const parts: string[] = ['ssh', '-o BatchMode=yes'];
|
||||
const hasConnectTimeout = (remote.extraSshOptions ?? []).some((opt) => /^ConnectTimeout=/i.test(opt));
|
||||
if (!hasConnectTimeout) parts.push('-o ConnectTimeout=10');
|
||||
if (remote.port) parts.push(`-p ${remote.port}`);
|
||||
if (remote.identityFile) parts.push(`-i ${shellescape(expandIdentityPath(remote.identityFile))}`);
|
||||
if (remote.jumpHost) parts.push(`-J ${shellescape(remote.jumpHost)}`);
|
||||
@@ -146,10 +153,8 @@ export function buildSshConnectionArgs(remote: RemoteSshOptions & Pick<RemoteHos
|
||||
export function buildRemoteTmuxCheckCommand(
|
||||
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): string {
|
||||
const [ssh, ...connectionArgs] = buildSshConnectionArgs(host);
|
||||
const parts = [ssh, connectionArgs[0], '-o ConnectTimeout=10', ...connectionArgs.slice(1)];
|
||||
parts.push(remoteSshTarget(host), "'command -v tmux'");
|
||||
return parts.join(' ');
|
||||
// ConnectTimeout is now a default of buildSshConnectionArgs (shared with the launch).
|
||||
return [...buildSshConnectionArgs(host), remoteSshTarget(host), "'command -v tmux'"].join(' ');
|
||||
}
|
||||
|
||||
export interface RemoteTmuxCheckResult {
|
||||
|
||||
+70
-16
@@ -671,16 +671,32 @@ function buildSpawnCommand(options: {
|
||||
return '$SHELL';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated socket for Codeman-launched REMOTE tmux servers, distinct from the
|
||||
* canonical local `-L codeman` socket. A remote host that runs its OWN Codeman
|
||||
* would otherwise share the `-L codeman` socket AND the `codeman-<hex>` discovery
|
||||
* name, so its `reconcileSessions()` would ADOPT our session (attach a PTY,
|
||||
* resize, respawn-pane it locally) — the cross-machine form of the "2nd instance
|
||||
* attaches live sessions" hazard. A private socket keeps our remote sessions off
|
||||
* that instance's radar entirely.
|
||||
*/
|
||||
const REMOTE_TMUX_SOCKET = 'codeman-remote';
|
||||
|
||||
/**
|
||||
* Deterministic, reattach-stable remote tmux session name for a Codeman session.
|
||||
*
|
||||
* Derived from the same stable field the LOCAL muxName uses
|
||||
* (`codeman-${sessionId.slice(0, 8)}`), so reconnecting (which re-issues the
|
||||
* exact same `ssh … new-session -A`) lands back in the SAME remote session.
|
||||
* Must NOT be random/time-based — it has to be stable across reconnects.
|
||||
* Derived from the same stable field the LOCAL muxName uses (the first 8 chars of
|
||||
* the sessionId), so reconnecting (which re-issues the exact same
|
||||
* `ssh … new-session -A`) lands back in the SAME remote session. Must NOT be
|
||||
* random/time-based — it has to be stable across reconnects.
|
||||
*
|
||||
* The `codeman-ssh-` prefix is deliberately chosen to FAIL a remote Codeman's
|
||||
* `SAFE_MUX_NAME_PATTERN` (`^codeman-[a-f0-9-]+$`) — the `s`/`h` letters mean a
|
||||
* remote instance's discovery never treats this as one of its own sessions (belt
|
||||
* to the dedicated-socket suspenders above).
|
||||
*/
|
||||
export function remoteTmuxSessionName(sessionId: string): string {
|
||||
return `codeman-${sessionId.slice(0, 8)}`;
|
||||
return `codeman-ssh-${sessionId.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -690,16 +706,22 @@ export function remoteTmuxSessionName(sessionId: string): string {
|
||||
*
|
||||
* Emits:
|
||||
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
|
||||
* 'tmux -L codeman new-session -A -s codeman-<id> -c <path> "cd <path> && exec <cli>" \
|
||||
* \; set -g status off \; set -g mouse off \; set -sg escape-time 0 \; set -g prefix C-q'
|
||||
* 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path> "cd <path> && exec <cli>" \
|
||||
* \; set -t codeman-ssh-<id> status off \; set -t codeman-ssh-<id> mouse off \
|
||||
* \; set -t codeman-ssh-<id> prefix C-q \; set -s escape-time 0'
|
||||
*
|
||||
* COD-107 — the connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
|
||||
* arbitrary `-o`) come from the shared `buildSshConnectionArgs(remote)`, so the
|
||||
* prereq tmux probe and this launch connect with identical options.
|
||||
*
|
||||
* - `new-session -A -s codeman-<id>` = attach-if-exists-else-create (idempotent),
|
||||
* so reconnect re-runs the same command and reattaches the still-running agent.
|
||||
* - `-L codeman` = canonical remote socket (for Phase 2/3 discovery).
|
||||
* - `new-session -A -s codeman-ssh-<id>` = attach-if-exists-else-create
|
||||
* (idempotent), so reconnect re-runs the same command and reattaches the
|
||||
* still-running agent.
|
||||
* - `-L codeman-remote` = a DEDICATED socket, NOT the canonical `-L codeman` a
|
||||
* remote Codeman would use, so our session never collides with / gets adopted by
|
||||
* an instance running on the remote host.
|
||||
* - The `set` options are scoped per-session (`set -t <name>` / server-level
|
||||
* `set -s`), never `-g`, so they never mutate other sessions' prefix/mouse.
|
||||
* - The whole tmux invocation is a SINGLE ssh argument (the remote login shell
|
||||
* runs it), so it is shell-quoted as one unit; the `cd && exec` command is in
|
||||
* turn a single tmux argument (tmux runs it via `/bin/sh -c`), so the path is
|
||||
@@ -721,13 +743,15 @@ export function buildRemoteLaunchCommand(options: {
|
||||
const paneCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
|
||||
|
||||
// The tmux command line, with `\;` separating commands so the config `set`s
|
||||
// apply on the SAME connection (and are idempotent on reattach).
|
||||
// apply on the SAME connection (and are idempotent on reattach). Options are
|
||||
// scoped per-session (`set -t <name>` / server `set -s`), NEVER `-g`, so a
|
||||
// shared remote tmux server's other sessions keep their own prefix/mouse.
|
||||
const tmuxInvocation = [
|
||||
`tmux -L codeman new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`,
|
||||
'set -g status off',
|
||||
'set -g mouse off',
|
||||
'set -sg escape-time 0',
|
||||
'set -g prefix C-q',
|
||||
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`,
|
||||
`set -t ${remoteName} status off`,
|
||||
`set -t ${remoteName} mouse off`,
|
||||
`set -t ${remoteName} prefix C-q`,
|
||||
'set -s escape-time 0',
|
||||
].join(' \\; ');
|
||||
|
||||
// ssh runs its trailing args through the remote login shell, so the entire
|
||||
@@ -743,6 +767,22 @@ export function buildRemoteLaunchCommand(options: {
|
||||
return sshParts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SSH command that kills the durable remote tmux session created by
|
||||
* `buildRemoteLaunchCommand`. Because that session lives on a private socket
|
||||
* (`-L codeman-remote`) under a stable name, killing the LOCAL ssh wrapper alone
|
||||
* would orphan the remote agent forever (invisible to Codeman, still burning plan
|
||||
* quota). This is fired best-effort on session kill; the shared connection args
|
||||
* carry the default `-o ConnectTimeout=10` so an unreachable host fails fast.
|
||||
*/
|
||||
export function buildRemoteKillCommand(options: { remote: SessionRemote; sessionId: string }): string {
|
||||
const { remote, sessionId } = options;
|
||||
const remoteName = remoteTmuxSessionName(sessionId);
|
||||
const killCmd = `tmux -L ${REMOTE_TMUX_SOCKET} kill-session -t ${shellescape(remoteName)}`;
|
||||
const [ssh, ...connectionArgs] = buildSshConnectionArgs(remote);
|
||||
return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sensitive environment variables on a tmux session via setenv.
|
||||
* These are inherited by panes but not visible in ps output or tmux history.
|
||||
@@ -1635,6 +1675,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3b: Remote sessions run a DURABLE tmux server on the remote host
|
||||
// (survives ssh drops), so killing only the local ssh wrapper above would
|
||||
// orphan the remote agent forever. Fire a best-effort `ssh … tmux kill-session`
|
||||
// — fire-and-forget so it NEVER blocks or throws the local kill (bounded by the
|
||||
// shared ConnectTimeout on an unreachable host).
|
||||
if (session.remote) {
|
||||
try {
|
||||
const remoteKillCmd = buildRemoteKillCommand({ remote: session.remote, sessionId });
|
||||
exec(remoteKillCmd, { timeout: EXEC_TIMEOUT_MS }, () => {});
|
||||
} catch {
|
||||
// Best-effort — a failure here must not affect the local kill result.
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: Direct kill by PID as final fallback
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
try {
|
||||
|
||||
@@ -1621,6 +1621,7 @@
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
|
||||
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
|
||||
<button class="modal-tab-btn" data-tab="case-remote">Remote</button>
|
||||
<button class="modal-tab-btn" data-tab="case-manage">Manage</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
+101
-29
@@ -331,6 +331,31 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const workingDir = caseData.path;
|
||||
if (!workingDir) throw new Error('Case path not found');
|
||||
|
||||
// Remote cases run over ssh — POST /api/sessions stat-validates workingDir on
|
||||
// the LOCAL fs (a remote user@host:/path never exists locally), so route them
|
||||
// through /api/quick-start, which resolves the remote case + launches via ssh.
|
||||
if (caseData.location === 'remote') {
|
||||
const remoteIds = [];
|
||||
for (let i = 0; i < tabCount; i++) {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'claude' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
this.terminal.writeln(`\x1b[90m All ${tabCount} remote session(s) ready\x1b[0m`);
|
||||
if (remoteIds[0]) {
|
||||
await this.selectSession(remoteIds[0]);
|
||||
this.loadQuickStartCases();
|
||||
}
|
||||
this.terminal.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
let firstSessionId = null;
|
||||
|
||||
// Find the highest existing w-number for THIS case to avoid duplicates
|
||||
@@ -489,6 +514,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
const workingDir = caseData.path;
|
||||
if (!workingDir) throw new Error('Case path not found');
|
||||
|
||||
// Remote cases run over ssh — route through /api/quick-start (see runClaude).
|
||||
if (caseData.location === 'remote') {
|
||||
const remoteIds = [];
|
||||
for (let i = 0; i < shellCount; i++) {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'shell' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
if (remoteIds[0]) {
|
||||
this.activeSessionId = remoteIds[0];
|
||||
await this.selectSession(remoteIds[0]);
|
||||
}
|
||||
this.terminal.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the highest existing s-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
@@ -554,6 +600,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async runOpenCode() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run the CLI on the REMOTE host — the local /api/opencode/status
|
||||
// probe and the local-only config/env below don't apply (quick-start rejects them).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
|
||||
@@ -562,13 +611,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
// Check if OpenCode is available
|
||||
const statusRes = await fetch('/api/opencode/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m');
|
||||
return;
|
||||
// Check if OpenCode is available (local sessions only)
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/opencode/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Quick-start with opencode mode (auto-allow tools by default).
|
||||
@@ -580,8 +631,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'opencode',
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(isRemote ? {} : {
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -601,6 +654,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async runCodex() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Codex on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
|
||||
@@ -608,12 +664,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
const statusRes = await fetch('/api/codex/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m');
|
||||
return;
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/codex/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const globalSettings = this.loadAppSettingsFromStorage();
|
||||
@@ -624,11 +682,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'codex',
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(isRemote ? {} : {
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -648,6 +708,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async runGemini() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Gemini on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`);
|
||||
@@ -655,12 +718,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
const statusRes = await fetch('/api/gemini/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m');
|
||||
return;
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/gemini/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
@@ -670,8 +735,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'gemini',
|
||||
geminiConfig: { approvalMode: 'yolo' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(isRemote ? {} : {
|
||||
geminiConfig: { approvalMode: 'yolo' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -1315,13 +1382,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.renderCaseManageList();
|
||||
} else {
|
||||
submitBtn.style.display = '';
|
||||
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
|
||||
submitBtn.textContent =
|
||||
tabName === 'case-create' ? 'Create' : tabName === 'case-remote' ? 'Link Remote' : 'Link';
|
||||
}
|
||||
// Focus appropriate input
|
||||
if (tabName === 'case-create') {
|
||||
document.getElementById('newCaseName').focus();
|
||||
} else if (tabName === 'case-link') {
|
||||
document.getElementById('linkCaseName').focus();
|
||||
} else if (tabName === 'case-remote') {
|
||||
document.getElementById('remoteCaseName').focus();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1337,6 +1407,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
if (this.caseModalTab === 'case-create') {
|
||||
await this.createCase();
|
||||
} else if (this.caseModalTab === 'case-remote') {
|
||||
await this.linkRemoteCase();
|
||||
} else {
|
||||
await this.linkCase();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
readRemoteCases,
|
||||
readRemoteHosts,
|
||||
remoteDisplayPath,
|
||||
@@ -218,6 +219,14 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// Courtesy validation: tmux is a hard prerequisite for durable remote sessions.
|
||||
// Verify it up-front so linking surfaces a clear error now instead of a dead pane
|
||||
// at first launch (also confirms the SSH connection actually works).
|
||||
const tmuxCheck = await checkRemoteTmuxAvailable(host);
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux');
|
||||
}
|
||||
|
||||
await writeRemoteCases(CODEMAN_CONFIG_DIR, [...remoteCases, remoteCase]);
|
||||
ctx.broadcast(SseEvent.CaseLinked, { name: remoteCase.name, path: remoteCase.remotePath, type: 'remote' });
|
||||
return { success: true, data: { case: remoteCase } };
|
||||
|
||||
@@ -62,7 +62,7 @@ import { RunSummaryTracker } from '../../run-summary.js';
|
||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import { readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
|
||||
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
@@ -1262,50 +1262,78 @@ export function registerSessionRoutes(
|
||||
effort,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
if (mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex availability if requested
|
||||
if (mode === 'codex') {
|
||||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
||||
if (!isCodexAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Gemini availability if requested
|
||||
if (mode === 'gemini') {
|
||||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
||||
if (!isGeminiAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the remote case FIRST — the CLI executes on the REMOTE host over ssh,
|
||||
// so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and
|
||||
// would wrongly reject a machine that hasn't got the CLI installed locally.
|
||||
let remote = undefined;
|
||||
let linkedCasePath: string | undefined;
|
||||
let casePath: string | null = null;
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCase = remoteCases.find((item) => item.name === caseName);
|
||||
if (remoteCase) {
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
|
||||
// Per-session config that is applied to the LOCAL tmux/CLI wrapper (env vars via
|
||||
// tmux setenv, effort/model CLI args, codex/gemini/opencode config) does NOT
|
||||
// cross ssh, so it would silently no-op. Reject rather than pretend it worked —
|
||||
// remote command/env customization goes through the per-host command override.
|
||||
if (
|
||||
(envOverrides && Object.keys(envOverrides).length > 0) ||
|
||||
effort ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
||||
);
|
||||
}
|
||||
|
||||
// tmux is a hard prerequisite on the remote host (the agent runs inside a remote
|
||||
// tmux server so it survives ssh drops). Probe before spawning so a missing tmux
|
||||
// surfaces a clear, structured error instead of a dead "tmux: command not found" pane.
|
||||
const tmuxCheck = await checkRemoteTmuxAvailable(host);
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux');
|
||||
}
|
||||
|
||||
casePath = remoteCase.remotePath;
|
||||
remote = toSessionRemote(host, remoteCase);
|
||||
} else {
|
||||
// Check OpenCode availability if requested
|
||||
if (mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex availability if requested
|
||||
if (mode === 'codex') {
|
||||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
||||
if (!isCodexAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Gemini availability if requested
|
||||
if (mode === 'gemini') {
|
||||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
||||
if (!isGeminiAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
||||
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
||||
// external project directories are honoured by quick-start just like regular case routes.
|
||||
@@ -1316,8 +1344,7 @@ export function registerSessionRoutes(
|
||||
} catch {
|
||||
// File missing or unparseable — treat as empty registry
|
||||
}
|
||||
linkedCasePath = linkedCases[caseName];
|
||||
casePath = linkedCasePath || validatePathWithinBase(caseName, CASES_DIR);
|
||||
casePath = linkedCases[caseName] || validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
@@ -1348,10 +1375,11 @@ export function registerSessionRoutes(
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
} else if (mode !== 'opencode') {
|
||||
} else if (!remote && mode !== 'opencode') {
|
||||
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
|
||||
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
|
||||
// the hooks aren't ours or already carry the secret.
|
||||
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
|
||||
// resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem.
|
||||
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
+16
-8
@@ -289,6 +289,13 @@ const RemoteCommandOverridesSchema = z
|
||||
const NO_SHELL_INJECTION = /^[^\n\r\0`]*$/;
|
||||
const noCommandSubstitution = (s: string) => !s.includes('$(');
|
||||
|
||||
// `remotePath`/`identityFile` are shell-escaped, then the whole launch command is
|
||||
// embedded via `JSON.stringify(...)` inside `bash -c "..."` (tmux-manager). That
|
||||
// outer DOUBLE-quote layer re-exposes `$(...)`, backticks, and `$VAR` even though
|
||||
// the inner value is single-quoted — so a `$(cmd)` in the path would run LOCALLY at
|
||||
// launch. Reject `$` and backtick (and newline/CR/NUL) entirely at the boundary.
|
||||
const NO_SHELL_META = /^[^\n\r\0`$]*$/;
|
||||
|
||||
export const RemoteHostSchema = z.object({
|
||||
id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'),
|
||||
label: z.string().min(1).max(100),
|
||||
@@ -303,13 +310,9 @@ export const RemoteHostSchema = z.object({
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._-]+$/, 'Invalid SSH username'),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
// Identity (private-key) file PATH only — never key bytes. No newline/NUL.
|
||||
identityFile: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(4096)
|
||||
.regex(/^[^\n\r\0]*$/, 'Invalid identity file path')
|
||||
.optional(),
|
||||
// Identity (private-key) file PATH only — never key bytes. Reject shell
|
||||
// metacharacters ($, backtick) that survive into the `bash -c` launch layer.
|
||||
identityFile: z.string().min(1).max(4096).regex(NO_SHELL_META, 'Invalid identity file path').optional(),
|
||||
// SOCKS5 proxy as host:port (e.g. 127.0.0.1:1080).
|
||||
socksProxy: z
|
||||
.string()
|
||||
@@ -348,7 +351,12 @@ export const RemoteHostSchema = z.object({
|
||||
export const RemoteCaseLinkSchema = z.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'),
|
||||
remotePath: z.string().min(1).max(2000).regex(/^\//, 'Remote path must be absolute'),
|
||||
remotePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Remote path must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in remote path'),
|
||||
});
|
||||
|
||||
// ========== Quick Start ==========
|
||||
|
||||
@@ -2140,6 +2140,12 @@ export class WebServer extends EventEmitter {
|
||||
envOverrides: savedEnvOverrides,
|
||||
effort: savedState?.effort,
|
||||
attachmentHistory: savedAttachmentHistory,
|
||||
// Remote SSH metadata must round-trip on recovery: without it the
|
||||
// attach cwd falls back to the (nonexistent-locally) remote path and
|
||||
// respawn rebuilds a LOCAL command, breaking the pane and silently
|
||||
// erasing `remote` from state.json on the next persist. mux-sessions.json
|
||||
// round-trips MuxSession.remote; state.json carries SessionState.remote.
|
||||
remote: muxSession.remote ?? savedState?.remote,
|
||||
});
|
||||
|
||||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
||||
|
||||
@@ -57,7 +57,8 @@ describe('remote-hosts domain', () => {
|
||||
it('returns safe mode defaults and remote display values', () => {
|
||||
expect(defaultRemoteCommandForMode('shell')).toBe('exec bash -l');
|
||||
expect(defaultRemoteCommandForMode('codex')).toBe('exec codex');
|
||||
expect(defaultRemoteCommandForMode('claude')).toBe('exec claude');
|
||||
// Mirrors the local claude default so the remote agent runs non-interactively.
|
||||
expect(defaultRemoteCommandForMode('claude')).toBe('exec claude --dangerously-skip-permissions');
|
||||
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(
|
||||
'aamer@box.local:/opt/work'
|
||||
|
||||
@@ -51,8 +51,8 @@ const aaDesktop: SessionRemote = {
|
||||
const SESSION_ID = 'cod107chk';
|
||||
|
||||
describe('COD-107 buildSshConnectionArgs — shared ssh connection tokens', () => {
|
||||
it('always leads with -o BatchMode=yes', () => {
|
||||
expect(buildSshConnectionArgs(baseRemote)).toEqual(['ssh', '-o BatchMode=yes']);
|
||||
it('always leads with -o BatchMode=yes then the default -o ConnectTimeout=10', () => {
|
||||
expect(buildSshConnectionArgs(baseRemote)).toEqual(['ssh', '-o BatchMode=yes', '-o ConnectTimeout=10']);
|
||||
});
|
||||
|
||||
it('emits the full aa-desktop option set in order with escaping + %h %p intact', () => {
|
||||
@@ -107,10 +107,19 @@ describe('COD-107 buildSshConnectionArgs — shared ssh connection tokens', () =
|
||||
expect(args.join(' ')).toContain(`-i '${HOME}/.ssh/id_ed25519'`);
|
||||
});
|
||||
|
||||
it('empty options ⇒ exactly the historical token set (back-compat)', () => {
|
||||
// Today: ssh -o BatchMode=yes (+ -p only when set). Nothing else.
|
||||
expect(buildSshConnectionArgs(baseRemote)).toEqual(['ssh', '-o BatchMode=yes']);
|
||||
expect(buildSshConnectionArgs({ ...baseRemote, port: 2200 })).toEqual(['ssh', '-o BatchMode=yes', '-p 2200']);
|
||||
it('empty options ⇒ BatchMode + the default ConnectTimeout (+ -p only when set)', () => {
|
||||
expect(buildSshConnectionArgs(baseRemote)).toEqual(['ssh', '-o BatchMode=yes', '-o ConnectTimeout=10']);
|
||||
expect(buildSshConnectionArgs({ ...baseRemote, port: 2200 })).toEqual([
|
||||
'ssh',
|
||||
'-o BatchMode=yes',
|
||||
'-o ConnectTimeout=10',
|
||||
'-p 2200',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the default ConnectTimeout when extraSshOptions already sets it (operator wins)', () => {
|
||||
const args = buildSshConnectionArgs({ ...baseRemote, extraSshOptions: ['ConnectTimeout=3'] });
|
||||
expect(args.filter((a) => a.includes('ConnectTimeout'))).toEqual(["-o 'ConnectTimeout=3'"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,7 +133,7 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
||||
expect(command).toContain("-o 'StrictHostKeyChecking=accept-new'");
|
||||
expect(command).toContain('-t');
|
||||
expect(command).toContain('aakht@192.168.55.170');
|
||||
expect(command).toContain('tmux -L codeman new-session -A');
|
||||
expect(command).toContain('tmux -L codeman-remote new-session -A');
|
||||
|
||||
// Connection options come BEFORE -t / the target / the tmux command.
|
||||
const idxProxy = command.indexOf('ProxyCommand=');
|
||||
@@ -132,32 +141,35 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
||||
expect(idxProxy).toBeLessThan(idxTarget);
|
||||
});
|
||||
|
||||
it('back-compat: a remote with no advanced options is byte-identical to the historical form', () => {
|
||||
it('a remote with no advanced options is byte-identical to the expected form', () => {
|
||||
const command = buildRemoteLaunchCommand({ mode: 'shell', remote: baseRemote, sessionId: SESSION_ID });
|
||||
// Reconstruct the historical command using the SAME nested POSIX
|
||||
// single-quote escaping the production code uses, to prove byte-identity.
|
||||
// Reconstruct the command using the SAME nested POSIX single-quote escaping the
|
||||
// production code uses, to prove byte-identity. Session runs on the DEDICATED
|
||||
// `-L codeman-remote` socket under a `codeman-ssh-` name that a remote Codeman's
|
||||
// discovery ignores; set-options are scoped per-session (never `-g`).
|
||||
const sh = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
const remoteName = `codeman-${SESSION_ID.slice(0, 8)}`;
|
||||
const remoteName = `codeman-ssh-${SESSION_ID.slice(0, 8)}`;
|
||||
const path = sh('/home/ubuntu/work');
|
||||
const paneCommand = `cd ${path} && exec bash -l`;
|
||||
const tmuxInvocation = [
|
||||
`tmux -L codeman new-session -A -s ${remoteName} -c ${path} ${sh(paneCommand)}`,
|
||||
'set -g status off',
|
||||
'set -g mouse off',
|
||||
'set -sg escape-time 0',
|
||||
'set -g prefix C-q',
|
||||
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} ${sh(paneCommand)}`,
|
||||
`set -t ${remoteName} status off`,
|
||||
`set -t ${remoteName} mouse off`,
|
||||
`set -t ${remoteName} prefix C-q`,
|
||||
'set -s escape-time 0',
|
||||
].join(' \\; ');
|
||||
const expected = `ssh -o BatchMode=yes -t ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`;
|
||||
// Connection args (with the default -o ConnectTimeout=10) sit after -t.
|
||||
const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`;
|
||||
expect(command).toBe(expected);
|
||||
});
|
||||
|
||||
it('back-compat: port-only remote matches the historical -p placement', () => {
|
||||
it('port-only remote places -p after the -t/ConnectTimeout tokens', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'shell',
|
||||
remote: { ...baseRemote, port: 2222 },
|
||||
sessionId: SESSION_ID,
|
||||
});
|
||||
expect(command).toMatch(/^ssh -o BatchMode=yes -t -p 2222 ubuntu@10\.0\.0\.42 /);
|
||||
expect(command).toMatch(/^ssh -o BatchMode=yes -t -o ConnectTimeout=10 -p 2222 ubuntu@10\.0\.0\.42 /);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -53,9 +53,20 @@ vi.mock('../../src/hooks-config.js', () => ({
|
||||
writeHooksConfig: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
// Stub the remote-tmux prereq probe so remote-link tests never shell out to ssh
|
||||
// (readRemoteHosts/writeRemoteHosts stay real, backed by the mocked fs).
|
||||
vi.mock('../../src/remote-hosts.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/remote-hosts.js')>();
|
||||
return {
|
||||
...actual,
|
||||
checkRemoteTmuxAvailable: vi.fn(async () => ({ ok: true, tmuxPath: '/usr/bin/tmux' })),
|
||||
};
|
||||
});
|
||||
|
||||
// Import mocked modules for test control
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { checkRemoteTmuxAvailable } from '../../src/remote-hosts.js';
|
||||
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedMkdirSync = vi.mocked(mkdirSync);
|
||||
@@ -63,6 +74,7 @@ const mockedReaddirSync = vi.mocked(readdirSync);
|
||||
const mockedReaddir = vi.mocked(fs.readdir);
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
const mockedWriteFile = vi.mocked(fs.writeFile);
|
||||
const mockedCheckRemoteTmux = vi.mocked(checkRemoteTmuxAvailable);
|
||||
|
||||
interface CaseRouteHarness {
|
||||
app: FastifyInstance;
|
||||
@@ -375,6 +387,101 @@ describe('case-routes', () => {
|
||||
expect(deleted.statusCode).toBe(200);
|
||||
expect(JSON.parse(deleted.body)).toEqual({ success: true, data: { name: 'gpu-work' } });
|
||||
});
|
||||
|
||||
// Injection hardening: remotePath/identityFile are shell-escaped, then embedded
|
||||
// via JSON.stringify() inside `bash -c "..."` — a DOUBLE-quote layer that
|
||||
// re-exposes `$(...)`/backticks even inside the inner single quotes. The schema
|
||||
// MUST reject those before they reach the launch command.
|
||||
it('rejects an identityFile containing $(...) command substitution', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'evil-host',
|
||||
label: 'evil',
|
||||
host: '10.0.0.9',
|
||||
username: 'ubuntu',
|
||||
identityFile: '/home/u/$(touch /tmp/pwned)',
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects an identityFile containing a backtick', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'evil-host2',
|
||||
label: 'evil2',
|
||||
host: '10.0.0.9',
|
||||
username: 'ubuntu',
|
||||
identityFile: '/home/u/`touch /tmp/pwned`',
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a remotePath containing $(...) command substitution', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/tmp/$(touch /tmp/pwned)' },
|
||||
});
|
||||
expect(link.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(link.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a remotePath containing a backtick', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/tmp/`touch /tmp/pwned`' },
|
||||
});
|
||||
expect(link.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(link.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('refuses remote-link when the remote host lacks tmux (courtesy prereq probe)', async () => {
|
||||
setupRemoteConfigStore();
|
||||
mockedCheckRemoteTmux.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: 'remote host 10.0.0.42 needs tmux installed for durable remote sessions',
|
||||
});
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
});
|
||||
expect(link.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
|
||||
expect(JSON.parse(link.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.OPERATION_FAILED });
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/cases ==========
|
||||
|
||||
@@ -29,13 +29,19 @@ vi.mock('node:child_process', async (orig) => {
|
||||
});
|
||||
|
||||
// In-memory remote store so remote-case tests can inject hosts/cases without real JSON files.
|
||||
const remoteStore = vi.hoisted(() => ({ hosts: [] as unknown[], cases: [] as unknown[] }));
|
||||
const remoteStore = vi.hoisted(() => ({
|
||||
hosts: [] as unknown[],
|
||||
cases: [] as unknown[],
|
||||
tmuxCheck: { ok: true, tmuxPath: '/usr/bin/tmux' } as { ok: boolean; tmuxPath?: string; error?: string },
|
||||
}));
|
||||
vi.mock('../../src/remote-hosts.js', async (orig) => {
|
||||
const actual = await orig<typeof import('../../src/remote-hosts.js')>();
|
||||
return {
|
||||
...actual,
|
||||
readRemoteHosts: vi.fn(async () => remoteStore.hosts),
|
||||
readRemoteCases: vi.fn(async () => remoteStore.cases),
|
||||
// Stub the remote-tmux prereq probe so quick-start never shells out to ssh.
|
||||
checkRemoteTmuxAvailable: vi.fn(async () => remoteStore.tmuxCheck),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -90,9 +96,10 @@ describe('session-routes', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness(registerSessionRoutes);
|
||||
// Reset remote store so tests start with empty hosts/cases
|
||||
// Reset remote store so tests start with empty hosts/cases and a passing tmux probe
|
||||
remoteStore.hosts = [];
|
||||
remoteStore.cases = [];
|
||||
remoteStore.tmuxCheck = { ok: true, tmuxPath: '/usr/bin/tmux' };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -861,6 +868,61 @@ describe('session-routes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a remote quick-start that carries envOverrides (inert over ssh)', async () => {
|
||||
remoteStore.hosts = [{ id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' }];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'claude', envOverrides: { CLAUDE_CODE_FOO: 'bar' } },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a remote quick-start when the remote host lacks tmux', async () => {
|
||||
remoteStore.hosts = [{ id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' }];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
remoteStore.tmuxCheck = {
|
||||
ok: false,
|
||||
error: 'remote host 10.0.0.42 needs tmux installed for durable remote sessions',
|
||||
};
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'shell' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.OPERATION_FAILED });
|
||||
});
|
||||
|
||||
it('does not run local codex availability check for a remote codex case', async () => {
|
||||
// A remote codex case must NOT be blocked by the LOCAL codex availability gate
|
||||
// (the CLI runs on the remote host). Probe is stubbed ok in remoteStore.tmuxCheck.
|
||||
const startInteractive = vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [
|
||||
{ id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', commands: { codex: 'exec codx' } },
|
||||
];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'codex' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
} finally {
|
||||
startInteractive.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('creates session with valid resumeSessionId', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -174,4 +174,24 @@ describe('session attachment history', () => {
|
||||
).toBe('/tmp');
|
||||
expect(resolveMuxAttachCwd('/opt/projects/Codeman')).toBe('/opt/projects/Codeman');
|
||||
});
|
||||
|
||||
it('round-trips remote metadata through the Session constructor (restart-recovery contract)', () => {
|
||||
// restoreMuxSessions() reconstructs recovered sessions via
|
||||
// `new Session({ ..., remote: muxSession.remote ?? savedState.remote })`. If that
|
||||
// remote does not survive toState(), the next persistSessionState() erases it from
|
||||
// state.json AND the attach cwd falls back to the (nonexistent-locally) remote path.
|
||||
const remote = {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
commands: { claude: 'exec claude --dangerously-skip-permissions' },
|
||||
};
|
||||
const restored = new Session({ workingDir: '/home/ubuntu/work', remote });
|
||||
const state = restored.toState();
|
||||
expect(state.remote).toEqual(remote);
|
||||
// Recovered attach cwd must be a LOCAL path, never the remote-only workingDir.
|
||||
expect(resolveMuxAttachCwd(state.workingDir, state.remote)).toBe('/tmp');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
TmuxManager,
|
||||
buildRemoteKillCommand,
|
||||
buildRemoteLaunchCommand,
|
||||
formatPaneSnapshot,
|
||||
parsePaneList,
|
||||
@@ -116,8 +117,11 @@ describe('TmuxManager (unit)', () => {
|
||||
expect(command).toContain('BatchMode=yes');
|
||||
expect(command).toContain('ubuntu@10.0.0.42');
|
||||
expect(command).toContain('/home/ubuntu/work');
|
||||
expect(command).toContain('tmux -L codeman new-session -A');
|
||||
// Dedicated socket + a name that fails a remote Codeman's SAFE_MUX_NAME_PATTERN.
|
||||
expect(command).toContain('tmux -L codeman-remote new-session -A -s codeman-ssh-abc123de');
|
||||
expect(command).toContain('exec codx personal');
|
||||
// Session options are scoped per-session, never global (-g).
|
||||
expect(command).not.toContain('set -g');
|
||||
});
|
||||
|
||||
it('uses default shell command when no override is configured', () => {
|
||||
@@ -135,6 +139,37 @@ describe('TmuxManager (unit)', () => {
|
||||
|
||||
expect(command).toContain('exec bash -l');
|
||||
});
|
||||
|
||||
it('defaults claude to a non-interactive launch (--dangerously-skip-permissions)', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'claude',
|
||||
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
expect(command).toContain('exec claude --dangerously-skip-permissions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote kill command builder', () => {
|
||||
it('kills the durable remote tmux session on the dedicated socket via ssh', () => {
|
||||
const command = buildRemoteKillCommand({
|
||||
remote: {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
},
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
|
||||
expect(command).toContain('ssh');
|
||||
// Shares the default ConnectTimeout so an unreachable host fails fast (never blocks kill).
|
||||
expect(command).toContain('-o ConnectTimeout=10');
|
||||
expect(command).toContain('ubuntu@10.0.0.42');
|
||||
expect(command).toContain('tmux -L codeman-remote kill-session -t');
|
||||
expect(command).toContain('codeman-ssh-abc123de');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAttachCommand', () => {
|
||||
|
||||
@@ -66,7 +66,14 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: 'Recovery Test',
|
||||
respawnConfig: { enabled: true, idleTimeoutMs: 10000, updatePrompt: 'continue', interStepDelayMs: 2000, sendClear: false, sendInit: true },
|
||||
respawnConfig: {
|
||||
enabled: true,
|
||||
idleTimeoutMs: 10000,
|
||||
updatePrompt: 'continue',
|
||||
interStepDelayMs: 2000,
|
||||
sendClear: false,
|
||||
sendInit: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.reconcileSessions();
|
||||
@@ -86,6 +93,34 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
expect(result.discovered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('preserves remote SSH metadata across reconcile (mux-sessions.json round-trip source)', async () => {
|
||||
manager.registerSession({
|
||||
sessionId: 'remote-recovery-1',
|
||||
muxName: 'codeman-de51ecaf',
|
||||
pid: 1,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/ubuntu/work',
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: 'Remote Recovery',
|
||||
remote: {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.reconcileSessions();
|
||||
expect(result.alive).toContain('remote-recovery-1');
|
||||
|
||||
// restoreMuxSessions() reads MuxSession.remote off exactly this map to rebuild
|
||||
// the recovered Session — if it were dropped here the session would respawn LOCAL.
|
||||
const recovered = manager.getSession('remote-recovery-1');
|
||||
expect(recovered?.remote).toMatchObject({ hostId: 'gpu-box', host: '10.0.0.42', remotePath: '/home/ubuntu/work' });
|
||||
});
|
||||
|
||||
it('should not execute any tmux commands in test mode', async () => {
|
||||
manager.registerSession({
|
||||
sessionId: 'alive-session',
|
||||
@@ -101,9 +136,7 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
await manager.reconcileSessions();
|
||||
|
||||
// Verify no tmux commands were executed
|
||||
const tmuxCalls = mockedExecSync.mock.calls.filter(
|
||||
([cmd]) => typeof cmd === 'string' && cmd.includes('tmux')
|
||||
);
|
||||
const tmuxCalls = mockedExecSync.mock.calls.filter(([cmd]) => typeof cmd === 'string' && cmd.includes('tmux'));
|
||||
expect(tmuxCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -155,9 +188,7 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
expect(manager.getSession('kill-me')).toBeUndefined();
|
||||
|
||||
// Verify no real kill commands were executed
|
||||
const killCalls = mockedExecSync.mock.calls.filter(
|
||||
([cmd]) => typeof cmd === 'string' && cmd.includes('kill')
|
||||
);
|
||||
const killCalls = mockedExecSync.mock.calls.filter(([cmd]) => typeof cmd === 'string' && cmd.includes('kill'));
|
||||
expect(killCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user