Files
Codeman/src/remote-hosts.ts
T
Codeman maintainer 26cbbe0dcb feat(cli): Antigravity run mode
Adds Antigravity as a sixth CLI backend alongside Claude Code, shell, OpenCode,
Codex and Gemini, following the existing pluggable-resolver pattern.

- `utils/antigravity-cli-resolver.ts` resolves the CLI, mirroring the other
  resolvers; `GET /api/antigravity/status` reports availability and path.
- `ANTIGRAVITY_*` joins the `ALLOWED_ENV_PREFIXES` allowlist in schemas.ts, so
  env overrides stay CLI-scoped rather than blanket-forwarded.
- Session, tmux-manager, mux-interface and types carry the new mode; secrets are
  injected via socket-scoped `tmux setenv`, never on the spawn command line, so
  the mode requires tmux with no direct PTY fallback like the other external CLIs.
- Frontend: Run-dropdown entry, agent-type option, `ag` tab badge and toolbar
  colours. `runAntigravity()` routes remote/docker cases through
  `POST /api/quick-start` and skips the local status probe for them.

Tests: test/antigravity-mode.test.ts, plus run-mode-ui and system-routes coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:59:40 +02:00

381 lines
16 KiB
TypeScript

import { existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import type {
RemoteCase,
RemoteCommandMode,
RemoteHost,
RemoteSessionInfo,
RemoteSshOptions,
SessionMode,
SessionRemote,
} from './types.js';
const execAsync = promisify(exec);
const REMOTE_HOSTS_FILE = 'remote-hosts.json';
const REMOTE_CASES_FILE = 'remote-cases.json';
export function remoteHostsPath(configDir: string): string {
return join(configDir, REMOTE_HOSTS_FILE);
}
export function remoteCasesPath(configDir: string): string {
return join(configDir, REMOTE_CASES_FILE);
}
async function readJsonArray<T>(path: string): Promise<T[]> {
try {
const raw = await fs.readFile(path, 'utf-8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as T[]) : [];
} catch {
return [];
}
}
async function writeJsonArray<T>(configDir: string, path: string, value: T[]): Promise<void> {
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
await fs.writeFile(path, JSON.stringify(value, null, 2));
}
export async function readRemoteHosts(configDir: string): Promise<RemoteHost[]> {
return readJsonArray<RemoteHost>(remoteHostsPath(configDir));
}
export async function writeRemoteHosts(configDir: string, hosts: RemoteHost[]): Promise<void> {
await writeJsonArray(configDir, remoteHostsPath(configDir), hosts);
}
export async function readRemoteCases(configDir: string): Promise<RemoteCase[]> {
return readJsonArray<RemoteCase>(remoteCasesPath(configDir));
}
export async function writeRemoteCases(configDir: string, cases: RemoteCase[]): Promise<void> {
await writeJsonArray(configDir, remoteCasesPath(configDir), cases);
}
export function defaultRemoteCommandForMode(mode: SessionMode): string {
const commands: Record<RemoteCommandMode, string> = {
shell: 'exec bash -l',
// Mirror the LOCAL claude default so the remote agent runs non-interactively
// (no trust-folder/permission prompt that nothing on the remote answers). The
// per-host `commands.claude` override stays the escape hatch.
claude: 'exec claude --dangerously-skip-permissions',
opencode: 'exec opencode',
codex: 'exec codex',
gemini: 'exec gemini',
antigravity: 'exec agy',
};
return commands[mode as RemoteCommandMode] || commands.shell;
}
export function remoteSshTarget(host: Pick<RemoteHost, 'username' | 'host'>): string {
return `${host.username}@${host.host}`;
}
/**
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
* Mirrors the helper in tmux-manager.ts so a value with spaces/metachars stays a
* single shell token. Used here for identity paths and `-o KEY=VALUE` options.
*/
function shellescape(str: string): string {
return "'" + str.replace(/'/g, "'\\''") + "'";
}
/**
* Expand a leading `~` or `$HOME` in an identity path to an absolute path.
*
* ssh does NOT expand `~` inside `-i` (the shell would, but we shellescape the
* value into a single quoted token so the shell never sees it). So we expand at
* build time, before escaping. Non-`~`/`$HOME` paths are returned unchanged.
*/
function expandIdentityPath(identityFile: string): string {
if (identityFile === '~') return homedir();
if (identityFile.startsWith('~/')) return join(homedir(), identityFile.slice(2));
if (identityFile === '$HOME') return homedir();
if (identityFile.startsWith('$HOME/')) return join(homedir(), identityFile.slice('$HOME/'.length));
return identityFile;
}
/**
* COD-107 — build the ordered, shell-safe ssh CONNECTION tokens shared by both
* the durable-launch command (`buildRemoteLaunchCommand`) and the tmux
* prerequisite probe (`buildRemoteTmuxCheckCommand`), so the prereq check and
* the real launch connect with IDENTICAL options (they can't drift).
*
* 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)
* [-o ProxyCommand=nc -X 5 -x <socks> %h %p] (ONE shellescaped -o token)
* [-o <KEY=VALUE>] … (each extra option, shellescaped)
*
* Escaping notes (the risky part):
* - 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.
* - 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)}`);
if (remote.socksProxy) {
parts.push(`-o ${shellescape(`ProxyCommand=nc -X 5 -x ${remote.socksProxy} %h %p`)}`);
}
for (const opt of remote.extraSshOptions ?? []) {
parts.push(`-o ${shellescape(opt)}`);
}
return parts;
}
/**
* COD-104 — build the SSH command that checks the remote host has tmux.
*
* Durable remote sessions run the agent inside a tmux server ON the remote host
* (`tmux -L codeman new-session -A …`), so tmux is now a hard prerequisite there.
* `command -v tmux` exits 0 (and prints the path) when tmux is installed.
*
* COD-107 — connects with the SAME options as the real launch
* (`buildSshConnectionArgs`) so a proxied/custom-port/identity host that the
* launch can reach also passes the prereq probe (and vice-versa).
*/
export function buildRemoteTmuxCheckCommand(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
): string {
// ConnectTimeout is now a default of buildSshConnectionArgs (shared with the launch).
return [...buildSshConnectionArgs(host), remoteSshTarget(host), "'command -v tmux'"].join(' ');
}
export interface RemoteTmuxCheckResult {
ok: boolean;
/** Resolved tmux path on the remote (when ok). */
tmuxPath?: string;
/** Human-readable failure reason (when !ok). */
error?: string;
}
/**
* COD-104 — verify the remote host has tmux installed (required for durable
* remote sessions). Returns a structured result with a clear, user-facing error
* when tmux is missing or the host is unreachable. Never throws.
*/
export async function checkRemoteTmuxAvailable(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
): Promise<RemoteTmuxCheckResult> {
// Under vitest, never open a real ssh connection — mirrors TmuxManager's
// no-op-shell-under-VITEST (IS_TEST_MODE). Without this, remote-case
// create-path tests hit a real ~10s ssh timeout. The command construction is
// covered by buildRemoteTmuxCheckCommand unit tests; only the live probe is
// short-circuited here.
if (process.env.VITEST) {
return { ok: true, tmuxPath: '(test-mode)' };
}
const command = buildRemoteTmuxCheckCommand(host);
try {
const { stdout } = await execAsync(command, { timeout: 15_000 });
const tmuxPath = stdout.trim();
if (!tmuxPath) {
return {
ok: false,
error: `remote host ${host.host} needs tmux installed for durable remote sessions`,
};
}
return { ok: true, tmuxPath };
} catch (err) {
const stderr =
err && typeof err === 'object' && 'stderr' in err ? String((err as { stderr?: unknown }).stderr ?? '') : '';
// `command -v tmux` exits non-zero when tmux is absent (no stderr); a real
// connection failure surfaces ssh diagnostics on stderr.
if (stderr.trim()) {
return {
ok: false,
error: `could not verify tmux on remote host ${host.host}: ${stderr.trim()}`,
};
}
return {
ok: false,
error: `remote host ${host.host} needs tmux installed for durable remote sessions`,
};
}
}
/**
* COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a
* remote host's canonical `-L codeman` socket.
*
* `list-sessions` exits NON-ZERO with empty output when no sessions exist (and
* the server isn't running), so `2>/dev/null` swallows tmux's "no server
* running" stderr; the caller treats a non-zero exit / empty output as "no
* sessions" rather than an error.
*
* COD-107 — connection options come from the shared `buildSshConnectionArgs`, so
* discovery connects with the SAME port/identity/proxy/jump-host as the launch
* and the tmux prereq probe.
*/
export function buildRemoteListSessionsCommand(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
): string {
const [ssh, ...connectionArgs] = buildSshConnectionArgs(host);
const parts = [ssh, connectionArgs[0], '-o ConnectTimeout=10', ...connectionArgs.slice(1)];
// The tmux list-sessions invocation is passed as ONE shell-quoted argument so
// the remote login shell runs it verbatim. The `-F` format uses literal `\t`
// separators (tmux expands them); `2>/dev/null` is inside the quoted command.
const remoteCmd =
'tmux -L codeman list-sessions -F "#{session_name}\\t#{session_attached}\\t#{session_created}\\t#{session_windows}" 2>/dev/null';
parts.push(remoteSshTarget(host), shellescape(remoteCmd));
return parts.join(' ');
}
/**
* COD-105 — pure parser for the `tmux list-sessions -F` output emitted by
* `buildRemoteListSessionsCommand`. Factored out so the parse is unit-testable
* without opening a real ssh connection.
*
* - Splits each non-empty line into [name, attached, created, windows] on the
* field separator. IMPORTANT: the remote tmux's `-F "…\t…"` format does NOT
* expand `\t` to a real tab — it emits the LITERAL two-character sequence
* `\t` (verified on aa-desktop / tmux next-3.7). So we split on the literal
* backslash-t sequence; we also tolerate a real tab in case a tmux build
* does expand it. (A real TAB is the regex `\t`; a literal backslash-t is the
* regex `\\t`.)
* - Keeps ONLY sessions whose name starts with `codeman-` (ignores foreign tmux
* sessions that happen to share the socket).
* - Coerces: `attached` → boolean (`'1'`), `created`/`windows` → finite ints.
* - Skips malformed lines (wrong column count or non-numeric created/windows)
* rather than emitting garbage.
*/
export function parseRemoteSessionList(stdout: string): RemoteSessionInfo[] {
const out: RemoteSessionInfo[] = [];
for (const rawLine of stdout.split('\n')) {
const line = rawLine.trim();
if (!line) continue;
// Split on a literal `\t` (backslash + t, what the remote tmux emits) OR a
// real tab character. `/\\t|\t/` = the two-char sequence, or a TAB.
const cols = line.split(/\\t|\t/);
if (cols.length !== 4) continue;
const [name, attachedStr, createdStr, windowsStr] = cols;
if (!name.startsWith('codeman-')) continue;
const created = Number(createdStr);
const windows = Number(windowsStr);
if (!Number.isFinite(created) || !Number.isFinite(windows)) continue;
// COD-106 — `session_attached` is the CLIENT COUNT (not a 0/1 flag); >1 = shared.
const attachedNum = Number(attachedStr.trim());
const attachedClients = Number.isFinite(attachedNum) ? Math.max(0, Math.trunc(attachedNum)) : 0;
out.push({
name,
attached: attachedClients > 0,
attachedClients,
created: Math.trunc(created),
windows: Math.trunc(windows),
});
}
return out;
}
/**
* COD-105 — discover `codeman-*` tmux sessions already running on a remote host
* (created by the remote's own Codeman, another instance, or this one), so the
* operator can attach to one this Codeman didn't launch.
*
* NEVER throws: returns `[]` on unreachable host / no tmux / no sessions
* (`list-sessions` exits non-zero with empty output when there are none).
*
* VITEST guard — like `checkRemoteTmuxAvailable`, returns `[]` under test so a
* real ssh never runs in a request path (which would make route tests hit a
* ~10s timeout). The command construction is covered by
* `buildRemoteListSessionsCommand` and the parse by `parseRemoteSessionList`.
*/
export async function listRemoteCodemanSessions(
remote: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
): Promise<RemoteSessionInfo[]> {
if (process.env.VITEST) {
return [];
}
const command = buildRemoteListSessionsCommand(remote);
try {
const { stdout } = await execAsync(command, { timeout: 15_000 });
return parseRemoteSessionList(stdout);
} catch {
// Unreachable host, no tmux server, or no sessions (non-zero exit). All map
// to "nothing to attach to" — never surface as an error to the caller.
return [];
}
}
export function remoteDisplayPath(
remote: Pick<SessionRemote, 'username' | 'host' | 'remotePath'> | { username: string; host: string; path: string }
): string {
const path = 'remotePath' in remote ? remote.remotePath : remote.path;
return `${remote.username}@${remote.host}:${path}`;
}
export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): SessionRemote {
return {
hostId: host.id,
label: host.label,
host: host.host,
username: host.username,
port: host.port,
remotePath: remoteCase.remotePath,
commands: host.commands,
// COD-105 — the COD-104 launch path creates the remote session, so we own it
// (an explicit kill may propagate a remote kill-session). Discovered+attached
// sessions go through `toAttachedSessionRemote` with `owned: false`.
owned: true,
// COD-107 — carry the advanced SSH options from host config into the session
// so the launch/prereq commands connect the same way the operator configured.
identityFile: host.identityFile,
socksProxy: host.socksProxy,
jumpHost: host.jumpHost,
extraSshOptions: host.extraSshOptions,
};
}
/**
* COD-105 — build a NON-owned `SessionRemote` for ATTACHING to a `codeman-*`
* session already running on a remote host (discovered via
* `listRemoteCodemanSessions`). The resulting session's pane runs
* `tmux -L codeman attach -t <remoteSessionName>` (see
* `buildRemoteAttachCommand`), and because we did NOT create the remote session,
* `owned: false` means closing the tab DETACHES rather than killing it.
*
* `remotePath` is informational here (the attached remote session keeps its own
* cwd); we record the host's nominal path so display helpers still show
* `user@host:path`.
*/
export function toAttachedSessionRemote(
host: RemoteHost,
remoteSessionName: string,
remotePath: string
): SessionRemote {
return {
hostId: host.id,
label: host.label,
host: host.host,
username: host.username,
port: host.port,
remotePath,
commands: host.commands,
// Discovered + attached — another Codeman created it. Detach-not-kill.
owned: false,
remoteSessionName,
identityFile: host.identityFile,
socksProxy: host.socksProxy,
jumpHost: host.jumpHost,
extraSshOptions: host.extraSshOptions,
};
}