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
+61
View File
@@ -1723,6 +1723,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">
@@ -1751,6 +1752,66 @@
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
</div>
</div>
<!-- Remote Tab -->
<div class="modal-tab-content hidden" id="case-remote">
<div class="form-row">
<label>Case Name</label>
<input type="text" id="remoteCaseName" placeholder="gpu-work" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Name to identify this remote case in Codeman</span>
</div>
<div class="form-row">
<label>Remote Path</label>
<input type="text" id="remoteCasePath" placeholder="/home/user/projects/work" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Absolute path on the remote host. Codeman will not create or delete it.</span>
</div>
<div class="form-row">
<label>Host ID</label>
<input type="text" id="remoteHostId" placeholder="gpu-box" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-row">
<label>SSH Host/IP</label>
<input type="text" id="remoteHostAddress" placeholder="10.0.0.42" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-row">
<label>SSH Username</label>
<input type="text" id="remoteHostUsername" placeholder="ubuntu" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-row">
<label>SSH Port</label>
<input type="number" id="remoteHostPort" placeholder="22" min="1" max="65535" autocomplete="off">
<span class="form-hint">Optional. Leave blank for the default port 22.</span>
</div>
<div class="form-row">
<label>Codex Command Override</label>
<input type="text" id="remoteHostCodexCommand" placeholder="exec codx personal" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Optional. Leave blank to use exec codex on the remote host.</span>
</div>
<details class="advanced-options">
<summary>Advanced SSH</summary>
<div class="advanced-options-content">
<div class="form-row">
<label>Identity File</label>
<input type="text" id="remoteHostIdentityFile" placeholder="~/.ssh/remote_ed25519" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Optional. Path to a private key on this machine (passed to ssh -i). Never the key contents.</span>
</div>
<div class="form-row">
<label>SOCKS Proxy</label>
<input type="text" id="remoteHostSocksProxy" placeholder="127.0.0.1:1080" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Optional. host:port of a SOCKS5 proxy (e.g. cloudflared). Routes ssh through it via a ProxyCommand.</span>
</div>
<div class="form-row">
<label>Jump Host</label>
<input type="text" id="remoteHostJumpHost" placeholder="bastion@10.0.0.1:22" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Optional. [user@]host[:port] for ssh -J (jump/bastion host).</span>
</div>
<div class="form-row">
<label>Extra -o Options</label>
<textarea id="remoteHostExtraSshOptions" rows="3" placeholder="StrictHostKeyChecking=accept-new&#10;ConnectTimeout=10" autocomplete="off" autocapitalize="off" spellcheck="false"></textarea>
<span class="form-hint">Optional. One KEY=VALUE per line; each becomes an ssh -o option.</span>
</div>
</div>
</details>
</div>
<!-- Manage Tab -->
<div class="modal-tab-content hidden" id="case-manage">
<div class="case-manage-list" id="caseManageList">
+203 -31
View File
@@ -71,9 +71,12 @@ Object.assign(CodemanApp.prototype, {
const maxNameLength = isMobile ? 8 : 20; // Truncate to 8 chars on mobile
cases.forEach(c => {
const displayName = c.name.length > maxNameLength
? c.name.substring(0, maxNameLength) + '…'
const baseLabel = c.location === 'remote' && c.remote
? `${c.name} @ ${c.remote.hostId}`
: c.name;
const displayName = baseLabel.length > maxNameLength
? baseLabel.substring(0, maxNameLength) + '…'
: baseLabel;
options += `<option value="${escapeHtml(c.name)}">${escapeHtml(displayName)}</option>`;
});
@@ -328,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
@@ -486,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) {
@@ -551,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`);
@@ -559,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).
@@ -577,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();
@@ -598,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`);
@@ -605,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();
@@ -621,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();
@@ -645,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`);
@@ -652,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());
@@ -667,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();
@@ -1254,6 +1324,23 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('newCaseDescription').value = '';
document.getElementById('linkCaseName').value = '';
document.getElementById('linkCasePath').value = '';
const remoteFields = [
'remoteCaseName',
'remoteCasePath',
'remoteHostId',
'remoteHostAddress',
'remoteHostUsername',
'remoteHostPort',
'remoteHostCodexCommand',
'remoteHostIdentityFile',
'remoteHostSocksProxy',
'remoteHostJumpHost',
'remoteHostExtraSshOptions',
];
remoteFields.forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
// Reset to first tab
this.caseModalTab = 'case-create';
this.switchCaseModalTab('case-create');
@@ -1295,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();
}
},
@@ -1317,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();
}
@@ -1407,6 +1499,86 @@ Object.assign(CodemanApp.prototype, {
}
},
async linkRemoteCase() {
const name = document.getElementById('remoteCaseName').value.trim();
const remotePath = document.getElementById('remoteCasePath').value.trim();
const hostId = document.getElementById('remoteHostId').value.trim();
const host = document.getElementById('remoteHostAddress').value.trim();
const username = document.getElementById('remoteHostUsername').value.trim();
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
// COD-107 — port + advanced SSH connection options.
const portRaw = document.getElementById('remoteHostPort').value.trim();
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
if (!name || !remotePath || !hostId || !host || !username) {
this.showToast('Please complete all required remote fields', 'error');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name) || !/^[a-zA-Z0-9_-]+$/.test(hostId)) {
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
return;
}
if (!remotePath.startsWith('/')) {
this.showToast('Remote path must be absolute', 'error');
return;
}
let port;
if (portRaw) {
port = Number(portRaw);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
this.showToast('SSH port must be a number between 1 and 65535', 'error');
return;
}
}
try {
const hostPayload = {
id: hostId,
label: hostId,
host,
username,
...(port ? { port } : {}),
...(identityFile ? { identityFile } : {}),
...(socksProxy ? { socksProxy } : {}),
...(jumpHost ? { jumpHost } : {}),
...(extraSshOptions.length ? { extraSshOptions } : {}),
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
};
const hostRes = await fetch('/api/remote-hosts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hostPayload)
});
const hostData = await hostRes.json();
if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') {
throw new Error(hostData.error || 'Failed to save remote host');
}
const caseRes = await fetch('/api/cases/remote-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, hostId, remotePath })
});
const caseData = await caseRes.json();
if (caseData.success) {
this.closeCreateCaseModal();
this.showToast(`Remote case "${name}" linked`, 'success');
await this.loadQuickStartCases(name);
await this.saveLastUsedCase(name);
} else {
this.showToast(caseData.error || 'Failed to link remote case', 'error');
}
} catch (err) {
console.error('Failed to link remote case:', err);
this.showToast('Failed to link remote case: ' + err.message, 'error');
}
},
// ═══════════════════════════════════════════════════════════════
// Case Management (reorder + delete)
+142 -1
View File
@@ -11,15 +11,30 @@ import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import type { ApiResponse, CaseInfo } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema } from '../schemas.js';
import {
CreateCaseSchema,
LinkCaseSchema,
CaseOrderSchema,
RemoteCaseLinkSchema,
RemoteHostSchema,
} from '../schemas.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { writeHooksConfig } from '../../hooks-config.js';
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
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,
writeRemoteCases,
writeRemoteHosts,
} from '../../remote-hosts.js';
const LINKED_CASES_FILE = dataPath('linked-cases.json');
const CODEMAN_CONFIG_DIR = getDataDir();
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
@@ -53,6 +68,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
name: e.name,
path: join(CASES_DIR, e.name),
hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')),
location: 'local',
});
}
}
@@ -69,10 +85,39 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
name,
path,
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
linked: true,
location: 'linked-local',
});
}
}
// Get remote cases
const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
const host = remoteHostMap.get(remoteCase.hostId);
if (!host || !SAFE_CASE_NAME.test(remoteCase.name)) continue;
existingNames.add(remoteCase.name);
const remoteCaseInfo: CaseInfo = {
name: remoteCase.name,
path: remoteDisplayPath({ username: host.username, host: host.host, path: remoteCase.remotePath }),
hasClaudeMd: false,
location: 'remote',
remote: {
hostId: host.id,
host: host.host,
username: host.username,
path: remoteCase.remotePath,
},
};
const existingIndex = cases.findIndex((item) => item.name === remoteCase.name);
if (existingIndex === -1) {
cases.push(remoteCaseInfo);
} else {
cases[existingIndex] = remoteCaseInfo;
}
}
// Sort by persisted caseOrder from settings.json
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings', {});
const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : [];
@@ -120,6 +165,73 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
});
app.get('/api/remote-hosts', async () => readRemoteHosts(CODEMAN_CONFIG_DIR));
app.post('/api/remote-hosts', async (req): Promise<ApiResponse<{ host: unknown }>> => {
const host = parseBody(RemoteHostSchema, req.body);
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
if (hosts.some((item) => item.id === host.id)) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Remote host already exists');
}
await writeRemoteHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
return { success: true, data: { host } };
});
app.put('/api/remote-hosts/:id', async (req): Promise<ApiResponse<{ host: unknown }>> => {
const { id } = req.params as { id: string };
const host = parseBody(RemoteHostSchema, { ...(req.body as object), id });
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
const index = hosts.findIndex((item) => item.id === id);
if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
const next = [...hosts];
next[index] = host;
await writeRemoteHosts(CODEMAN_CONFIG_DIR, next);
return { success: true, data: { host } };
});
app.delete('/api/remote-hosts/:id', async (req): Promise<ApiResponse<{ id: string }>> => {
const { id } = req.params as { id: string };
const cases = await readRemoteCases(CODEMAN_CONFIG_DIR);
if (cases.some((item) => item.hostId === id)) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Remote host is still used by remote cases');
}
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
await writeRemoteHosts(
CODEMAN_CONFIG_DIR,
hosts.filter((item) => item.id !== id)
);
return { success: true, data: { id } };
});
app.post('/api/cases/remote-link', async (req): Promise<ApiResponse<{ case: unknown }>> => {
const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const };
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
const host = hosts.find((item) => item.id === remoteCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
const linkedCases = await readLinkedCases();
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
if (
remoteCases.some((item) => item.name === remoteCase.name) ||
linkedCases[remoteCase.name] ||
existsSync(join(CASES_DIR, remoteCase.name))
) {
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 } };
});
// Link an existing folder as a case
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
@@ -173,6 +285,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
if (remoteCases.some((item) => item.name === name)) {
await writeRemoteCases(
CODEMAN_CONFIG_DIR,
remoteCases.filter((item) => item.name !== name)
);
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
return { success: true, data: { name } };
}
// Check linked cases first — unlink only, don't delete the actual directory
const linkedCases = await readLinkedCases();
if (linkedCases[name]) {
@@ -233,6 +355,25 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
const remoteCase = remoteCases.find((item) => item.name === name);
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');
return {
name,
path: remoteDisplayPath({ username: host.username, host: host.host, path: remoteCase.remotePath }),
hasClaudeMd: false,
location: 'remote',
remote: {
hostId: host.id,
host: host.host,
username: host.username,
path: remoteCase.remotePath,
},
};
}
const casePath = await resolveCasePath(name);
if (!existsSync(casePath)) {
+104 -56
View File
@@ -70,10 +70,12 @@ 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 } from '../../config/instance.js';
import { dataPath, getDataDir } from '../../config/instance.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');
const CODEMAN_CONFIG_DIR = getDataDir();
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
// eslint-disable-next-line no-control-regex
@@ -1269,81 +1271,125 @@ 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()) {
// 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 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.OPERATION_FAILED,
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
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.'
);
}
}
// 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'
);
// 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');
}
}
// 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'
);
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'
);
}
}
}
// 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.
let linkedCases: Record<string, string> = {};
try {
const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
linkedCases = JSON.parse(raw);
} catch {
// File missing or unparseable — treat as empty registry
}
const linkedCasePath = linkedCases[caseName];
const casePath = linkedCasePath || validatePathWithinBase(caseName, CASES_DIR);
if (!casePath) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
}
// 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'
);
}
}
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked cases)
if (!existsSync(casePath)) {
// 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.
let linkedCases: Record<string, string> = {};
try {
mkdirSync(casePath, { recursive: true });
mkdirSync(join(casePath, 'src'), { recursive: true });
const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
linkedCases = JSON.parse(raw);
} catch {
// File missing or unparseable — treat as empty registry
}
casePath = linkedCases[caseName] || validatePathWithinBase(caseName, CASES_DIR);
if (!casePath) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
}
}
// By this point casePath is guaranteed non-null: for remote cases it was set from remoteCase.remotePath,
// for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
const resolvedCasePath = casePath as string;
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote cases)
if (!remote && !existsSync(resolvedCasePath)) {
try {
mkdirSync(resolvedCasePath, { recursive: true });
mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });
// Read settings to get custom template path
const templatePath = await ctx.getDefaultClaudeMdPath();
const claudeMd = generateClaudeMd(caseName, '', templatePath);
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
// Write .claude/settings.local.json with hooks for desktop notifications
// (Claude-specific — OpenCode, Codex, and Gemini use their own systems)
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
await writeHooksConfig(casePath);
await writeHooksConfig(resolvedCasePath);
}
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath });
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: resolvedCasePath });
} 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.
await refreshStaleHookSecret(casePath).catch(() => {});
// 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(() => {});
}
// Strip stale disk entries for keys this request is actively setting (Claude only —
@@ -1352,10 +1398,11 @@ export function registerSessionRoutes(
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
!remote &&
envOverrides &&
Object.keys(envOverrides).length > 0
) {
await stripCaseEnvKeys(casePath, Object.keys(envOverrides));
await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
}
// Create a new session with the case as working directory
@@ -1375,7 +1422,7 @@ export function registerSessionRoutes(
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir: casePath,
workingDir: resolvedCasePath,
mux: ctx.mux,
useMux: true,
mode: mode,
@@ -1388,13 +1435,14 @@ export function registerSessionRoutes(
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
envOverrides,
effort,
remote,
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
});
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
// so the initial state already has the phrase configured (only if globally enabled)
if (mode === 'claude' && ctx.store.getConfig().ralphEnabled) {
autoConfigureRalph(session, casePath, ctx);
if (mode === 'claude' && !remote && ctx.store.getConfig().ralphEnabled) {
autoConfigureRalph(session, resolvedCasePath, ctx);
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
@@ -1463,7 +1511,7 @@ export function registerSessionRoutes(
return {
sessionId: session.id,
casePath,
casePath: resolvedCasePath,
caseName,
};
} catch (err) {
+88
View File
@@ -271,6 +271,94 @@ export const CreateCaseSchema = z.object({
description: z.string().max(1000).optional(),
});
const RemoteCommandOverridesSchema = z
.object({
shell: z.string().min(1).max(300).optional(),
claude: z.string().min(1).max(300).optional(),
opencode: z.string().min(1).max(300).optional(),
codex: z.string().min(1).max(300).optional(),
gemini: z.string().min(1).max(300).optional(),
})
.strict()
.optional();
// COD-107 — advanced SSH connection options. These ultimately exec as shell
// (ProxyCommand etc.), but are OPERATOR-entered host config (never attacker- or
// terminal-output-influenced), so we validate as defense-in-depth, not as the
// security boundary. Reject newline/NUL/backtick/`$(` shell-injection vectors.
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),
host: z
.string()
.min(1)
.max(255)
.regex(/^[a-zA-Z0-9._:-]+$/, 'Invalid SSH host'),
username: z
.string()
.min(1)
.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. 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()
.regex(/^[\w.-]+:\d{1,5}$/, 'SOCKS proxy must be host:port')
.optional(),
// SSH jump host: a comma-separated chain of [user@]host[:port] hops. Structural
// ALLOWLIST (not an open denylist) — only chars valid in user/host/port/IPv6,
// so no shell metacharacter (;, |, &, space, $, quotes, …) can appear. The value
// is also shellescaped at command-build time (buildSshConnectionArgs); this is the
// belt to that suspenders.
jumpHost: z
.string()
.min(1)
.max(255)
.regex(
/^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.:[\]-]+(?::\d{1,5})?(?:,(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.:[\]-]+(?::\d{1,5})?)*$/,
'Jump host must be [user@]host[:port] (comma-separated for multiple hops)'
)
.optional(),
// Arbitrary extra -o KEY=VALUE options (escape hatch); each must be KEY=VALUE.
extraSshOptions: z
.array(
z
.string()
.min(3)
.max(1024)
.regex(/^[A-Za-z][A-Za-z0-9]*=.+$/, 'Extra SSH option must be KEY=VALUE')
.regex(NO_SHELL_INJECTION, 'Invalid characters in SSH option')
.refine(noCommandSubstitution, 'Invalid characters in SSH option')
)
.max(32)
.optional(),
commands: RemoteCommandOverridesSchema,
});
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')
.regex(NO_SHELL_META, 'Invalid characters in remote path'),
});
// ========== Quick Start ==========
/**
+6
View File
@@ -2180,6 +2180,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