Merge PR #145 from aakhter/cod-94-remote-host-ssh: remote host SSH cases

Includes review fixes: reachable Remote tab UI, remote metadata restore on recovery, quick-start routing for remote run flows, ssh-arg injection guards, dedicated remote socket/name (no cross-instance adoption), remote tmux kill on delete, wired tmux probe + ConnectTimeout, --dangerously-skip-permissions default.
This commit is contained in:
Codeman maintainer
2026-07-12 20:01:47 +02:00
19 changed files with 1921 additions and 102 deletions
+67
View File
@@ -0,0 +1,67 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
defaultRemoteCommandForMode,
readRemoteCases,
readRemoteHosts,
remoteDisplayPath,
remoteSshTarget,
writeRemoteCases,
writeRemoteHosts,
} from '../src/remote-hosts.js';
describe('remote-hosts domain', () => {
let dir: string | null = null;
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
dir = null;
});
function configDir(): string {
dir = mkdtempSync(join(tmpdir(), 'codeman-remote-hosts-'));
return dir;
}
it('round-trips remote hosts and remote cases from a config directory', async () => {
const root = configDir();
await writeRemoteHosts(root, [
{
id: 'gpu-box',
label: 'GPU Box',
host: '10.0.0.42',
username: 'ubuntu',
commands: { codex: 'exec codx personal' },
},
]);
await writeRemoteCases(root, [
{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
]);
await expect(readRemoteHosts(root)).resolves.toEqual([
{
id: 'gpu-box',
label: 'GPU Box',
host: '10.0.0.42',
username: 'ubuntu',
commands: { codex: 'exec codx personal' },
},
]);
await expect(readRemoteCases(root)).resolves.toEqual([
{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
]);
});
it('returns safe mode defaults and remote display values', () => {
expect(defaultRemoteCommandForMode('shell')).toBe('exec bash -l');
expect(defaultRemoteCommandForMode('codex')).toBe('exec codex');
// 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'
);
});
});
+194
View File
@@ -0,0 +1,194 @@
/**
* @fileoverview COD-107 — Remote-host SSH: custom port + advanced connection options.
*
* Unit tests for the shared, pure `buildSshConnectionArgs(remote)` and its two
* consumers (`buildRemoteLaunchCommand`, `buildRemoteTmuxCheckCommand`). The
* acceptance target is the aa-desktop option set (custom port 2222, ed25519
* identity under `~`, a cloudflared SOCKS5 ProxyCommand, plus an arbitrary
* `-o` escape-hatch option) — the same connection `~/repos/claude-config/bin/
* ssh-aa-desktop` makes, WITHOUT shelling out to that wrapper.
*
* Critical, easy-to-break invariants pinned here:
* - `%h %p` in the ProxyCommand reach ssh LITERALLY (one shellescaped
* `-o ProxyCommand=…` token; the local shell must not expand/mangle them).
* - a leading `~`/`$HOME` in `identityFile` is expanded to an absolute path at
* build time (ssh does NOT expand `~` in `-i`), then shellescaped.
* - empty options ⇒ byte-identical ssh to today (full back-compat).
*
* Pure command-string construction; no real tmux, no ssh. Port: N/A.
*/
import { homedir } from 'node:os';
import { describe, it, expect } from 'vitest';
import { buildSshConnectionArgs, buildRemoteTmuxCheckCommand, remoteSshTarget } from '../src/remote-hosts.js';
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
import type { SessionRemote } from '../src/types.js';
const HOME = homedir();
const baseRemote: SessionRemote = {
hostId: 'gpu-box',
label: 'GPU Box',
host: '10.0.0.42',
username: 'ubuntu',
remotePath: '/home/ubuntu/work',
};
// The acceptance host: aa-desktop reached over the cloudflared SOCKS5 proxy.
const aaDesktop: SessionRemote = {
hostId: 'aa-desktop',
label: 'aa-desktop',
host: '192.168.55.170',
username: 'aakht',
port: 2222,
remotePath: '/tmp',
identityFile: '~/.ssh/remote_ed25519',
socksProxy: '127.0.0.1:1080',
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
commands: { shell: 'exec bash -l' },
};
const SESSION_ID = 'cod107chk';
describe('COD-107 buildSshConnectionArgs — shared ssh connection tokens', () => {
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', () => {
const args = buildSshConnectionArgs(aaDesktop);
const joined = args.join(' ');
// -p before -i before the proxy -o; identity ~ expanded absolute, then escaped.
expect(joined).toContain('-o BatchMode=yes');
expect(joined).toContain('-p 2222');
expect(joined).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
// No literal tilde survives into the -i token.
expect(joined).not.toContain('-i ~');
expect(joined).not.toContain("-i '~");
// The whole ProxyCommand (with its spaces and %h %p) is ONE shellescaped -o token.
expect(joined).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
// %h %p must survive verbatim — they are ssh tokens, not shell tokens.
expect(joined).toContain('%h %p');
// The escape-hatch extra option, shellescaped.
expect(joined).toContain("-o 'StrictHostKeyChecking=accept-new'");
// Ordering: BatchMode -> port -> identity -> ProxyCommand -> extras.
const idxBatch = joined.indexOf('BatchMode=yes');
const idxPort = joined.indexOf('-p 2222');
const idxIdentity = joined.indexOf('-i ');
const idxProxy = joined.indexOf('ProxyCommand=');
const idxExtra = joined.indexOf('StrictHostKeyChecking');
expect(idxBatch).toBeLessThan(idxPort);
expect(idxPort).toBeLessThan(idxIdentity);
expect(idxIdentity).toBeLessThan(idxProxy);
expect(idxProxy).toBeLessThan(idxExtra);
});
it('supports an explicit -J jump host (shellescaped, like its siblings)', () => {
const args = buildSshConnectionArgs({ ...baseRemote, jumpHost: 'bastion@10.0.0.1:22' });
expect(args.join(' ')).toContain("-J 'bastion@10.0.0.1:22'");
});
it('shellescapes a -J jump host containing shell metacharacters (no injection)', () => {
// Defense-in-depth: even if a metachar-laden value slipped past schema validation,
// it must stay a single shell token and never break out of the ssh command.
const args = buildSshConnectionArgs({ ...baseRemote, jumpHost: 'x; touch /tmp/pwned' });
const joined = args.join(' ');
// The whole value is wrapped in single quotes — the `;` cannot start a new command.
expect(joined).toContain("-J 'x; touch /tmp/pwned'");
expect(joined).not.toContain('-J x;');
});
it('expands a $HOME-prefixed identity path', () => {
const args = buildSshConnectionArgs({ ...baseRemote, identityFile: '$HOME/.ssh/id_ed25519' });
expect(args.join(' ')).toContain(`-i '${HOME}/.ssh/id_ed25519'`);
});
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'"]);
});
});
describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
it('emits the aa-desktop ssh connection options ahead of -t and the target', () => {
const command = buildRemoteLaunchCommand({ mode: 'shell', remote: aaDesktop, sessionId: SESSION_ID });
expect(command).toContain('-p 2222');
expect(command).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
expect(command).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
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-remote new-session -A');
// Connection options come BEFORE -t / the target / the tmux command.
const idxProxy = command.indexOf('ProxyCommand=');
const idxTarget = command.indexOf('aakht@192.168.55.170');
expect(idxProxy).toBeLessThan(idxTarget);
});
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 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-ssh-${SESSION_ID.slice(0, 8)}`;
const path = sh('/home/ubuntu/work');
const paneCommand = `cd ${path} && exec bash -l`;
const tmuxInvocation = [
`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(' \\; ');
// 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('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 -o ConnectTimeout=10 -p 2222 ubuntu@10\.0\.0\.42 /);
});
});
describe('COD-107 buildRemoteTmuxCheckCommand — same connection options as the launch', () => {
it('uses the shared connection args (proxy/identity/port) plus ConnectTimeout', () => {
const cmd = buildRemoteTmuxCheckCommand(aaDesktop);
expect(cmd).toContain('-o BatchMode=yes');
expect(cmd).toContain('-o ConnectTimeout=10');
expect(cmd).toContain('-p 2222');
expect(cmd).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
expect(cmd).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
expect(cmd).toContain('aakht@192.168.55.170');
expect(cmd).toContain("'command -v tmux'");
});
it('back-compat: no advanced options ⇒ unchanged probe string', () => {
expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42' })).toBe(
"ssh -o BatchMode=yes -o ConnectTimeout=10 ubuntu@10.0.0.42 'command -v tmux'"
);
expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42', port: 2222 })).toContain('-p 2222');
});
});
+285
View File
@@ -53,15 +53,28 @@ 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);
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;
@@ -199,6 +212,278 @@ describe('case-routes', () => {
});
});
describe('remote host and remote case routes', () => {
function setupRemoteConfigStore() {
const store = new Map<string, string>();
mockedReadFile.mockImplementation(async (path) => {
const key = String(path);
if (store.has(key)) return store.get(key) || '';
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
mockedWriteFile.mockImplementation(async (path, data) => {
store.set(String(path), String(data));
});
}
it('creates a remote host and lists it', async () => {
setupRemoteConfigStore();
const create = await harness.app.inject({
method: 'POST',
url: '/api/remote-hosts',
payload: {
id: 'gpu-box',
label: 'GPU Box',
host: '10.0.0.42',
username: 'ubuntu',
commands: { codex: 'exec codx personal' },
},
});
expect(create.statusCode).toBe(200);
expect(JSON.parse(create.body)).toMatchObject({ success: true });
const list = await harness.app.inject({ method: 'GET', url: '/api/remote-hosts' });
expect(list.statusCode).toBe(200);
expect(JSON.parse(list.body).data).toEqual([
expect.objectContaining({ id: 'gpu-box', label: 'GPU Box', commands: { codex: 'exec codx personal' } }),
]);
});
// COD-107 — advanced SSH connection options (port, identity, SOCKS proxy,
// jump host, escape-hatch -o options) round-trip through the host schema.
it('persists advanced SSH options (port/identity/socks/jump/extra) on a remote host', async () => {
setupRemoteConfigStore();
const create = await harness.app.inject({
method: 'POST',
url: '/api/remote-hosts',
payload: {
id: 'aa-desktop',
label: 'aa-desktop',
host: '192.168.55.170',
username: 'aakht',
port: 2222,
identityFile: '~/.ssh/remote_ed25519',
socksProxy: '127.0.0.1:1080',
jumpHost: 'bastion@10.0.0.1:22',
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
},
});
expect(create.statusCode).toBe(200);
expect(JSON.parse(create.body)).toMatchObject({ success: true });
const list = await harness.app.inject({ method: 'GET', url: '/api/remote-hosts' });
expect(JSON.parse(list.body).data).toEqual([
expect.objectContaining({
id: 'aa-desktop',
port: 2222,
identityFile: '~/.ssh/remote_ed25519',
socksProxy: '127.0.0.1:1080',
jumpHost: 'bastion@10.0.0.1:22',
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
}),
]);
});
it('rejects a malformed extraSshOptions entry (not KEY=VALUE) with INVALID_INPUT', async () => {
setupRemoteConfigStore();
const create = await harness.app.inject({
method: 'POST',
url: '/api/remote-hosts',
payload: {
id: 'bad-host',
label: 'bad',
host: '10.0.0.9',
username: 'ubuntu',
extraSshOptions: ['not a valid option'],
},
});
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
expect(JSON.parse(create.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
});
it('rejects a malformed socksProxy (missing port) with INVALID_INPUT', async () => {
setupRemoteConfigStore();
const create = await harness.app.inject({
method: 'POST',
url: '/api/remote-hosts',
payload: { id: 'bad2', label: 'bad2', host: '10.0.0.9', username: 'ubuntu', socksProxy: '127.0.0.1' },
});
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
expect(JSON.parse(create.body)).toMatchObject({ success: false });
});
it('links a remote case and includes it in GET /api/cases', async () => {
setupRemoteConfigStore();
mockedReaddir.mockRejectedValue(new Error('ENOENT'));
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(200);
const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' });
expect(JSON.parse(cases.body).data).toContainEqual(
expect.objectContaining({
name: 'gpu-work',
location: 'remote',
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
remote: expect.objectContaining({ hostId: 'gpu-box', path: '/home/ubuntu/work' }),
})
);
});
it('prefers remote case metadata over a same-name local managed case', async () => {
setupRemoteConfigStore();
mockedReaddir.mockResolvedValue([{ name: 'gpu-work', isDirectory: () => true }] as never);
await harness.app.inject({
method: 'POST',
url: '/api/remote-hosts',
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
});
await harness.app.inject({
method: 'POST',
url: '/api/cases/remote-link',
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
});
mockedExistsSync.mockReturnValue(true);
const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' });
expect(JSON.parse(cases.body).data).toContainEqual(
expect.objectContaining({
name: 'gpu-work',
location: 'remote',
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
})
);
});
it('deletes remote case metadata only', 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' },
});
await harness.app.inject({
method: 'POST',
url: '/api/cases/remote-link',
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
});
const deleted = await harness.app.inject({ method: 'DELETE', url: '/api/cases/gpu-work' });
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 ==========
describe('POST /api/cases', () => {
+158
View File
@@ -19,6 +19,7 @@ import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { Session } from '../../src/session.js';
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
@@ -27,6 +28,23 @@ vi.mock('node:child_process', async (orig) => {
return { ...actual, execFile };
});
// 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[],
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),
};
});
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface LocalHarness {
@@ -78,6 +96,10 @@ describe('session-routes', () => {
beforeEach(async () => {
harness = await createEnvelopeHarness(registerSessionRoutes);
// 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 () => {
@@ -765,6 +787,142 @@ describe('session-routes', () => {
// ========== POST /api/sessions (with resumeSessionId) ==========
describe('POST /api/sessions with resumeSessionId', () => {
it('creates session from a remote case without local stat validation', async () => {
// Remote cases go through /api/quick-start which skips local stat() of the workingDir.
// /api/sessions always requires workingDir to exist on the local filesystem.
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
try {
remoteStore.hosts = [
{
id: 'gpu-box',
label: 'GPU Box',
host: '10.0.0.42',
username: 'ubuntu',
commands: { codex: 'exec codx personal' },
},
];
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: 'shell', name: 'Remote Shell' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.casePath).toBe('/home/ubuntu/work');
const session = [...harness.ctx.sessions.values()].find((item) => item.id === body.data.sessionId);
expect(session?.toState()).toMatchObject({
workingDir: '/home/ubuntu/work',
remote: expect.objectContaining({
hostId: 'gpu-box',
host: '10.0.0.42',
username: 'ubuntu',
remotePath: '/home/ubuntu/work',
commands: { codex: 'exec codx personal' },
}),
});
} finally {
startShell.mockRestore();
}
});
it('quick-start creates remote case sessions through ssh metadata', async () => {
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
try {
remoteStore.hosts = [
{
id: 'gpu-box',
label: 'GPU Box',
host: '10.0.0.42',
username: 'ubuntu',
commands: { codex: 'exec codx personal' },
},
];
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: 'shell' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.casePath).toBe('/home/ubuntu/work');
const session = [...harness.ctx.sessions.values()].find((item) => item.id === body.data.sessionId);
expect(session?.toState()).toMatchObject({
workingDir: '/home/ubuntu/work',
remote: expect.objectContaining({
hostId: 'gpu-box',
host: '10.0.0.42',
username: 'ubuntu',
remotePath: '/home/ubuntu/work',
}),
});
} finally {
startShell.mockRestore();
}
});
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',
+34 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
import { resolveMuxAttachCwd, Session } from '../src/session.js';
import type { SessionAttachmentHistoryItem } from '../src/types/session.js';
import {
ATTACHMENT_HISTORY_LIMIT,
@@ -161,4 +161,37 @@ describe('session attachment history', () => {
expect(persisted).toHaveLength(1);
expect(persisted?.[0].fileName).toBe('ok.png');
});
it('attaches remote mux sessions from a local cwd', () => {
expect(
resolveMuxAttachCwd('/Users/remote/project', {
hostId: 'mac-mini',
label: 'Mac Mini',
host: '192.168.21.109',
username: 'saqebakhter',
remotePath: '/Users/remote/project',
})
).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');
});
});
+82 -1
View File
@@ -8,7 +8,14 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { TmuxManager, formatPaneSnapshot, parsePaneList, resolveActivePaneTarget } from '../src/tmux-manager.js';
import {
TmuxManager,
buildRemoteKillCommand,
buildRemoteLaunchCommand,
formatPaneSnapshot,
parsePaneList,
resolveActivePaneTarget,
} from '../src/tmux-manager.js';
import { execSync, exec } from 'node:child_process';
// ============================================================================
@@ -91,6 +98,80 @@ describe('TmuxManager (unit)', () => {
});
});
describe('remote launch command builder', () => {
it('wraps codex command overrides in ssh with remote tmux launch', () => {
const command = buildRemoteLaunchCommand({
mode: 'codex',
remote: {
hostId: 'gpu-box',
label: 'GPU Box',
host: '10.0.0.42',
username: 'ubuntu',
remotePath: '/home/ubuntu/work',
commands: { codex: 'exec codx personal' },
},
sessionId: 'abc123def456',
});
expect(command).toContain('ssh');
expect(command).toContain('BatchMode=yes');
expect(command).toContain('ubuntu@10.0.0.42');
expect(command).toContain('/home/ubuntu/work');
// 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', () => {
const command = buildRemoteLaunchCommand({
mode: 'shell',
remote: {
hostId: 'gpu-box',
label: 'GPU Box',
host: '10.0.0.42',
username: 'ubuntu',
remotePath: '/home/ubuntu/work',
},
sessionId: 'abc123def456',
});
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', () => {
it('should return tmux', () => {
expect(manager.getAttachCommand()).toBe('tmux');
+38 -7
View File
@@ -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);
});
});