mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
Merge pull request #357 from dignfei/feat/docker-adopt-existing-container
feat(docker): attach a case to an already-running container Conflicts came from work that landed after the PR was opened, and each is resolved onto the newer abstraction rather than by keeping the older code: - `defaultDockerCommandForMode` is registry-driven since #347, so the PR's `runsAsRoot` arm became `overlays.docker.rootCommand` (claude only). Claude Code still refuses `--dangerously-skip-permissions` as root in 2.1.261 and the refusal is visible only inside the container, so an adopted root container otherwise just shows a dead pane. Which flag to drop is a per-CLI fact, and `test/cli-registry-no-id-branching.test.ts` forbids expressing it as a branch. - The probe's mode list and its mode -> binary table both duplicated the registry. They now read `enabledCliIds()` / `discovery.binaries[0]`, which is also what fixes the merge's silent regression: the hand-written list predates `omp`, and the run menu gates every docker case on this probe, so owned containers would have lost that mode. `shell` needs no arm — it declares no binary, so it is dropped from the lookup and reported available regardless. - The per-mode `mode === 'claude' && !cliDir` chain in `tmux-manager.ts` is one `missingCliMessage(mode)` gate since #347; the PR's docker exemption moved onto it. Its test now pins the single gate instead of counting seven arms. - The create arm keeps #349's swap-limit warning filter, which the adopted arm never reaches; the run-mode list gains `omp` from #353. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TecFD9hvPYJ1mkkMtBQbT1
This commit is contained in:
+40
-13
@@ -2270,9 +2270,11 @@ class CodemanApp {
|
||||
? 'Grok'
|
||||
: mode === 'deepseek'
|
||||
? 'DeepSeek'
|
||||
: mode === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
: mode === 'omp'
|
||||
? 'OMP'
|
||||
: mode === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
}
|
||||
|
||||
async toggleResponseViewer() {
|
||||
@@ -2632,29 +2634,52 @@ class CodemanApp {
|
||||
// string field (e.g. modelDisplayName, which the route also broadcasts) is
|
||||
// ever shown in this chip, render it via textContent — never interpolate an
|
||||
// untrusted string into this template.
|
||||
const seg = (label, p) => {
|
||||
if (p === null) return '';
|
||||
// `idle: true` keeps a missing window's SLOT with a dimmed em dash instead of
|
||||
// dropping it. Claude only: Claude Code documents `five_hour` as "present
|
||||
// only while the API reports it and its resets_at has not passed", so that
|
||||
// key leaves the statusline payload whenever no 5-hour session window is
|
||||
// open, and a chip that silently shrank from two windows to one read as a
|
||||
// broken feature rather than as an idle window (reported 2026-09-01). A
|
||||
// missing CODEX bucket means the opposite — that plan has no such limit —
|
||||
// so those stay omitted rather than showing a dash forever.
|
||||
const seg = (label, p, idle) => {
|
||||
if (p === null) {
|
||||
if (!idle) return '';
|
||||
return `<span class="pu-win pu-win-idle"><span class="pu-label">${label}</span><span class="pu-val">—</span></span>`;
|
||||
}
|
||||
const n = Math.round(Number(p));
|
||||
if (!Number.isFinite(n)) return '';
|
||||
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`;
|
||||
};
|
||||
const row = (provider, usage) => {
|
||||
const windows = [seg('5h', pct(usage?.fiveHour)), seg('7d', pct(usage?.sevenDay))].filter(Boolean);
|
||||
// The provider label only earns its space when there is more than one
|
||||
// provider to tell apart: a machine with Claude alone shows bare windows.
|
||||
const hasWindows = (usage) => pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null;
|
||||
const labelled = hasWindows(data) && hasWindows(data.codex);
|
||||
const row = (provider, usage, idle) => {
|
||||
// hasWindows() gates the row, so a placeholder can only ever appear
|
||||
// ALONGSIDE a real reading — a provider reporting nothing still renders
|
||||
// nothing, never a row of em dashes.
|
||||
if (!hasWindows(usage)) return '';
|
||||
const windows = [seg('5h', pct(usage?.fiveHour), idle), seg('7d', pct(usage?.sevenDay), idle)].filter(Boolean);
|
||||
if (!windows.length) return '';
|
||||
return `<span class="pu-row"><span class="pu-provider">${provider}</span><span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
|
||||
const label = labelled ? `<span class="pu-provider">${provider}</span>` : '';
|
||||
return `<span class="pu-row">${label}<span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
|
||||
};
|
||||
const rows = [row('Claude', data), row('Codex', data.codex)].filter(Boolean);
|
||||
const rows = [row('Claude', data, true), row('Codex', data.codex, false)].filter(Boolean);
|
||||
chip.innerHTML = rows.length ? rows.join('') : '—';
|
||||
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
|
||||
const details = (provider, usage) => {
|
||||
const details = (provider, usage, idle) => {
|
||||
const lines = [];
|
||||
const five = pct(usage?.fiveHour);
|
||||
const seven = pct(usage?.sevenDay);
|
||||
if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`);
|
||||
else if (idle && seven !== null) lines.push('5-hour limit: no active session window');
|
||||
if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`);
|
||||
return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : '';
|
||||
};
|
||||
chip.title = [details('Claude', data), details('Codex', data.codex)].filter(Boolean).join('\n\n') || 'Plan usage limits';
|
||||
chip.title =
|
||||
[details('Claude', data, true), details('Codex', data.codex, false)].filter(Boolean).join('\n\n') ||
|
||||
'Plan usage limits';
|
||||
}
|
||||
|
||||
// Scheduled runs
|
||||
@@ -4896,7 +4921,7 @@ class CodemanApp {
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : ''}
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
|
||||
${inlineSessionActions ? tabActionsHtml : ''}
|
||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||
@@ -6344,7 +6369,9 @@ class CodemanApp {
|
||||
? 'Kill Tmux & Grok'
|
||||
: session.mode === 'deepseek'
|
||||
? 'Kill Tmux & DeepSeek'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
: session.mode === 'omp'
|
||||
? 'Kill Tmux & OMP'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
}
|
||||
|
||||
document.getElementById('closeConfirmModal').classList.add('active');
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
|
||||
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
|
||||
* @globals {function} escapeHtml - XSS-safe HTML escaping
|
||||
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (156 event types; must match backend src/web/sse-events.ts)
|
||||
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (157 event types; must match backend src/web/sse-events.ts)
|
||||
* @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets
|
||||
*
|
||||
* @dependency None (first in load order)
|
||||
|
||||
@@ -80,6 +80,7 @@ const HOME_SESSIONS_MODE_BADGE = {
|
||||
pi: 'pi',
|
||||
grok: 'gk',
|
||||
deepseek: 'ds',
|
||||
omp: 'om',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
|
||||
@@ -452,6 +452,10 @@
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run DeepSeek
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-omp" id="welcomeOmpBtn" style="display: none;" onclick="app.setRunMode('omp'); app.runOmp()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run OMP
|
||||
</button>
|
||||
</div>
|
||||
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
||||
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
||||
@@ -643,6 +647,9 @@
|
||||
<button class="run-mode-option run-mode-option-install" data-action="deepseek-install" id="runModeDeepSeekInstall" style="display: none;" onclick="app.installDeepSeekProfile()">
|
||||
<span class="run-mode-dot deepseek"></span>DeepSeek — add a terminal profile…
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="omp" onclick="app.setRunMode('omp')">
|
||||
<span class="run-mode-dot omp"></span>OMP
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
|
||||
<span class="run-mode-dot shell"></span>Terminal / Shell
|
||||
@@ -930,6 +937,7 @@
|
||||
<option value="pi">Pi</option>
|
||||
<option value="grok">Grok</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="omp">OMP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
|
||||
@@ -2020,6 +2028,8 @@
|
||||
<div class="set-modelgrid" id="appSettingsModelCards" role="radiogroup" aria-label="Model for new Claude sessions" data-search="model opus sonnet haiku fable claude"></div>
|
||||
<select id="appSettingsClaudeModel" class="set-select set-field-hidden" aria-hidden="true" tabindex="-1">
|
||||
<option value="" data-meta="Whatever the CLI picks">Default (CLI setting)</option>
|
||||
<option value="claude-fable-5-1" data-meta="Latest" data-base="claude-fable-5-1" data-ctx="1">Fable 5.1</option>
|
||||
<option value="claude-fable-5-1[1m]" data-variant="1m" data-base="claude-fable-5-1">Fable 5.1 (1M context)</option>
|
||||
<option value="claude-fable-5" data-meta="Most powerful" data-base="claude-fable-5" data-ctx="1">Fable 5</option>
|
||||
<option value="claude-fable-5[1m]" data-variant="1m" data-base="claude-fable-5">Fable 5 (1M context)</option>
|
||||
<option value="opus" data-meta="Most capable" data-base="opus" data-ctx="1">Opus</option>
|
||||
@@ -2032,7 +2042,7 @@
|
||||
<div class="set-row" id="appSettingsContextRow" data-search="1m context window opus long">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">1M context window</span>
|
||||
<span class="set-row-desc" id="appSettingsContextDesc">Available for Fable 5, Opus and Opus 4.6.</span>
|
||||
<span class="set-row-desc" id="appSettingsContextDesc">Available for Fable 5.1, Fable 5, Opus and Opus 4.6.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsOpusContext1m"><span class="slider"></span></label>
|
||||
</div>
|
||||
@@ -2072,6 +2082,7 @@
|
||||
</div>
|
||||
<select id="appSettingsDefaultModel" class="set-select">
|
||||
<option value="">Default (CLI default)</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1 (Latest)</option>
|
||||
<option value="claude-fable-5">Fable 5 (Most powerful)</option>
|
||||
<option value="opus">Opus (Most capable)</option>
|
||||
<option value="sonnet">Sonnet (Balanced)</option>
|
||||
@@ -2086,6 +2097,7 @@
|
||||
<option value="haiku">Haiku</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -2096,6 +2108,7 @@
|
||||
<option value="haiku">Haiku</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -2106,6 +2119,7 @@
|
||||
<option value="haiku">Haiku</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -2116,6 +2130,7 @@
|
||||
<option value="haiku">Haiku</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -2710,6 +2725,7 @@
|
||||
<option value="pi" data-cli="pi">Pi</option>
|
||||
<option value="grok" data-cli="grok">Grok</option>
|
||||
<option value="deepseek" data-cli="deepseek">DeepSeek</option>
|
||||
<option value="omp" data-cli="omp">OMP</option>
|
||||
<option value="shell">Shell (no agent)</option>
|
||||
</select>
|
||||
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
|
||||
|
||||
@@ -56,6 +56,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
{ mode: 'pi', label: 'Pi', short: 'Pi' },
|
||||
{ mode: 'grok', label: 'Grok', short: 'Grok' },
|
||||
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
|
||||
{ mode: 'omp', label: 'OMP', short: 'OMP' },
|
||||
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
||||
];
|
||||
|
||||
@@ -388,7 +389,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
async resumeMobileOverviewSession(sessionId) {
|
||||
const row = (this._mobileOverviewPastRows || []).find((r) => r.id === sessionId);
|
||||
if (!row || !row.workingDir) return;
|
||||
await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined);
|
||||
await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined, row.mode);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1003,6 +1003,20 @@ html.mobile-init .file-browser-panel {
|
||||
border-color: rgba(150, 170, 255, 0.55) !important;
|
||||
}
|
||||
|
||||
/* OMP mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek blocks above. */
|
||||
.btn-toolbar.btn-run.mode-omp,
|
||||
.btn-toolbar.btn-run-gear.mode-omp {
|
||||
background: #312e81 !important;
|
||||
border-color: rgba(129, 140, 248, 0.3) !important;
|
||||
color: #e0e7ff !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-run.mode-omp:active,
|
||||
.btn-toolbar.btn-run-gear.mode-omp:active {
|
||||
background: #4f46e5 !important;
|
||||
border-color: rgba(129, 140, 248, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Run mode dropdown menu — positioned above toolbar on mobile */
|
||||
.run-mode-menu {
|
||||
bottom: 100%;
|
||||
@@ -3086,6 +3100,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-omp, .btn-toolbar.btn-run-gear.mode-omp) {
|
||||
background: linear-gradient(135deg, #4f46e5, #6366f1);
|
||||
border-color: #4338ca;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) {
|
||||
background: linear-gradient(135deg, #27272a, #52525b);
|
||||
border-color: #18181b;
|
||||
|
||||
@@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
_buildCommandPaletteNewSessionItem(query = '') {
|
||||
const mode = this.runMode || this._runMode || 'claude';
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek' };
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP' };
|
||||
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
|
||||
return {
|
||||
id: 'new-session',
|
||||
@@ -670,7 +670,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else if (record.workingDir) {
|
||||
// History rows are keyed by the Claude conversation UUID; resumed
|
||||
// sessions carry theirs separately as claudeSessionId.
|
||||
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir);
|
||||
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir, undefined, s.mode);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -407,6 +407,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (mode === 'antigravity') {
|
||||
return await this.runAntigravity();
|
||||
}
|
||||
if (mode === 'omp') {
|
||||
return await this.runOmp();
|
||||
}
|
||||
if (mode === 'pi') {
|
||||
return await this.runPi();
|
||||
}
|
||||
@@ -501,7 +504,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// An unreachable container hides every agent mode and explains why, instead
|
||||
// of silently offering modes that cannot start.
|
||||
const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null;
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) {
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) {
|
||||
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
||||
if (!btn) continue;
|
||||
let available;
|
||||
@@ -789,7 +792,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
btn.append(...parts);
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name);
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
@@ -810,7 +813,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'shell' ? 'Run SH' : 'Run';
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : 'Run';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1523,6 +1526,56 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async runOmp() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run omp on the OTHER side — skip the local status probe
|
||||
// and the local-only config below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OMP session in ${caseName}...`);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/omp/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'omp',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start OMP');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
|
||||
if (data.data.sessionId) {
|
||||
await this.selectSession(data.data.sessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch a Grok Build (xAI `grok`) session.
|
||||
*
|
||||
@@ -1726,7 +1779,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||
|
||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek';
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||
|
||||
// Update respawn status display and buttons
|
||||
@@ -1756,7 +1809,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Hide Claude-specific options for external CLI sessions
|
||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek';
|
||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
||||
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
||||
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
|
||||
|
||||
@@ -3733,7 +3786,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
|
||||
},
|
||||
set(mode) {
|
||||
this._runMode =
|
||||
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'claude'
|
||||
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'omp' || mode === 'claude'
|
||||
? mode
|
||||
: 'claude';
|
||||
},
|
||||
|
||||
@@ -922,7 +922,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
desc.textContent = inert
|
||||
? 'The selected model has no 1M variant.'
|
||||
: base
|
||||
? 'Available for Fable 5, Opus and Opus 4.6.'
|
||||
? 'Available for Fable 5.1, Fable 5, Opus and Opus 4.6.'
|
||||
: 'With no model pinned, this starts new sessions on Opus with a 1M window.';
|
||||
}
|
||||
},
|
||||
@@ -1079,10 +1079,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const verEl = this.$('updateCurrentVersion');
|
||||
if (verEl && data.currentVersion) verEl.textContent = `v${data.currentVersion}`;
|
||||
|
||||
if (data.installKind && data.installKind !== 'git') {
|
||||
this._setUpdateResult(
|
||||
`This install can't update itself (${escapeHtml(data.installKind)}). Update with <code>npm i -g aicodeman@latest</code>.`
|
||||
);
|
||||
// `docker-compose` self-updates in place like `git` does — the container
|
||||
// restarts itself. Anything else cannot.
|
||||
if (data.installKind && data.installKind !== 'git' && data.installKind !== 'docker-compose') {
|
||||
const hint =
|
||||
data.supervisor === 'docker-compose'
|
||||
? 'Update from the Docker host with <code>docker/Start-Codeman.sh</code>.'
|
||||
: 'Update with <code>npm i -g aicodeman@latest</code>.';
|
||||
this._setUpdateResult(`This install can't update itself (${escapeHtml(data.installKind)}). ${hint}`);
|
||||
return;
|
||||
}
|
||||
if (data.selfUpdateEnabled === false) {
|
||||
@@ -1093,6 +1097,30 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._setUpdateResult(escapeHtml(data.error));
|
||||
return;
|
||||
}
|
||||
// A container release that changes the ENVIRONMENT (Dockerfile, compose file
|
||||
// or new .env keys) cannot be applied by the container restarting itself, so
|
||||
// the update button is never offered — the host command is, instead. The
|
||||
// server re-checks this on POST, so hiding the button is UX, not the gate.
|
||||
const blockers = data.environment?.blockers || [];
|
||||
if (data.updateAvailable && blockers.length > 0) {
|
||||
const reasons = blockers
|
||||
.map((b) => {
|
||||
const details = b.details?.length ? `<br><code>${escapeHtml(b.details.join(' '))}</code>` : '';
|
||||
return `<li>${escapeHtml(b.message)}${details}</li>`;
|
||||
})
|
||||
.join('');
|
||||
this._setUpdateResult(
|
||||
`<strong>v${escapeHtml(data.latestVersion || '')}</strong> needs a rebuild on the Docker host` +
|
||||
` (current v${escapeHtml(data.currentVersion || '')}):<ul>${reasons}</ul>` +
|
||||
`Run <code>${escapeHtml(data.environment?.hostCommand || 'docker/Start-Codeman.sh')}</code> there to apply it.`
|
||||
);
|
||||
if (notes && data.notes) {
|
||||
notes.style.display = 'block';
|
||||
notes.textContent = data.notes;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.updateAvailable && data.latestVersion) {
|
||||
this._setUpdateResult(
|
||||
`Update available: <strong>v${escapeHtml(data.latestVersion)}</strong> (current v${escapeHtml(data.currentVersion || '')})`
|
||||
@@ -1230,6 +1258,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
['welcomeClaudeBtn', 'claude'],
|
||||
['welcomeOpencodeBtn', 'opencode'],
|
||||
['welcomeAntigravityBtn', 'antigravity'],
|
||||
['welcomeOmpBtn', 'omp'],
|
||||
['welcomeGeminiBtn', 'gemini'],
|
||||
['welcomePiBtn', 'pi'],
|
||||
['welcomeGrokBtn', 'grok'],
|
||||
|
||||
@@ -349,7 +349,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.session-tab .tab-mode.gemini,
|
||||
.session-tab .tab-mode.antigravity,
|
||||
.session-tab .tab-mode.pi,
|
||||
.session-tab .tab-mode.grok
|
||||
.session-tab .tab-mode.grok,
|
||||
.session-tab .tab-mode.omp
|
||||
) {
|
||||
color: var(--accent-d);
|
||||
}
|
||||
@@ -2499,6 +2500,10 @@ body.solo-mode .btn-lifecycle-log {
|
||||
background: rgba(34, 211, 238, 0.2);
|
||||
color: #22d3ee;
|
||||
}
|
||||
.session-tab .tab-mode.omp {
|
||||
background: rgba(129, 140, 248, 0.2);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.session-tab .tab-mode.pi {
|
||||
background: rgba(244, 114, 182, 0.2);
|
||||
@@ -3883,6 +3888,22 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
color: #fff1f7;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
/* OMP: indigo identity, matching .btn-toolbar.btn-run.mode-omp and
|
||||
.run-mode-dot.omp so the welcome action reads as the same backend. */
|
||||
.welcome-btn-omp {
|
||||
background: linear-gradient(135deg, #1e1b4b 0%, #4f46e5 55%, #6366f1 100%);
|
||||
border-color: rgba(129, 140, 248, 0.4);
|
||||
color: #e0e7ff;
|
||||
box-shadow: 0 2px 8px rgba(129, 140, 248, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.welcome-btn-omp:hover {
|
||||
background: linear-gradient(135deg, #312e81 0%, #6366f1 55%, #818cf8 100%);
|
||||
box-shadow: 0 4px 20px rgba(129, 140, 248, 0.3), 0 0 40px rgba(79, 70, 229, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(165, 180, 252, 0.5);
|
||||
color: #eef2ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Grok (xAI): monochrome charcoal identity, matching .btn-toolbar.btn-run.mode-grok
|
||||
and .run-mode-dot.grok so the welcome action reads as the same backend. */
|
||||
@@ -4992,6 +5013,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
border-color: rgba(249, 168, 212, 0.6);
|
||||
color: #fff1f7;
|
||||
}
|
||||
/* OMP mode colors */
|
||||
.btn-toolbar.btn-run.mode-omp,
|
||||
.btn-toolbar.btn-run-gear.mode-omp {
|
||||
background: linear-gradient(135deg, #312e81 0%, #4f46e5 55%, #6366f1 100%);
|
||||
border-color: rgba(129, 140, 248, 0.5);
|
||||
color: #e0e7ff;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-omp:hover,
|
||||
.btn-toolbar.btn-run-gear.mode-omp:hover {
|
||||
background: linear-gradient(135deg, #3730a3 0%, #6366f1 55%, #818cf8 100%);
|
||||
box-shadow: 0 0 12px rgba(129, 140, 248, 0.35), 0 2px 8px rgba(79, 70, 229, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(165, 180, 252, 0.6);
|
||||
color: #eef2ff;
|
||||
}
|
||||
|
||||
/* Grok mode colors. Same cascade note as pi above: this base-sheet pair only
|
||||
renders on the `og` skin — the nested `html:not([data-skin="og"])` block
|
||||
@@ -5116,6 +5152,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
.run-mode-dot.pi { background: #f472b6; }
|
||||
.run-mode-dot.grok { background: #a1a1aa; }
|
||||
.run-mode-dot.deepseek { background: #4d6bfe; }
|
||||
.run-mode-dot.omp { background: #818cf8; }
|
||||
.run-mode-dot.shell { background: #94a3b8; }
|
||||
|
||||
/* Phone-only Enter button (see index.html). Hidden by default at every width;
|
||||
@@ -12433,6 +12470,13 @@ kbd {
|
||||
color: var(--text-dim);
|
||||
opacity: 0.45;
|
||||
}
|
||||
/* A window Claude is not currently reporting: the slot stays, dimmed, so the
|
||||
chip keeps its shape instead of looking like half of it broke. */
|
||||
.header-plan-usage .pu-win-idle .pu-label,
|
||||
.header-plan-usage .pu-win-idle .pu-val {
|
||||
color: var(--text-dim);
|
||||
opacity: 0.55;
|
||||
}
|
||||
/* Green/yellow/red by how much of the window is used up. */
|
||||
.header-plan-usage .pu-green {
|
||||
color: #3fb950;
|
||||
|
||||
@@ -2193,7 +2193,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (isLive && this.sessions.has(s.sessionId)) {
|
||||
this.selectSession(s.sessionId);
|
||||
} else {
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name);
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -2436,7 +2436,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else {
|
||||
// Resume by the Claude conversation UUID when present (resumed sessions
|
||||
// carry theirs separately from their Codeman id).
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name);
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode);
|
||||
}
|
||||
this.closeSessionManager?.();
|
||||
closeMenu();
|
||||
@@ -2904,7 +2904,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
return `w${startNumber}-${dirName}`;
|
||||
},
|
||||
|
||||
async resumeHistorySession(sessionId, workingDir, existingName) {
|
||||
async resumeHistorySession(sessionId, workingDir, existingName, mode) {
|
||||
// Close the run mode menu if open
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
// Close folder history modal if open
|
||||
@@ -2925,13 +2925,45 @@ Object.assign(CodemanApp.prototype, {
|
||||
const globalSettings = this.loadAppSettingsFromStorage();
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
|
||||
const effort = this.getEffortSetting(globalSettings);
|
||||
// `resumeSessionId` is a Claude conversation UUID (server reads it from
|
||||
// ~/.claude/projects); an external-CLI row has no such thing, so sending
|
||||
// it there gets silently ignored while the OMITTED `mode` field defaults
|
||||
// the create to plain claude — reproducing whatever conversation THAT
|
||||
// uuid happens to collide with instead of the row's own backend. Row mode
|
||||
// wins here. Codeman has no cross-restart PTY-reattach outside server
|
||||
// boot, so "resume" for a non-claude row means relaunching the CLI's own
|
||||
// continue-most-recent flag (opencode/pi/grok/omp --continue, deepseek
|
||||
// resumeSession) in the same directory — real conversation continuity,
|
||||
// just not the literal old process.
|
||||
const effectiveMode = mode || 'claude';
|
||||
const modeConfigKey = {
|
||||
opencode: 'openCodeConfig',
|
||||
pi: 'piConfig',
|
||||
grok: 'grokConfig',
|
||||
omp: 'ompConfig',
|
||||
}[effectiveMode];
|
||||
// codex/gemini/antigravity have no wired continuation here yet (their
|
||||
// configs use an exact conversation id, not a "continue most recent"
|
||||
// flag, and the row's own `sessionId` is not verified to carry that
|
||||
// id for these three modes) — `continuesSomething` below is what keeps
|
||||
// their row from being retired for a resume that didn't actually
|
||||
// continue anything.
|
||||
const modeConfig =
|
||||
modeConfigKey
|
||||
? { [modeConfigKey]: { continueSession: true } }
|
||||
: effectiveMode === 'deepseek'
|
||||
? { deepSeekConfig: { resumeSession: true } }
|
||||
: {};
|
||||
const continuesSomething = Boolean(modeConfigKey) || effectiveMode === 'deepseek';
|
||||
const createRes = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workingDir,
|
||||
name,
|
||||
resumeSessionId: sessionId,
|
||||
mode: effectiveMode,
|
||||
...(effectiveMode === 'claude' ? { resumeSessionId: sessionId } : {}),
|
||||
...modeConfig,
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
}),
|
||||
@@ -2944,6 +2976,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Start interactive
|
||||
await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' });
|
||||
|
||||
// Retire the row being resumed: a non-claude "resume" is really a brand
|
||||
// new Codeman session pointed at the same directory (there is no id to
|
||||
// reattach to), so without this every resume leaves the old row behind
|
||||
// as a duplicate — click it 3 times, see the same name 3 times. Claude
|
||||
// rows are left alone: `sessionId` there is a claudeSessionId, which
|
||||
// usually has no live/persisted Codeman session of its own to delete.
|
||||
// Gated on `continuesSomething`: for codex/gemini/antigravity (no
|
||||
// continuation wired above), this is really a FRESH session with no
|
||||
// relation to the old row's conversation, so retiring it would discard
|
||||
// the old conversation with no recovery — worse than the duplicate row
|
||||
// this guard exists to prevent for the modes that DO continue.
|
||||
if (effectiveMode !== 'claude' && continuesSomething && sessionId !== newSessionId) {
|
||||
fetch(`/api/sessions/${sessionId}?killMux=true`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
|
||||
this.terminal.writeln(`\x1b[90m Session ${name} ready\x1b[0m`);
|
||||
await this.selectSession(newSessionId);
|
||||
this.terminal.focus();
|
||||
|
||||
@@ -8,11 +8,10 @@
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { realpathSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import type { z } from 'zod';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { Session } from '../session.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser, type SessionState } from '../types.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
@@ -21,12 +20,15 @@ import type { EventPort } from './ports/event-port.js';
|
||||
import type { AuthSessionRecord } from './ports/auth-port.js';
|
||||
import type { StaleExpirationMap } from '../utils/index.js';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { getCasesDir } from '../config/cases-dir.js';
|
||||
import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js';
|
||||
import { SYNTHETIC_ADMIN, findUser } from '../user-store.js';
|
||||
|
||||
// Shared path constants used across route modules. CASES_DIR (project folders)
|
||||
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
|
||||
export const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
// The cases dir is resolved in ONE place (config/cases-dir.ts) because the CLI
|
||||
// resolves it too, and CODEMAN_CASES_PATH must move both or neither.
|
||||
export const CASES_DIR = getCasesDir();
|
||||
export const SETTINGS_PATH = dataPath('settings.json');
|
||||
|
||||
/**
|
||||
@@ -264,6 +266,18 @@ export function revokeUserSessions(
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 404 both session-lookup helpers below throw. A missing session and one
|
||||
* the caller isn't allowed to see get the IDENTICAL error (never 403), so
|
||||
* existence of another user's session is never leaked.
|
||||
*/
|
||||
function sessionNotFoundError(sessionId: string): Error & { statusCode: number; body: unknown } {
|
||||
return Object.assign(new Error(`Session ${sessionId} not found`), {
|
||||
statusCode: 404,
|
||||
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a session by ID or throw a structured error.
|
||||
* Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`.
|
||||
@@ -274,15 +288,31 @@ export function revokeUserSessions(
|
||||
*/
|
||||
export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: FastifyRequest): Session {
|
||||
const session = ctx.sessions.get(sessionId);
|
||||
if (!session || (req && !canAccessOwned(getAuthUser(req), session.owner))) {
|
||||
throw Object.assign(new Error(`Session ${sessionId} not found`), {
|
||||
statusCode: 404,
|
||||
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
|
||||
});
|
||||
}
|
||||
if (!session) throw sessionNotFoundError(sessionId);
|
||||
if (req && !canAccessOwned(getAuthUser(req), session.owner)) throw sessionNotFoundError(sessionId);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link findSessionOrFail}, for a session that exists ONLY in persisted
|
||||
* state — a resumed-but-never-reattached row (e.g. a non-claude "Resume" that
|
||||
* relaunched into a new session and wants to retire the row it can no longer
|
||||
* reattach to) has no live `Session` instance for `findSessionOrFail` to
|
||||
* return, so this returns the persisted record instead. Same ownership
|
||||
* enforcement, same 404-not-403 leak protection — this is that function's
|
||||
* missing other half, not a separate check reimplemented inline.
|
||||
*/
|
||||
export function findPersistedSessionOrFail(
|
||||
store: { getSession(id: string): SessionState | null },
|
||||
sessionId: string,
|
||||
req?: FastifyRequest
|
||||
): SessionState {
|
||||
const persisted = store.getSession(sessionId);
|
||||
if (!persisted) throw sessionNotFoundError(sessionId);
|
||||
if (req && !canAccessOwned(getAuthUser(req), persisted.owner)) throw sessionNotFoundError(sessionId);
|
||||
return persisted;
|
||||
}
|
||||
|
||||
/** Shortest prefix accepted for a parent session id (see resolveParentSessionId). */
|
||||
const PARENT_SESSION_ID_MIN_PREFIX = 8;
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
UserStoreError,
|
||||
} from '../../user-store.js';
|
||||
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { appendAdminAudit } from '../admin-audit.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { AuthPort } from '../ports/auth-port.js';
|
||||
@@ -179,7 +180,10 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const revoked = revokeUserSessions(ctx.authSessions, username);
|
||||
audit(req, 'user.logout', username, { revoked });
|
||||
// Web-tab proxy capabilities are a second credential the cookie purge does not
|
||||
// touch; a forced logout that left them alive would not be a logout.
|
||||
const revokedWebviews = webviewCapabilities.revokeOwner(normalizeUsername(username));
|
||||
audit(req, 'user.logout', username, { revoked, revokedWebviews });
|
||||
return { success: true, data: { revoked } };
|
||||
});
|
||||
|
||||
@@ -201,6 +205,7 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
await ctx.cleanupSession(id, true, 'admin_delete_user').catch(() => {});
|
||||
}
|
||||
revokeUserSessions(ctx.authSessions, username);
|
||||
webviewCapabilities.revokeOwner(normalizeUsername(username));
|
||||
if (deleteSpace) await deleteUserSpace(username);
|
||||
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
|
||||
@@ -72,7 +72,7 @@ import {
|
||||
probeAdoptableContainer,
|
||||
listDockerContainers,
|
||||
browseInContainer,
|
||||
DOCKER_ADOPT_PROBE_MODES,
|
||||
dockerAdoptProbeModes,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
removeDockerContainer,
|
||||
@@ -845,11 +845,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// hostWorkspacePath only because that is what an owned container's bind
|
||||
// mount guarantees; adoption mounts nothing, so the probe has to prove it.
|
||||
const adoptDocker = toSessionDocker(host, dockerCase);
|
||||
const probe = await probeAdoptableContainer(
|
||||
adoptDocker,
|
||||
[...DOCKER_ADOPT_PROBE_MODES],
|
||||
adoptDocker.containerWorkdir
|
||||
);
|
||||
const probe = await probeAdoptableContainer(adoptDocker, dockerAdoptProbeModes(), adoptDocker.containerWorkdir);
|
||||
if (!probe.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable');
|
||||
}
|
||||
@@ -930,7 +926,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
daemonHost: host.daemonHost,
|
||||
containerName: body.container,
|
||||
},
|
||||
[...DOCKER_ADOPT_PROBE_MODES],
|
||||
dockerAdoptProbeModes(),
|
||||
body.containerWorkdir
|
||||
);
|
||||
return { success: true, data: probe };
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js';
|
||||
import { canAccessOwned, getAuthUser, isWorkingDirAllowed, ownerFor, parseBody } from '../route-helpers.js';
|
||||
@@ -45,7 +46,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
// Resolve the owner's grant from the store (AuthUser.role alone can't tell a GRANTED
|
||||
// regular user from a plain one); mirrors session-routes + the cron fire-time re-check.
|
||||
if (
|
||||
(body.agentType === 'shell' || body.launchCommand) &&
|
||||
(getCli(body.agentType)?.capabilities.privilegedCommandGate || body.launchCommand) &&
|
||||
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
|
||||
) {
|
||||
return createErrorResponse(
|
||||
@@ -72,7 +73,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||||
}
|
||||
if (
|
||||
(body.agentType === 'shell' || body.launchCommand) &&
|
||||
(getCli(body.agentType ?? 'claude')?.capabilities.privilegedCommandGate || body.launchCommand) &&
|
||||
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
|
||||
) {
|
||||
return createErrorResponse(
|
||||
|
||||
+291
-252
@@ -20,15 +20,18 @@ import {
|
||||
type ApiResponse,
|
||||
type SessionColor,
|
||||
type SessionStatus,
|
||||
type SessionMode,
|
||||
type CodexConfig,
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
} from '../../types.js';
|
||||
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
||||
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import {
|
||||
CreateSessionSchema,
|
||||
SessionNameSchema,
|
||||
@@ -65,6 +68,7 @@ import {
|
||||
autoConfigureRalph,
|
||||
canAccessOwned,
|
||||
CASES_DIR,
|
||||
findPersistedSessionOrFail,
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
@@ -79,6 +83,9 @@ import {
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
||||
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
||||
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import {
|
||||
@@ -137,6 +144,8 @@ import {
|
||||
toSessionDocker,
|
||||
} from '../../docker-hosts.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
||||
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
||||
import {
|
||||
getLastTranscriptResponse,
|
||||
isExternalCliTranscriptMode,
|
||||
@@ -352,12 +361,54 @@ export function _resetPasteRateBuckets(): void {
|
||||
*/
|
||||
async function clampExternalCliBypassForOwner(
|
||||
owner: string | undefined,
|
||||
codexConfig: CodexConfig | undefined,
|
||||
geminiConfig: GeminiConfig | undefined,
|
||||
antigravityConfig: AntigravityConfig | undefined,
|
||||
piConfig: PiConfig | undefined,
|
||||
grokConfig: GrokConfig | undefined,
|
||||
deepSeekConfig: DeepSeekConfig | undefined
|
||||
configs: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (await canUsernameRunPrivilegedCommands(owner)) return configs;
|
||||
|
||||
const out = { ...configs };
|
||||
for (const entry of enabledClis()) {
|
||||
const field = entry.launch.legacyConfigField;
|
||||
if (!field) continue;
|
||||
// `privilegedParams[].param` names the REGISTRY param, so it has to be translated to the
|
||||
// legacy wire field on the way out — the same `legacyConfigAliases` hop `configSetenvValues`
|
||||
// already makes. Writing `param` straight through would put it in a DIFFERENT namespace
|
||||
// from every other `param` in the schema, and a name that is right in one and wrong in the
|
||||
// other is a SILENT no-op: no load error, no failing test, the clamp simply stops clamping.
|
||||
// Codex is where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`),
|
||||
// and `schema.ts` refuses an entry naming a param it never declared.
|
||||
const aliases = entry.launch.legacyConfigAliases ?? {};
|
||||
const existing = out[field] as Record<string, unknown> | undefined;
|
||||
let next = existing;
|
||||
for (const { param, clampTo, materializeWhenAbsent } of entry.capabilities.privilegedParams) {
|
||||
// MATERIALIZE vs ONLY-IF-SENT is the whole design of this clamp, and the two are not
|
||||
// interchangeable — see CliCapabilities.privilegedParams. Materialize where the CLI's
|
||||
// own absent-config default is ITSELF unsafe (gemini defaults to yolo; pi's default is
|
||||
// an interactive trust prompt the session user could just answer "yes" to), so a
|
||||
// caller who sends no config at all still gets clamped.
|
||||
if (next === undefined && !materializeWhenAbsent) continue;
|
||||
next = { ...(next ?? {}), [aliases[param] ?? param]: clampTo };
|
||||
}
|
||||
if (next !== existing) out[field] = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test hook, and the positional shape the clamp has always been called with in tests.
|
||||
*
|
||||
* The clamp itself is now generic over the registry, which is what makes a CUSTOM CLI's
|
||||
* privileged flag clampable with no code here — previously the five config objects were
|
||||
* named individually, so `privilegedParams` on anything outside that list was declared but
|
||||
* unreachable.
|
||||
*/
|
||||
export async function _clampExternalCliBypassForOwner(
|
||||
owner: string | undefined,
|
||||
codexConfig?: CodexConfig,
|
||||
geminiConfig?: GeminiConfig,
|
||||
antigravityConfig?: AntigravityConfig,
|
||||
piConfig?: PiConfig,
|
||||
grokConfig?: GrokConfig,
|
||||
deepSeekConfig?: DeepSeekConfig
|
||||
): Promise<{
|
||||
codexConfig: CodexConfig | undefined;
|
||||
geminiConfig: GeminiConfig | undefined;
|
||||
@@ -366,40 +417,30 @@ async function clampExternalCliBypassForOwner(
|
||||
grokConfig: GrokConfig | undefined;
|
||||
deepSeekConfig: DeepSeekConfig | undefined;
|
||||
}> {
|
||||
const granted = await canUsernameRunPrivilegedCommands(owner);
|
||||
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig };
|
||||
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
|
||||
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default)
|
||||
// and pi to --no-approve (clamps an explicit true AND pi's own "ask" default).
|
||||
const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
|
||||
const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
|
||||
const clampedAntigravity = antigravityConfig
|
||||
? { ...antigravityConfig, dangerouslySkipPermissions: false }
|
||||
: antigravityConfig;
|
||||
const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false };
|
||||
const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig;
|
||||
const clampedDeepSeek = deepSeekConfig
|
||||
? { ...deepSeekConfig, permissionMode: 'workspace-write' as const }
|
||||
: deepSeekConfig;
|
||||
return {
|
||||
codexConfig: clampedCodex,
|
||||
geminiConfig: clampedGemini,
|
||||
antigravityConfig: clampedAntigravity,
|
||||
piConfig: clampedPi,
|
||||
grokConfig: clampedGrok,
|
||||
deepSeekConfig: clampedDeepSeek,
|
||||
const out = await clampExternalCliBypassForOwner(owner, {
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
});
|
||||
return out as {
|
||||
codexConfig: CodexConfig | undefined;
|
||||
geminiConfig: GeminiConfig | undefined;
|
||||
antigravityConfig: AntigravityConfig | undefined;
|
||||
piConfig: PiConfig | undefined;
|
||||
grokConfig: GrokConfig | undefined;
|
||||
deepSeekConfig: DeepSeekConfig | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */
|
||||
export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
|
||||
|
||||
/**
|
||||
* Env-var keys a non-granted owner must not be able to set, because each one
|
||||
* hands back privilege the config clamp above just removed — or, for the last,
|
||||
* redirects a credential the server injects.
|
||||
* hands back privilege the config clamp above just removed, or redirects a
|
||||
* credential-resolution endpoint.
|
||||
*
|
||||
* All are DeepSeek's, and all are reachable because `DSH_*` and `DEEPSEEK_*` are
|
||||
* The DeepSeek three are reachable because `DSH_*` and `DEEPSEEK_*` are
|
||||
* allowlisted `envOverrides` prefixes (schemas.ts) — which they have to be, since
|
||||
* that is also how a user configures the harness's non-privileged knobs.
|
||||
*
|
||||
@@ -410,26 +451,38 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
|
||||
* - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code
|
||||
* executes at BOOT, before any approval row can apply. A user who can write a
|
||||
* workspace can put a profile in it, so this is the wider of the two.
|
||||
* - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureDeepSeek()`
|
||||
* - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureCliEnv()`
|
||||
* forwards the SERVER's own `DEEPSEEK_API_KEY` into every dsh pane before
|
||||
* `applyEnvOverrides()` runs — so a non-granted owner who could set the base
|
||||
* URL would have the operator's API key sent as a bearer credential to a host
|
||||
* of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying
|
||||
* your OWN key removes privilege rather than granting it.)
|
||||
* - `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are where omp resolves
|
||||
* credentials from — the same shape as `DEEPSEEK_BASE_URL` above, reachable
|
||||
* because `OMP_*` is an allowlisted prefix. Unlike DeepSeek, Codeman does not
|
||||
* forward any operator-held key into an omp pane today (omp's provider
|
||||
* credentials live in `~/.omp` config files, not env vars), so there is no
|
||||
* known concrete exfiltration path yet — clamped defensively anyway, since a
|
||||
* non-granted owner redirecting where a shared multi-tenant deployment
|
||||
* resolves auth from is not something to allow silently (found in
|
||||
* Ark0N/Codeman#353 review; omp's own knobs are otherwise mostly `PI_*`,
|
||||
* already allowlisted for pi and not addressed here — see resolveOmpHome()).
|
||||
*/
|
||||
const OWNER_CLAMPED_ENV_KEYS = ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'] as const;
|
||||
function ownerClampedEnvKeys(): string[] {
|
||||
return enabledClis().flatMap((entry) => entry.capabilities.privilegedEnvKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Env-var half of the multi-user bypass clamp.
|
||||
*
|
||||
* `clampExternalCliBypassForOwner()` clamps the per-CLI CONFIG, and for every CLI
|
||||
* but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs
|
||||
* AFTER `_configureDeepSeek()` in tmux-manager, so an override sent on the SAME
|
||||
* AFTER `_configureCliEnv()` in tmux-manager, so an override sent on the SAME
|
||||
* request lands last and wins, and a non-granted owner could restore
|
||||
* `danger-full-access` on the very request the config clamp downgraded.
|
||||
*
|
||||
* Keys are DROPPED rather than rewritten: dropping falls through to what
|
||||
* `_configureDeepSeek()` exports, which is the clamped config and the server's own
|
||||
* `_configureCliEnv()` exports, which is the clamped config and the server's own
|
||||
* `DSH_HOME`, i.e. exactly the intended state. No-op in single-user mode and for a
|
||||
* granted owner, like every other clamp here
|
||||
* (`canUsernameRunPrivilegedCommands()` returns true when `!isMultiUserMode()`),
|
||||
@@ -440,38 +493,17 @@ async function clampEnvOverridesForOwner(
|
||||
envOverrides: Record<string, string> | undefined
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
if (!envOverrides) return envOverrides;
|
||||
if (!OWNER_CLAMPED_ENV_KEYS.some((key) => key in envOverrides)) return envOverrides;
|
||||
const keys = ownerClampedEnvKeys();
|
||||
if (!keys.some((key) => key in envOverrides)) return envOverrides;
|
||||
if (await canUsernameRunPrivilegedCommands(owner)) return envOverrides;
|
||||
const clamped = { ...envOverrides };
|
||||
for (const key of OWNER_CLAMPED_ENV_KEYS) delete clamped[key];
|
||||
for (const key of keys) delete clamped[key];
|
||||
return clamped;
|
||||
}
|
||||
|
||||
/** Test hook: the env-var half of the same multi-user safety gate. */
|
||||
export const _clampEnvOverridesForOwner = clampEnvOverridesForOwner;
|
||||
|
||||
/**
|
||||
* Why a DeepSeek session cannot start, or null when it can.
|
||||
*
|
||||
* Availability for this mode is TWO questions, not one, because `dsh` is a
|
||||
* profile launcher rather than an agent: the binary must resolve (and prove it
|
||||
* is the harness and not Debian's dancer's shell), AND a profile that can occupy
|
||||
* a pane must exist. Reporting only the first would let the Run button spawn a
|
||||
* pane that dies instantly, which is the single most confusing failure this mode
|
||||
* can produce, so each half gets its own actionable message.
|
||||
*
|
||||
* A profile named EXPLICITLY is checked on both counts: existence, and whether
|
||||
* it is pane-capable — `web` serves a browser UI and `headless` answers one task
|
||||
* and exits, so both would present as "the tab immediately died".
|
||||
*/
|
||||
async function resolveDeepSeekLaunchError(requestedProfile?: string): Promise<string | null> {
|
||||
// Thin async wrapper: the implementation moved into the resolver module so
|
||||
// CRON fires can ask the same question before constructing a Session; the
|
||||
// dynamic import keeps this file's startup free of the probe machinery.
|
||||
const { resolveDeepSeekLaunchError: impl } = await import('../../utils/deepseek-cli-resolver.js');
|
||||
return impl(requestedProfile);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -745,6 +777,36 @@ async function injectAgentSkill(casePath: string): Promise<void> {
|
||||
// bypassing the `workspaceHooksEnabled` setting. Route handlers here resolve the
|
||||
// setting through the ConfigPort (tests stub it) and pass it as the second arg.
|
||||
|
||||
/**
|
||||
* A "Resume"/"continue" request for a NEW omp-mode session (the frontend's
|
||||
* resumeHistorySession(), or anyone hitting the API directly) carries
|
||||
* `continueSession: true` but no id — omp has none to give it, since Codeman
|
||||
* has never tracked its own conversation UUID. Left as `--continue`, that
|
||||
* picks whichever session file in the directory is newest, which silently
|
||||
* drifts to the WRONG conversation the moment a second omp session (this
|
||||
* one, a sibling worker, a stray manual run) has touched the same directory
|
||||
* more recently. Resolve the real id up front instead, same as the
|
||||
* dead-pane-respawn path in session.ts does, so even the FIRST relaunch of a
|
||||
* resumed conversation is pinned rather than guessed.
|
||||
*/
|
||||
export function resolveOmpConfigForCreate(
|
||||
mode: SessionMode,
|
||||
workingDir: string,
|
||||
ompConfig: OmpConfig | undefined
|
||||
): OmpConfig | undefined {
|
||||
if (mode !== 'omp') return undefined;
|
||||
if (!ompConfig || ompConfig.resumeSessionId || !ompConfig.continueSession) {
|
||||
return ompConfig;
|
||||
}
|
||||
const resolvedId = findLatestOmpSessionId(workingDir);
|
||||
if (!resolvedId) {
|
||||
console.warn(
|
||||
`[Session] OMP: no session file found under ${workingDir} to pin --resume; falling back to ambiguous --continue`
|
||||
);
|
||||
}
|
||||
return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig;
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
|
||||
@@ -761,6 +823,10 @@ export function registerSessionRoutes(
|
||||
if (sessionToken) {
|
||||
ctx.authSessions?.delete(sessionToken);
|
||||
}
|
||||
// The web-tab proxy authenticates on capabilities, not on this cookie, so a
|
||||
// logout has to retire them too or every dashboard URL opened during this
|
||||
// login keeps relaying without one (WebviewCapabilityStore.revokeOwner).
|
||||
webviewCapabilities.revokeOwner(ownerFor(req));
|
||||
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
||||
return {};
|
||||
});
|
||||
@@ -826,7 +892,10 @@ export function registerSessionRoutes(
|
||||
// Multi-user: shell mode is arbitrary command execution as the host account,
|
||||
// gated behind the same grant as bypass (section 6.3). Resolve the owner's grant
|
||||
// from the store so a GRANTED regular user is not wrongly denied (AuthUser role alone can't tell).
|
||||
if (body.mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||||
if (
|
||||
getCli(body.mode ?? 'claude')?.capabilities.privilegedCommandGate &&
|
||||
!(await canUsernameRunPrivilegedCommands(owner))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
||||
}
|
||||
|
||||
@@ -859,14 +928,11 @@ export function registerSessionRoutes(
|
||||
// repos that POST /api/sessions can target, as those may have hand-authored
|
||||
// values).
|
||||
const managedCasesBase = resolveCasesDir(getAuthUser(req));
|
||||
// `!isExternalCliMode()` is byte-identical to the eight-mode `!==` chain it replaces
|
||||
// (claude and shell are the two non-external modes) and, unlike the chain, cannot fall
|
||||
// behind the next CLI added.
|
||||
const canStripDisk =
|
||||
body.mode !== 'opencode' &&
|
||||
body.mode !== 'codex' &&
|
||||
body.mode !== 'gemini' &&
|
||||
body.mode !== 'antigravity' &&
|
||||
body.mode !== 'pi' &&
|
||||
body.mode !== 'grok' &&
|
||||
body.mode !== 'deepseek' &&
|
||||
!isExternalCliMode(body.mode ?? 'claude') &&
|
||||
body.envOverrides &&
|
||||
Object.keys(body.envOverrides).length > 0 &&
|
||||
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
|
||||
@@ -918,52 +984,24 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Check OpenCode availability if requested. The error text comes from the
|
||||
// resolver (formatCliNotFoundMessage) so it names where resolution looked —
|
||||
// server PATH, login shell, common directories — same for the modes below.
|
||||
if (body.mode === 'opencode') {
|
||||
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import('../../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex availability if requested
|
||||
if (body.mode === 'codex') {
|
||||
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
|
||||
if (!isCodexAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Gemini availability if requested
|
||||
if (body.mode === 'gemini') {
|
||||
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
|
||||
if (!isGeminiAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
if (body.mode === 'antigravity') {
|
||||
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
|
||||
await import('../../utils/antigravity-cli-resolver.js');
|
||||
if (!isAntigravityAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
|
||||
}
|
||||
}
|
||||
if (body.mode === 'pi') {
|
||||
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
|
||||
if (!isPiAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
if (body.mode === 'deepseek') {
|
||||
const err = await resolveDeepSeekLaunchError(body.deepSeekConfig?.profile);
|
||||
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
|
||||
}
|
||||
if (body.mode === 'grok') {
|
||||
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
|
||||
if (!isGrokAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage());
|
||||
// Refuse up front if the requested CLI cannot start, rather than spawning a pane that
|
||||
// dies on `command not found`. The message comes from the resolver, so it names where
|
||||
// resolution actually looked (server PATH, login shell, the entry's search dirs); a
|
||||
// LAUNCHER CLI answers with its own more specific reason instead — for dsh, whether the
|
||||
// binary is missing, no pane-capable profile exists, or the profile the caller NAMED
|
||||
// cannot drive a pane, which are three different things to go and fix.
|
||||
//
|
||||
// Scoped to EXTERNAL CLIs, matching what this route has always pre-flighted: claude and
|
||||
// shell deliberately fall through to tmux-manager's own not-found throw instead, and
|
||||
// pulling them forward here would change which error a missing claude produces.
|
||||
const requestedMode = body.mode ?? 'claude';
|
||||
if (getCli(requestedMode)?.capabilities.external) {
|
||||
const cliLaunchError = await resolveCliLaunchError(
|
||||
requestedMode,
|
||||
legacyConfigForMode(requestedMode, body as unknown as Record<string, unknown>)
|
||||
);
|
||||
if (cliLaunchError) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, cliLaunchError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1000,25 +1038,25 @@ export function registerSessionRoutes(
|
||||
const globalNice = await ctx.getGlobalNiceConfig();
|
||||
const modelConfig = await ctx.getModelConfig();
|
||||
const mode = body.mode || 'claude';
|
||||
// Where a model override comes from is a capability, and the three answers are
|
||||
// genuinely different mechanisms:
|
||||
// 'flag' — the CLI takes --model, so read the value the caller sent
|
||||
// in that CLI's own config object.
|
||||
// 'claude-settings-file' — claude alone, whose model is written to
|
||||
// <case>/.claude/settings.local.json rather than passed as
|
||||
// a flag, so the app-wide default applies here.
|
||||
// 'none' — shell has no model; deepseek's is a composition entry in
|
||||
// the profile's config tree, not a session field
|
||||
// (docs/deepseek-integration.md). Both get nothing.
|
||||
const modelSource = getCli(mode)?.capabilities.model;
|
||||
const model =
|
||||
mode === 'opencode'
|
||||
? body.openCodeConfig?.model
|
||||
: mode === 'codex'
|
||||
? body.codexConfig?.model
|
||||
: mode === 'gemini'
|
||||
? body.geminiConfig?.model
|
||||
: mode === 'antigravity'
|
||||
? body.antigravityConfig?.model
|
||||
: mode === 'pi'
|
||||
? body.piConfig?.model
|
||||
: mode === 'grok'
|
||||
? body.grokConfig?.model
|
||||
: // DeepSeek's model is a composition entry in the profile's config
|
||||
// tree, not a session flag, so there is deliberately nothing to
|
||||
// read here (see docs/deepseek-integration.md).
|
||||
mode !== 'shell' && mode !== 'deepseek'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
modelSource?.source === 'flag'
|
||||
? (legacyConfigForMode(mode, body as unknown as Record<string, unknown>)?.[modelSource.param ?? 'model'] as
|
||||
| string
|
||||
| undefined)
|
||||
: modelSource?.source === 'claude-settings-file'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
// Section 6.3: force non-granted users to a classifier-guarded mode.
|
||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
|
||||
@@ -1030,7 +1068,7 @@ export function registerSessionRoutes(
|
||||
piConfig: gatedPiConfig,
|
||||
grokConfig: gatedGrokConfig,
|
||||
deepSeekConfig: gatedDeepSeekConfig,
|
||||
} = await clampExternalCliBypassForOwner(
|
||||
} = await _clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
body.codexConfig,
|
||||
body.geminiConfig,
|
||||
@@ -1057,6 +1095,7 @@ export function registerSessionRoutes(
|
||||
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
|
||||
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
|
||||
ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig),
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
|
||||
effort: body.effort,
|
||||
@@ -1072,7 +1111,7 @@ export function registerSessionRoutes(
|
||||
await ctx.setupSessionListeners(session);
|
||||
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
|
||||
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
|
||||
if (mode === 'claude' && !remote && (await ctx.getAgentSkillEnabled())) {
|
||||
if (getCli(mode)?.capabilities.agentSkillInjection && !remote && (await ctx.getAgentSkillEnabled())) {
|
||||
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
|
||||
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
|
||||
);
|
||||
@@ -1125,9 +1164,26 @@ export function registerSessionRoutes(
|
||||
const query = req.query as { killMux?: string };
|
||||
const killMux = query.killMux !== 'false'; // Default to true
|
||||
|
||||
// Security: owner-scoped lookup 404s foreign/missing sessions uniformly (no existence leak, no cross-user kill).
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// A resumed/detached-but-never-live row (e.g. a non-claude "Resume" that
|
||||
// relaunched into a NEW session and wants to retire the old one it can no
|
||||
// longer reattach to) has no entry in ctx.sessions at all — only in
|
||||
// persisted state. Fall back to removing that persisted record directly
|
||||
// rather than 404ing: the caller means "make this row go away", and a
|
||||
// stale duplicate row is exactly what's left behind otherwise. Pinned
|
||||
// sessions keep their existing demote-not-delete protection.
|
||||
if (!ctx.sessions.has(id)) {
|
||||
// Called for its existence/ownership 404 side effect only — demoteOrRemoveSession
|
||||
// below re-looks-up the record by id, so the returned SessionState is unused here.
|
||||
findPersistedSessionOrFail(ctx.store, id, req);
|
||||
ctx.store.demoteOrRemoveSession(id);
|
||||
// Mirrors the broadcast at the tail of the live-session cleanup path
|
||||
// (_doCleanupSession in server.ts) — without it, other open tabs keep
|
||||
// showing the retired row until their next unrelated fetch.
|
||||
ctx.broadcast(SseEvent.SessionDeleted, { id });
|
||||
return {};
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
await ctx.cleanupSession(session.id, killMux, 'user_delete');
|
||||
return {};
|
||||
});
|
||||
@@ -1277,19 +1333,21 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
|
||||
// Ralph tracker is not supported for opencode / codex / gemini / antigravity / pi sessions.
|
||||
// Keep this list in step with isExternalCliMode(): _processExpensiveParsers() returns early
|
||||
// for those modes, so a tracker enabled here would never be fed, and the session would
|
||||
// still report ralphEnabled + Ralph UI state that no other external CLI shows.
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally
|
||||
// enabled and not explicitly disabled by user).
|
||||
//
|
||||
// `isExternalCliMode()` is what the eight-mode `!==` chain this replaces was FOR: its
|
||||
// own comment asked the next person to keep the list in step with that predicate by
|
||||
// hand. Calling it instead is byte-identical today (claude and shell are the two
|
||||
// non-external modes, exactly what the chain admitted) and cannot drift.
|
||||
//
|
||||
// ⚠️ Deliberately NOT `capabilities.ralph`, which the quick-start path below reads:
|
||||
// that capability is claude-only, so using it here would stop auto-enabling Ralph for
|
||||
// SHELL sessions, which this path has always done. The two paths genuinely disagree
|
||||
// about shell, and they disagree upstream too — reconciling them is a behaviour change
|
||||
// and belongs in its own PR, not in a refactor that is meant to change nothing.
|
||||
if (
|
||||
session.mode !== 'opencode' &&
|
||||
session.mode !== 'codex' &&
|
||||
session.mode !== 'gemini' &&
|
||||
session.mode !== 'antigravity' &&
|
||||
session.mode !== 'pi' &&
|
||||
session.mode !== 'grok' &&
|
||||
session.mode !== 'deepseek' &&
|
||||
!isExternalCliMode(session.mode) &&
|
||||
ctx.store.getConfig().ralphEnabled &&
|
||||
!session.ralphTracker.autoEnableDisabled
|
||||
) {
|
||||
@@ -2028,7 +2086,7 @@ export function registerSessionRoutes(
|
||||
// Codex sessions don't write to ~/.claude/projects — their transcripts
|
||||
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
|
||||
// response-viewer works for Codex panes too.
|
||||
if (session.mode === 'codex') {
|
||||
if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout') {
|
||||
const codexQuery = req.query as { context?: string };
|
||||
return await readCodexLastResponse(session, codexQuery.context === 'full');
|
||||
}
|
||||
@@ -2048,7 +2106,7 @@ export function registerSessionRoutes(
|
||||
// and return "nothing said yet" forever — an agent polling that worker
|
||||
// would starve on an answer that exists. Those configurations keep the
|
||||
// pane segmenter below: coarse, but the real conversation.
|
||||
if (session.mode === 'deepseek' && !session.docker && !session.remote) {
|
||||
if (getCli(session.mode)?.capabilities.transcript === 'deepseek-zstd' && !session.docker && !session.remote) {
|
||||
const deepSeekQuery = req.query as { context?: string };
|
||||
const full = deepSeekQuery.context === 'full';
|
||||
const transcript = await readDeepSeekLastResponse(session, { blocks: full });
|
||||
@@ -2191,7 +2249,7 @@ export function registerSessionRoutes(
|
||||
const WINDOW_MS = 15_000;
|
||||
const otherSubmits: number[] = [];
|
||||
for (const s of ctx.sessions.values()) {
|
||||
if (s.id !== session.id && s.mode === 'codex' && s.lastSubmitAt) {
|
||||
if (s.id !== session.id && getCli(s.mode)?.capabilities.transcript === 'codex-rollout' && s.lastSubmitAt) {
|
||||
otherSubmits.push(s.lastSubmitAt);
|
||||
}
|
||||
}
|
||||
@@ -2536,7 +2594,8 @@ export function registerSessionRoutes(
|
||||
// During long thinking phases, Ink rewrites the same rows thousands of times
|
||||
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
||||
// the terminal appears empty when switching tabs.
|
||||
let strippedBuffer = session.mode === 'shell' ? rawBuffer : stripInkRedrawBloat(rawBuffer);
|
||||
let strippedBuffer =
|
||||
getCli(session.mode)?.capabilities.stripInkBloat === false ? rawBuffer : stripInkRedrawBloat(rawBuffer);
|
||||
|
||||
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
|
||||
// streams. xterm.js obeys them by switching to its scrollback-less alt
|
||||
@@ -2858,6 +2917,7 @@ export function registerSessionRoutes(
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
envOverrides,
|
||||
effort,
|
||||
parentSessionId,
|
||||
@@ -2865,7 +2925,7 @@ export function registerSessionRoutes(
|
||||
|
||||
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
|
||||
// Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
|
||||
if (mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||||
if (getCli(mode)?.capabilities.privilegedCommandGate && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
||||
}
|
||||
|
||||
@@ -2908,6 +2968,7 @@ export function registerSessionRoutes(
|
||||
piConfig ||
|
||||
grokConfig ||
|
||||
deepSeekConfig ||
|
||||
ompConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
@@ -2942,6 +3003,7 @@ export function registerSessionRoutes(
|
||||
piConfig ||
|
||||
grokConfig ||
|
||||
deepSeekConfig ||
|
||||
ompConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
@@ -2971,7 +3033,9 @@ export function registerSessionRoutes(
|
||||
// The probe already exec'd into the container; carry its facts onto the
|
||||
// live session so the launch chain does not have to re-ask.
|
||||
sessionDocker.runsAsRoot = probe.runsAsRoot;
|
||||
if (mode !== 'shell' && !probe.availableModes?.includes(mode)) {
|
||||
// No `mode !== 'shell'` arm: a mode with no binary of its own is reported
|
||||
// available by the probe unconditionally, so this reads the same answer for it.
|
||||
if (!probe.availableModes?.includes(mode)) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`"${mode}" is not installed in container "${sessionDocker.containerName}". Adoption never modifies the container — install it inside, or pick another mode.`
|
||||
@@ -3016,69 +3080,35 @@ export function registerSessionRoutes(
|
||||
|
||||
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
|
||||
docker = sessionDocker;
|
||||
// Seed resume so a relaunch resumes the case's last conversation from the
|
||||
// bind-mounted transcript (decision: resume-on-start default ON).
|
||||
if (sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) {
|
||||
// Seed only Claude's resume id. Codex, Gemini, and the other CLIs have
|
||||
// separate conversation stores and must never receive a Claude UUID.
|
||||
if (mode === 'claude' && sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) {
|
||||
dockerResumeId = dockerCase.lastClaudeSessionId;
|
||||
}
|
||||
} else {
|
||||
// Check OpenCode availability if requested. Error text comes from the
|
||||
// resolver so it carries the resolution diagnostics; same for the modes below.
|
||||
if (mode === 'opencode') {
|
||||
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } =
|
||||
await import('../../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
|
||||
// Same pre-flight as POST /api/sessions: refuse before spawning a pane that would die
|
||||
// on `command not found`, with the resolver's own diagnostics, and a launcher CLI's
|
||||
// more specific reason (dsh: binary vs no pane-capable profile vs the profile the
|
||||
// caller named). External CLIs only — claude and shell fall through to tmux-manager's
|
||||
// own not-found throw, exactly as before.
|
||||
if (getCli(mode)?.capabilities.external) {
|
||||
const qsLaunchError = await resolveCliLaunchError(
|
||||
mode,
|
||||
legacyConfigForMode(mode, {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
} as unknown as Record<string, unknown>)
|
||||
);
|
||||
if (qsLaunchError) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, qsLaunchError);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex availability if requested
|
||||
if (mode === 'codex') {
|
||||
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
|
||||
if (!isCodexAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Gemini availability if requested
|
||||
if (mode === 'gemini') {
|
||||
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
|
||||
if (!isGeminiAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Antigravity availability if requested
|
||||
if (mode === 'antigravity') {
|
||||
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
|
||||
await import('../../utils/antigravity-cli-resolver.js');
|
||||
if (!isAntigravityAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Pi availability if requested
|
||||
if (mode === 'pi') {
|
||||
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
|
||||
if (!isPiAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Grok availability if requested
|
||||
if (mode === 'grok') {
|
||||
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
|
||||
if (!isGrokAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check DeepSeek Harness availability if requested (binary AND a pane-capable profile).
|
||||
if (mode === 'deepseek') {
|
||||
const err = await resolveDeepSeekLaunchError(deepSeekConfig?.profile);
|
||||
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -3125,14 +3155,15 @@ export function registerSessionRoutes(
|
||||
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
|
||||
|
||||
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||
// (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi and Grok use their own systems)
|
||||
// (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP use their own systems)
|
||||
if (
|
||||
mode !== 'opencode' &&
|
||||
mode !== 'codex' &&
|
||||
mode !== 'gemini' &&
|
||||
mode !== 'antigravity' &&
|
||||
mode !== 'pi' &&
|
||||
mode !== 'grok'
|
||||
mode !== 'grok' &&
|
||||
mode !== 'omp'
|
||||
) {
|
||||
await writeHooksConfig(resolvedCasePath);
|
||||
}
|
||||
@@ -3148,7 +3179,7 @@ export function registerSessionRoutes(
|
||||
// reads `.claude` hooks, so a shell/codex quick-start should not author a block
|
||||
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
|
||||
// doesn't exist on the local filesystem.
|
||||
if (mode === 'claude') {
|
||||
if (getCli(mode)?.capabilities.hooks === 'always') {
|
||||
await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
|
||||
} else {
|
||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||||
@@ -3160,7 +3191,7 @@ export function registerSessionRoutes(
|
||||
// (`.claude/skills/` is a Claude Code surface); skipped for remote cases, whose
|
||||
// casePath lives on another host. Docker cases qualify: hostWorkspacePath is a
|
||||
// real host dir and the skill crosses the bind mount like the rest of `.claude/`.
|
||||
if (!remote && mode === 'claude' && (await ctx.getAgentSkillEnabled())) {
|
||||
if (!remote && getCli(mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) {
|
||||
await injectAgentSkill(resolvedCasePath);
|
||||
}
|
||||
|
||||
@@ -3171,7 +3202,7 @@ export function registerSessionRoutes(
|
||||
// shell or external-CLI quick-start must not author a block of its own (the same
|
||||
// rule the existing-case branch above states; this branch used to exclude just
|
||||
// the five external CLIs and let `shell` through).
|
||||
if (docker && docker.hooksEnabled && mode === 'claude') {
|
||||
if (docker && docker.hooksEnabled && getCli(mode)?.capabilities.hooks === 'always') {
|
||||
try {
|
||||
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
|
||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||
@@ -3193,24 +3224,14 @@ export function registerSessionRoutes(
|
||||
// Model override → <case>/.claude/settings.local.json (claude-mode; local AND
|
||||
// docker — the docker workspace is a real host dir, so the settings file crosses
|
||||
// the bind mount and the in-container claude reads it). Remote was rejected above.
|
||||
if (mode === 'claude' && modelOverride !== undefined) {
|
||||
if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) {
|
||||
await updateCaseModel(resolvedCasePath, modelOverride || null);
|
||||
}
|
||||
|
||||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||
// see POST /api/sessions for full rationale).
|
||||
if (
|
||||
mode !== 'opencode' &&
|
||||
mode !== 'codex' &&
|
||||
mode !== 'gemini' &&
|
||||
mode !== 'antigravity' &&
|
||||
mode !== 'pi' &&
|
||||
mode !== 'grok' &&
|
||||
mode !== 'deepseek' &&
|
||||
!remote &&
|
||||
envOverrides &&
|
||||
Object.keys(envOverrides).length > 0
|
||||
) {
|
||||
// Same chain, same replacement as the create path above: byte-identical, drift-proof.
|
||||
if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) {
|
||||
await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
|
||||
}
|
||||
|
||||
@@ -3218,23 +3239,22 @@ export function registerSessionRoutes(
|
||||
// Apply global Nice priority config and model config from settings
|
||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||
const qsModelConfig = await ctx.getModelConfig();
|
||||
// See the create path for why this is a capability rather than a mode ladder.
|
||||
const qsModelSource = getCli(mode)?.capabilities.model;
|
||||
const qsModel =
|
||||
mode === 'opencode'
|
||||
? openCodeConfig?.model
|
||||
: mode === 'codex'
|
||||
? codexConfig?.model
|
||||
: mode === 'gemini'
|
||||
? geminiConfig?.model
|
||||
: mode === 'antigravity'
|
||||
? antigravityConfig?.model
|
||||
: mode === 'pi'
|
||||
? piConfig?.model
|
||||
: mode === 'grok'
|
||||
? grokConfig?.model
|
||||
: // DeepSeek's model lives in the profile's config tree, not here.
|
||||
mode !== 'shell' && mode !== 'deepseek'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
qsModelSource?.source === 'flag'
|
||||
? (legacyConfigForMode(mode, {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
} as unknown as Record<string, unknown>)?.[qsModelSource.param ?? 'model'] as string | undefined)
|
||||
: qsModelSource?.source === 'claude-settings-file'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
|
||||
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
@@ -3245,7 +3265,7 @@ export function registerSessionRoutes(
|
||||
piConfig: qsGatedPiConfig,
|
||||
grokConfig: qsGatedGrokConfig,
|
||||
deepSeekConfig: qsGatedDeepSeekConfig,
|
||||
} = await clampExternalCliBypassForOwner(
|
||||
} = await _clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
@@ -3274,6 +3294,7 @@ export function registerSessionRoutes(
|
||||
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
||||
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
||||
ompConfig: resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig),
|
||||
envOverrides: qsGatedEnvOverrides,
|
||||
effort,
|
||||
remote,
|
||||
@@ -3285,7 +3306,7 @@ export function registerSessionRoutes(
|
||||
|
||||
// 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' && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
|
||||
if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
|
||||
autoConfigureRalph(session, resolvedCasePath, ctx);
|
||||
if (!session.ralphTracker.enabled) {
|
||||
session.ralphTracker.enable();
|
||||
@@ -3299,7 +3320,7 @@ export function registerSessionRoutes(
|
||||
await ctx.setupSessionListeners(session);
|
||||
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
|
||||
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
|
||||
if (mode === 'claude' && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
|
||||
if (getCli(mode)?.capabilities.agentSkillInjection && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
|
||||
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
|
||||
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
|
||||
);
|
||||
@@ -3314,7 +3335,7 @@ export function registerSessionRoutes(
|
||||
|
||||
// Start in the appropriate mode
|
||||
try {
|
||||
if (mode === 'shell') {
|
||||
if (getCli(mode)?.capabilities.startMode === 'shell') {
|
||||
await session.startShell();
|
||||
getLifecycleLog().log({
|
||||
event: 'started',
|
||||
@@ -4122,6 +4143,24 @@ export function registerSessionRoutes(
|
||||
// Projects dir may not exist.
|
||||
}
|
||||
|
||||
// OMP's own session files (~/.omp/agent/sessions) — the non-claude twin
|
||||
// of the scan above; see omp-transcript.ts for why this exists at all.
|
||||
try {
|
||||
for (const h of scanOmpSessionsHistory()) {
|
||||
history.push({
|
||||
sessionId: h.sessionId,
|
||||
workingDir: h.workingDir,
|
||||
sizeBytes: h.sizeBytes,
|
||||
lastModified: h.lastModified,
|
||||
firstPrompt: h.firstPrompt,
|
||||
lastPrompt: h.lastPrompt,
|
||||
mode: 'omp',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best-effort, same as the claude scan above.
|
||||
}
|
||||
|
||||
// Mux process stats (best-effort; guard against mocks lacking the method).
|
||||
let mux: MuxStatInput[] = [];
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
@@ -389,6 +390,9 @@ export function registerSystemRoutes(
|
||||
'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
|
||||
'up-to-date': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
|
||||
'not-git': { http: 400, api: ApiErrorCode.INVALID_INPUT },
|
||||
// A container release that changes the ENVIRONMENT: not a client error to
|
||||
// retry, it needs a host-side rebuild (docs/docker-self-update.md).
|
||||
'env-blocked': { http: 409, api: ApiErrorCode.INVALID_INPUT },
|
||||
disabled: { http: 403, api: ApiErrorCode.INVALID_INPUT },
|
||||
'bad-tag': { http: 400, api: ApiErrorCode.INVALID_INPUT },
|
||||
error: { http: 500, api: ApiErrorCode.INTERNAL_ERROR },
|
||||
@@ -614,8 +618,13 @@ export function registerSystemRoutes(
|
||||
// same negative-pid signal as runGit() in git-clone.ts, which is the
|
||||
// synchronous-spawn precedent this endpoint is modelled on.
|
||||
detached: true,
|
||||
// dsh bundles its own package manager, so no system pnpm is required —
|
||||
// but it still needs a HOME to resolve $DSH_HOME against.
|
||||
// Inherit the environment: this needs a HOME to resolve $DSH_HOME
|
||||
// against, and a PATH carrying `pnpm`. ⚠️ `dsh plugin` does NOT bundle a
|
||||
// package manager — it `spawnSync`s a literal `pnpm` with no npm
|
||||
// fallback, so on a host without one this exits 127 and dsh's own
|
||||
// stderr ("pnpm not found on PATH") is what reaches the caller through
|
||||
// the OPERATION_FAILED detail below. That is the same missing
|
||||
// dependency that broke the docker agent image in issue #352.
|
||||
env: process.env,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -694,6 +703,17 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== OMP ==========
|
||||
|
||||
app.get('/api/omp/status', async () => {
|
||||
const { isOmpAvailable, resolveOmpDir, getOmpCliVersion } = await import('../../utils/omp-cli-resolver.js');
|
||||
return {
|
||||
available: isOmpAvailable(),
|
||||
path: resolveOmpDir(),
|
||||
version: getOmpCliVersion(),
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1024,7 +1044,8 @@ export function registerSystemRoutes(
|
||||
if (statusLineTelemetry === true) {
|
||||
const dirs = new Set<string>();
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.mode === 'claude' && session.workingDir) dirs.add(session.workingDir);
|
||||
if (getCli(session.mode)?.capabilities.statusLineTelemetry && session.workingDir)
|
||||
dirs.add(session.workingDir);
|
||||
}
|
||||
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {})));
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { WebSocket as WsClient } from 'ws';
|
||||
import type { WebSocket } from 'ws';
|
||||
import type { ClientOptions as WsClientOptions, WebSocket } from 'ws';
|
||||
import type { Response as UndiciResponse } from 'undici';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
MAX_LIVE_WEBVIEW_FRAMES,
|
||||
@@ -47,6 +48,8 @@ import {
|
||||
} from '../../config/webview-limits.js';
|
||||
import { readWebviews, writeWebviews } from '../../webview-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { egressBlockedReason, webviewEgressLookup, webviewFetch, type EgressLookup } from '../webview-egress.js';
|
||||
import { blockedWebviewHostReason } from '../webview-egress-policy.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import type { Webview, WebviewOpenData, WebviewProbe } from '../../types.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
@@ -293,7 +296,7 @@ async function probeUrl(url: string): Promise<WebviewProbe> {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(target.href, {
|
||||
const response = await webviewFetch(target, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(WEBVIEW_PROBE_TIMEOUT_MS),
|
||||
@@ -326,6 +329,12 @@ async function probeUrl(url: string): Promise<WebviewProbe> {
|
||||
reason,
|
||||
};
|
||||
} catch (err) {
|
||||
const blocked = egressBlockedReason(err);
|
||||
if (blocked) {
|
||||
// Refused by policy, not unreachable: say so, or the user reads it as a
|
||||
// network problem and starts debugging their firewall.
|
||||
return { reachable: false, framable: false, recommendedMode: 'proxy', reason: blocked };
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
reachable: false,
|
||||
@@ -476,19 +485,19 @@ async function proxyRequest(
|
||||
// string (it can carry the dashboard's tokens).
|
||||
const logTarget = `${req.method} ${upstream.origin}${upstream.pathname}`;
|
||||
|
||||
let response: Response;
|
||||
let response: UndiciResponse;
|
||||
try {
|
||||
response = await fetch(upstream.href, {
|
||||
response = await webviewFetch(upstream, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: hasBody ? (req.body as Readable) : undefined,
|
||||
// Required by undici whenever the body is a stream.
|
||||
...(hasBody ? { duplex: 'half' } : {}),
|
||||
...(hasBody ? { duplex: 'half' as const } : {}),
|
||||
// Redirects are rewritten into the proxy prefix instead of followed, so the
|
||||
// browser's URL stays inside the frame and relative assets keep resolving.
|
||||
redirect: 'manual',
|
||||
signal: abort.signal,
|
||||
} as RequestInit);
|
||||
});
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (clientGone) {
|
||||
@@ -496,6 +505,13 @@ async function proxyRequest(
|
||||
// failure, so no warn (it would read as the dashboard being broken).
|
||||
return reply;
|
||||
}
|
||||
const blocked = egressBlockedReason(err);
|
||||
if (blocked) {
|
||||
// Policy refusal, distinct from "unreachable": a record saved before the
|
||||
// egress rule existed, or a name that now resolves into a blocked range.
|
||||
console.warn(`[Webview] refused by egress policy: ${logTarget} (webview "${webview.name}"): ${blocked}`);
|
||||
return reply.code(403).type('text/plain').send(`Forbidden: ${blocked}`);
|
||||
}
|
||||
if (headerTimedOut) {
|
||||
console.warn(
|
||||
`[Webview] upstream sent no response headers within ${WEBVIEW_UPSTREAM_TIMEOUT_MS}ms: ` +
|
||||
@@ -644,6 +660,13 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa
|
||||
return;
|
||||
}
|
||||
|
||||
// An IP literal never reaches the lookup hook (net.connect skips DNS for it),
|
||||
// so the literal form is judged here and the resolved form in the lookup.
|
||||
if (blockedWebviewHostReason(upstream.hostname)) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
socketCounts.set(webview.id, live + 1);
|
||||
let released = false;
|
||||
const release = () => {
|
||||
@@ -655,16 +678,21 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa
|
||||
};
|
||||
|
||||
const protocols = req.headers['sec-websocket-protocol'];
|
||||
// `lookup` is absent from ws's ClientOptions typings but flows through
|
||||
// http.request to net.connect untouched, which is where the resolved
|
||||
// address is judged (see webview-egress.ts).
|
||||
const upstreamOptions: WsClientOptions & { lookup: EgressLookup } = {
|
||||
headers: {
|
||||
origin: upstream.origin,
|
||||
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
|
||||
},
|
||||
handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS,
|
||||
lookup: webviewEgressLookup,
|
||||
};
|
||||
const upstreamSocket = new WsClient(
|
||||
upstreamWebSocketUrl(upstream),
|
||||
protocols ? String(protocols).split(/,\s*/) : [],
|
||||
{
|
||||
headers: {
|
||||
origin: upstream.origin,
|
||||
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
|
||||
},
|
||||
handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS,
|
||||
}
|
||||
upstreamOptions
|
||||
);
|
||||
|
||||
// Buffer anything the browser sends before the upstream handshake completes,
|
||||
@@ -703,9 +731,14 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa
|
||||
socket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||
upstreamSocket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||
socket.on('error', () => closeBoth());
|
||||
upstreamSocket.on('error', () => {
|
||||
upstreamSocket.on('error', (err: Error) => {
|
||||
release();
|
||||
if (socket.readyState === socket.OPEN) socket.close(1011, 'Upstream error');
|
||||
if (socket.readyState !== socket.OPEN) return;
|
||||
// A name that resolved into a blocked range fails inside the connect, so it
|
||||
// surfaces here rather than at the sync check above; report it as the same
|
||||
// policy refusal, not as the dashboard being broken.
|
||||
if (egressBlockedReason(err)) socket.close(4003, 'Forbidden');
|
||||
else socket.close(1011, 'Upstream error');
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
+119
-35
@@ -10,6 +10,7 @@
|
||||
import { z } from 'zod';
|
||||
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
|
||||
import { isValidWebviewUrl } from './webview-proxy.js';
|
||||
import { isBlockedWebviewUrl } from './webview-egress-policy.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_BYTES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
@@ -18,6 +19,8 @@ import {
|
||||
} from '../config/terminal-history.js';
|
||||
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
|
||||
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
|
||||
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
|
||||
import type { SessionMode } from '../types.js';
|
||||
|
||||
// ========== Path Validation ==========
|
||||
|
||||
@@ -119,37 +122,84 @@ export const FileWriteSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ========== Env Var Allowlist ==========
|
||||
|
||||
/** Allowlisted env var key prefixes */
|
||||
const ALLOWED_ENV_PREFIXES = [
|
||||
'CLAUDE_CODE_',
|
||||
'OPENCODE_',
|
||||
'CODEX_',
|
||||
'GEMINI_',
|
||||
'GOOGLE_',
|
||||
'ANTIGRAVITY_',
|
||||
'PI_',
|
||||
'GROK_',
|
||||
'XAI_',
|
||||
// DeepSeek Harness: `DSH_*` carries the launcher's own documented inputs
|
||||
// (DSH_HOME, DSH_PERMISSION_MODE, DSH_TELEMETRY_MODE, and the DSH_TUI_* knobs
|
||||
// the terminal front door reads); `DEEPSEEK_*` is the vendor namespace holding
|
||||
// DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL, the same narrow-vendor reasoning that
|
||||
// admitted XAI_* for grok. Foreign provider keys stay out: a dsh settings.yaml
|
||||
// can name ANY env var as a provider credential (apiKeyEnv), which is pi's
|
||||
// 34-provider-key problem in a new shape, and the answer is the same one.
|
||||
'DSH_',
|
||||
'DEEPSEEK_',
|
||||
];
|
||||
/**
|
||||
* The run-mode ids the API currently accepts: every ENABLED registry entry.
|
||||
*
|
||||
* Exported so anything needing the authoritative list derives it from here rather than
|
||||
* restating the nine names (which is how the old literal enum drifted from the run menu).
|
||||
*/
|
||||
export function sessionModeIds(): string[] {
|
||||
return enabledCliIds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowlisted exact env var keys (checked alongside the prefixes).
|
||||
* CLAUDE_CONFIG_DIR relocates the Claude CLI's user config (credentials,
|
||||
* settings, stats) so a case can run on a separate Claude subscription (#255).
|
||||
* Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected.
|
||||
* Validation for a run mode, resolved AT PARSE TIME.
|
||||
*
|
||||
* ⚠️ Deliberately not a `z.enum([...])`. An enum has to be handed its members when the
|
||||
* SCHEMA OBJECT is built, which happens once at module import — so a CLI enabled while the
|
||||
* server was running kept failing validation with INVALID_INPUT until a restart, even
|
||||
* though the run menu already offered it. Checking membership inside the refinement moves
|
||||
* the question to when the request is actually validated.
|
||||
*
|
||||
* The cast is because callers type this field as `SessionMode`; the runtime check above is
|
||||
* what actually constrains it.
|
||||
*/
|
||||
const ALLOWED_ENV_KEYS = new Set(['CLAUDE_CONFIG_DIR']);
|
||||
function sessionModeSchema(): z.ZodType<SessionMode> {
|
||||
return (
|
||||
z
|
||||
.string()
|
||||
// Bounded BEFORE the membership check, and before the failure message quotes the value
|
||||
// back. `.max(24)` matches the `cliId` pattern in cli-registry/schema.ts — no id longer
|
||||
// than that can ever be registered, so nothing legitimate is rejected — and it means a
|
||||
// rejected mode cannot echo a body-limit-sized string into an error string and a log
|
||||
// line. Without it the only bound on either was the HTTP body limit.
|
||||
.max(24)
|
||||
.superRefine((value, ctx) => {
|
||||
const allowed = sessionModeIds();
|
||||
if (!allowed.includes(value)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Invalid run mode ${JSON.stringify(value)}. Enabled modes: ${allowed.join(', ')}`,
|
||||
});
|
||||
}
|
||||
}) as unknown as z.ZodType<SessionMode>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Env Var Allowlist ==========
|
||||
|
||||
/**
|
||||
* Allowlisted env var key prefixes, contributed by the ENABLED CLIs in the registry
|
||||
* (`env.allowedPrefixes`) — `CLAUDE_CODE_`, `OPENCODE_`, `CODEX_`, `GEMINI_`, `GOOGLE_`,
|
||||
* `ANTIGRAVITY_`, `PI_`, `GROK_`, `XAI_`, `DSH_`, `DEEPSEEK_` as shipped.
|
||||
*
|
||||
* ⚠️ Resolved AT PARSE TIME, not at module load. This used to be a frozen array computed
|
||||
* once when the module was imported, which meant a CLI enabled while the server was running
|
||||
* had its env prefix rejected until a restart — validation and the run menu disagreeing
|
||||
* about which CLIs exist. Reading the registry per call costs a memoized array lookup.
|
||||
*
|
||||
* ⚠️ This is ONE GLOBAL LIST applied with no mode context, so admitting a prefix for one CLI
|
||||
* widens it for every mode at once. That is why an entry only ever contributes its own
|
||||
* VENDOR namespace: pi's ~34 provider keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, HF_TOKEN, …)
|
||||
* share no prefix and stay out, and a dsh `settings.yaml` can nominate ANY env var as a
|
||||
* provider credential — same problem, same answer. Those CLIs authenticate via their own
|
||||
* `/login` or the server process's own environment.
|
||||
*/
|
||||
function allowedEnvPrefixes(): string[] {
|
||||
return enabledClis().flatMap((entry) => entry.env.allowedPrefixes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowlisted exact env var keys (checked alongside the prefixes), likewise contributed by
|
||||
* enabled registry entries via `env.allowedKeys`.
|
||||
*
|
||||
* As shipped this is claude's CLAUDE_CONFIG_DIR, which relocates the Claude CLI's user
|
||||
* config (credentials, settings, stats) so a case can run on a separate Claude subscription
|
||||
* (#255). Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected.
|
||||
*/
|
||||
function allowedEnvKeys(): Set<string> {
|
||||
return new Set(enabledClis().flatMap((entry) => entry.env.allowedKeys));
|
||||
}
|
||||
|
||||
/** Env var keys that are always blocked (security-sensitive) */
|
||||
const BLOCKED_ENV_KEYS = new Set([
|
||||
@@ -162,11 +212,17 @@ const BLOCKED_ENV_KEYS = new Set([
|
||||
'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password
|
||||
]);
|
||||
|
||||
/** Validate that an env var key is allowed */
|
||||
/**
|
||||
* Validate that an env var key is allowed.
|
||||
*
|
||||
* ⚠️ `BLOCKED_ENV_KEYS` is checked FIRST and is deliberately NOT registry-driven. It is a
|
||||
* hard floor: a rogue or fat-fingered `allowedPrefixes` entry (say `''`, which prefixes
|
||||
* everything) still cannot unblock PATH or LD_PRELOAD.
|
||||
*/
|
||||
function isAllowedEnvKey(key: string): boolean {
|
||||
if (BLOCKED_ENV_KEYS.has(key)) return false;
|
||||
if (ALLOWED_ENV_KEYS.has(key)) return true;
|
||||
return ALLOWED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
if (allowedEnvKeys().has(key)) return true;
|
||||
return allowedEnvPrefixes().some((prefix) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
/** Zod schema for env overrides with allowlist enforcement */
|
||||
@@ -180,7 +236,7 @@ const safeEnvOverridesSchema = z
|
||||
},
|
||||
{
|
||||
message:
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_* keys and CLAUDE_CONFIG_DIR are allowed.',
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_* keys and CLAUDE_CONFIG_DIR are allowed.',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -345,6 +401,25 @@ const GrokConfigSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* Schema for OMP CLI-specific configuration.
|
||||
*/
|
||||
const OmpConfigSchema = z
|
||||
.object({
|
||||
model: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._\-/]+$/)
|
||||
.optional(),
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._-]+$/)
|
||||
.optional(),
|
||||
continueSession: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* Schema for DeepSeek Harness (`dsh`)-specific configuration.
|
||||
*
|
||||
@@ -440,7 +515,7 @@ const parentSessionIdSchema = z.string().max(100).optional();
|
||||
|
||||
export const CreateSessionSchema = z.object({
|
||||
workingDir: safePathSchema.optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(),
|
||||
mode: sessionModeSchema().optional(),
|
||||
name: z.string().max(100).optional(),
|
||||
/** Session that spawned this one — see parentSessionIdSchema. */
|
||||
parentSessionId: parentSessionIdSchema,
|
||||
@@ -458,6 +533,7 @@ export const CreateSessionSchema = z.object({
|
||||
piConfig: PiConfigSchema,
|
||||
grokConfig: GrokConfigSchema,
|
||||
deepSeekConfig: DeepSeekConfigSchema,
|
||||
ompConfig: OmpConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
@@ -937,7 +1013,7 @@ export const QuickStartSchema = z.object({
|
||||
* a real host dir, so the settings file crosses the bind mount); rejected for
|
||||
* remote cases (the file would be written on the WRONG machine). */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(),
|
||||
mode: sessionModeSchema().optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
geminiConfig: GeminiConfigSchema,
|
||||
@@ -945,6 +1021,7 @@ export const QuickStartSchema = z.object({
|
||||
piConfig: PiConfigSchema,
|
||||
grokConfig: GrokConfigSchema,
|
||||
deepSeekConfig: DeepSeekConfigSchema,
|
||||
ompConfig: OmpConfigSchema,
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort: effortLevelSchema,
|
||||
@@ -1478,7 +1555,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
|
||||
/** Shared field shape for creating/updating a scheduled job. */
|
||||
const CronJobBaseSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']),
|
||||
agentType: sessionModeSchema(),
|
||||
workingDir: safePathSchema,
|
||||
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
|
||||
promptMode: z.enum(['inline_text', 'prompt_file_path']),
|
||||
@@ -1754,6 +1831,13 @@ const webviewUrlSchema = z
|
||||
.max(2000, 'URL too long (max 2000 chars)')
|
||||
.refine(isValidWebviewUrl, {
|
||||
message: 'Invalid URL: must be http(s), with a hostname and no embedded credentials',
|
||||
})
|
||||
// Egress policy (`webview-egress-policy.ts`): no dashboard lives at a link-local
|
||||
// or cloud-metadata address, while an IAM credential does. Refused at save time
|
||||
// for the clear message; the proxy re-judges the RESOLVED address at connect time.
|
||||
.refine((url) => !isBlockedWebviewUrl(url), {
|
||||
message:
|
||||
'Blocked URL: link-local and cloud-metadata addresses (169.254.0.0/16, metadata.google.internal, ...) cannot be dashboards',
|
||||
});
|
||||
|
||||
const WebviewBaseSchema = z.object({
|
||||
|
||||
+327
-14
@@ -16,6 +16,15 @@
|
||||
* tested, and IO wrappers (`getInstallInfo`, `checkForUpdate`, `startUpdate`,
|
||||
* `reconcileUpdateOnBoot`) that touch git/network/fs.
|
||||
*
|
||||
* DOCKER COMPOSE installs update in place too, through the same script and the
|
||||
* same status file. The repo is a host bind mount, so the pull/build land on the
|
||||
* host filesystem and survive container recreation; the "restart" is the server
|
||||
* EXITING so the container's restart policy relaunches it on the new `dist/`.
|
||||
* That applies CODE only — a restart reuses the existing container's image and
|
||||
* config — so `evaluateEnvironmentGate()` refuses a release that changes
|
||||
* `server.Dockerfile`, `docker-compose.yaml` or `.env.example`, pointing at the
|
||||
* host command instead. See `docs/docker-self-update.md`.
|
||||
*
|
||||
* Related: `src/types/update.ts`, `scripts/self-update.sh`, routes in
|
||||
* `src/web/routes/system-routes.ts`.
|
||||
*
|
||||
@@ -26,13 +35,15 @@ import { spawn, execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir, hostname, tmpdir } from 'node:os';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import type {
|
||||
EnvironmentBlocker,
|
||||
EnvironmentGate,
|
||||
InstallInfo,
|
||||
InstallKind,
|
||||
SupervisorKind,
|
||||
@@ -215,6 +226,139 @@ export function reconcileStatusDecision(
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PURE helpers — the container environment gate
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Host command that resolves every environment blocker. */
|
||||
export const DOCKER_HOST_UPDATE_COMMAND = 'docker/Start-Codeman.sh';
|
||||
|
||||
/**
|
||||
* Parse the SET keys out of a dotenv file. Commented-out lines are deliberately
|
||||
* NOT keys: `docker/.env.example` uses `# PUID=1000` to document an OPTIONAL
|
||||
* override, so treating those as required would block every update on settings
|
||||
* the user is meant to leave alone.
|
||||
*/
|
||||
export function parseEnvKeys(text: string): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const m = line.replace(/^export\s+/, '').match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
||||
if (m && !keys.includes(m[1])) keys.push(m[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys the TARGET release's `.env.example` sets that the user's `.env` does not.
|
||||
*
|
||||
* This is the check that makes a new required setting visible: Compose resolves
|
||||
* an unset `${VAR}` to the empty string and starts anyway, so a missing key is
|
||||
* otherwise silent until something misbehaves at runtime.
|
||||
*/
|
||||
export function diffRequiredEnvKeys(targetExample: string, userEnv: string): string[] {
|
||||
const have = new Set(parseEnvKeys(userEnv));
|
||||
return parseEnvKeys(targetExample).filter((k) => !have.has(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the container's restart policy relaunches it after the server exits.
|
||||
* `no` and an empty policy mean an in-place update would take Codeman DOWN
|
||||
* rather than restart it, so the update is refused instead.
|
||||
*/
|
||||
export function isAutoRestartPolicy(name: string | null | undefined): boolean {
|
||||
return name === 'always' || name === 'unless-stopped' || name === 'on-failure';
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE: may the container updater restart the server by exiting? Yes when the
|
||||
* Compose file declared it (`CODEMAN_RESTART_BY_EXIT=1`, set only there, since
|
||||
* that file is what sets `restart: unless-stopped`) or when the daemon reports an
|
||||
* auto-restart policy. Otherwise the answer is NO, and the updater stages the
|
||||
* build and asks for a manual restart instead of exiting: an unknown policy is
|
||||
* fine to fail open in the GATE (refusing would block installs with no socket),
|
||||
* but the kill itself must not fail open, or a container the daemon would not
|
||||
* bring back goes down with no UI left to recover it from.
|
||||
*/
|
||||
export function shouldRestartByExit(declared: boolean, restartPolicy: string | null): boolean {
|
||||
return declared || isAutoRestartPolicy(restartPolicy);
|
||||
}
|
||||
|
||||
/** The Compose file's declaration that exiting relaunches this container. */
|
||||
export function restartByExitDeclared(): boolean {
|
||||
return process.env.CODEMAN_RESTART_BY_EXIT === '1';
|
||||
}
|
||||
|
||||
export interface EnvironmentGateInput {
|
||||
/** sha256 of `docker/server.Dockerfile` the running container was built from. */
|
||||
appliedDockerfileHash: string | null;
|
||||
/** sha256 of `docker/server.Dockerfile` at the target release tag. */
|
||||
targetDockerfileHash: string | null;
|
||||
/** sha256 of `docker/docker-compose.yaml` the running container was created from. */
|
||||
appliedComposeHash: string | null;
|
||||
/** sha256 of `docker/docker-compose.yaml` at the target release tag. */
|
||||
targetComposeHash: string | null;
|
||||
/** Keys from `diffRequiredEnvKeys()`. */
|
||||
missingEnvKeys: string[];
|
||||
/** Docker restart policy name of the running container, or null if unknown. */
|
||||
restartPolicy: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE gate decision. An in-place container update applies CODE only: the server
|
||||
* exits and the container's restart policy relaunches it on the new `dist/`. A
|
||||
* restart reuses the existing container's image and config, so anything that
|
||||
* changes the ENVIRONMENT cannot take effect that way and is refused here with
|
||||
* the host command that can apply it.
|
||||
*
|
||||
* ⚠️ An unknown hash (null) is NOT treated as "changed": a first update from a
|
||||
* container created before the fingerprint file existed has no baseline, and
|
||||
* failing closed there would block every such install from ever updating. The
|
||||
* baseline is written by `Start-Codeman.sh`, so it exists from the first
|
||||
* host-side start onward. An unknown restart policy is likewise not a blocker —
|
||||
* the shipped Compose file sets `unless-stopped`, and the probe needs the Docker
|
||||
* socket, which a user may not have mounted.
|
||||
*/
|
||||
export function computeEnvironmentBlockers(input: EnvironmentGateInput): EnvironmentBlocker[] {
|
||||
const blockers: EnvironmentBlocker[] = [];
|
||||
|
||||
if (
|
||||
input.appliedDockerfileHash &&
|
||||
input.targetDockerfileHash &&
|
||||
input.appliedDockerfileHash !== input.targetDockerfileHash
|
||||
) {
|
||||
blockers.push({
|
||||
kind: 'dockerfile-changed',
|
||||
message: 'This release changes docker/server.Dockerfile, so the image must be rebuilt.',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.appliedComposeHash && input.targetComposeHash && input.appliedComposeHash !== input.targetComposeHash) {
|
||||
blockers.push({
|
||||
kind: 'compose-changed',
|
||||
message: 'This release changes docker/docker-compose.yaml, so the container must be recreated.',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.missingEnvKeys.length > 0) {
|
||||
blockers.push({
|
||||
kind: 'env-keys-missing',
|
||||
message: `This release adds ${input.missingEnvKeys.length} setting(s) your docker/.env has no value for.`,
|
||||
details: input.missingEnvKeys,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.restartPolicy !== null && !isAutoRestartPolicy(input.restartPolicy)) {
|
||||
blockers.push({
|
||||
kind: 'no-auto-restart',
|
||||
message: `This container's restart policy is "${input.restartPolicy}", so it would not come back after the update.`,
|
||||
});
|
||||
}
|
||||
|
||||
return blockers;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Status file IO
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -273,19 +417,149 @@ export function resolveInstallDir(): string {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this process runs inside a container. `/.dockerenv` is created by the
|
||||
* Docker daemon itself; the env var is set by our own Compose file so the check
|
||||
* also holds under runtimes that omit that file.
|
||||
*/
|
||||
export function isRunningInContainer(): boolean {
|
||||
return process.env.CODEMAN_IN_CONTAINER === '1' || existsSync('/.dockerenv');
|
||||
}
|
||||
|
||||
function detectInstallKind(dir: string): InstallKind {
|
||||
if (existsSync(join(dir, '.git'))) return 'git';
|
||||
// A container whose code is a bind-mounted checkout updates in place (the pull
|
||||
// and build land on the host filesystem and survive container recreation). A
|
||||
// container WITHOUT that mount runs a baked image copy — a pull there would go
|
||||
// to the writable layer and vanish on the next `up`, so it is not updatable.
|
||||
if (existsSync(join(dir, '.git'))) return isRunningInContainer() ? 'docker-compose' : 'git';
|
||||
// Global npm install ships only dist/ (no src/, no .git).
|
||||
if (!existsSync(join(dir, 'src'))) return 'npm';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Install kinds whose update is applied in place by `scripts/self-update.sh`. */
|
||||
export function canSelfUpdateInPlace(kind: InstallKind): boolean {
|
||||
return kind === 'git' || kind === 'docker-compose';
|
||||
}
|
||||
|
||||
/** Path of the fingerprint baseline written by `docker/Start-Codeman.sh`. */
|
||||
const DOCKER_ENV_APPLIED_FILE = dataPath('docker-env-applied.json');
|
||||
|
||||
/** Files whose content defines the container ENVIRONMENT (vs. the app's code). */
|
||||
const DOCKERFILE_REL = 'docker/server.Dockerfile';
|
||||
const COMPOSE_REL = 'docker/docker-compose.yaml';
|
||||
const ENV_EXAMPLE_REL = 'docker/.env.example';
|
||||
const ENV_REL = 'docker/.env';
|
||||
|
||||
function sha256(text: string): string {
|
||||
return createHash('sha256').update(text, 'utf-8').digest('hex');
|
||||
}
|
||||
|
||||
/** Read a file at a git TAG without checking it out (`git show tag:path`). */
|
||||
function gitShowAtTag(repo: string, tag: string, relPath: string): string | null {
|
||||
return tryExec('git', ['show', `${tag}:${relPath}`], repo);
|
||||
}
|
||||
|
||||
function readFileOrNull(path: string): string | null {
|
||||
try {
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fingerprints the RUNNING container was created from, recorded on the host
|
||||
* by `Start-Codeman.sh` at each build/recreate. Returns nulls when absent (a
|
||||
* container started before this file existed) — `computeEnvironmentBlockers()`
|
||||
* deliberately treats an unknown baseline as "not a blocker".
|
||||
*/
|
||||
function readAppliedEnvironmentFingerprints(): { dockerfile: string | null; compose: string | null } {
|
||||
const raw = readFileOrNull(DOCKER_ENV_APPLIED_FILE);
|
||||
if (!raw) return { dockerfile: null, compose: null };
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { dockerfileSha256?: string; composeSha256?: string };
|
||||
return { dockerfile: parsed.dockerfileSha256 ?? null, compose: parsed.composeSha256 ?? null };
|
||||
} catch {
|
||||
return { dockerfile: null, compose: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart policy of the container we're running in, via the mounted Docker
|
||||
* socket. Returns null when the socket or CLI is unavailable — an unknown policy
|
||||
* is not a blocker (see `computeEnvironmentBlockers`).
|
||||
*/
|
||||
function detectOwnRestartPolicy(): string | null {
|
||||
// Docker sets HOSTNAME to the short container id; os.hostname() is the same
|
||||
// value when the env var is absent. A custom `hostname:` in the compose file
|
||||
// makes both unresolvable to the daemon, which fails open (unknown is not a
|
||||
// blocker) rather than refusing an update over a cosmetic setting.
|
||||
const id = process.env.HOSTNAME || hostname();
|
||||
if (!id) return null;
|
||||
const out = tryExec('docker', ['inspect', '--format', '{{.HostConfig.RestartPolicy.Name}}', id]);
|
||||
return out && out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the environment gate for a candidate release tag. Reads the TARGET
|
||||
* tag's files straight out of git (`git show`), so nothing is checked out and the
|
||||
* answer is available at CHECK time — the UI can refuse before the user commits
|
||||
* to an update.
|
||||
*/
|
||||
export function evaluateEnvironmentGate(installDir: string, tag: string): EnvironmentGate {
|
||||
// `git show <tag>:<path>` needs the tag's objects locally, and neither the
|
||||
// GitHub API nor `ls-remote` fetches anything — so a check that has never seen
|
||||
// this tag would read nothing and report a falsely clean gate. Fetch the one
|
||||
// ref first (cheap: it deltas against what the clone already has) and only
|
||||
// then read. The updater fetches the same ref again; both are idempotent.
|
||||
if (tryExec('git', ['rev-parse', '--verify', '--quiet', `${tag}^{commit}`], installDir) === null) {
|
||||
tryExec(
|
||||
'git',
|
||||
['fetch', '--tags', '--force', 'origin', `refs/tags/${tag}:refs/tags/${tag}`],
|
||||
installDir,
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
const targetDockerfile = gitShowAtTag(installDir, tag, DOCKERFILE_REL);
|
||||
const targetCompose = gitShowAtTag(installDir, tag, COMPOSE_REL);
|
||||
const targetExample = gitShowAtTag(installDir, tag, ENV_EXAMPLE_REL);
|
||||
|
||||
// No environment files at the target tag at all: we cannot judge, so say so
|
||||
// rather than reporting a clean gate the caller would trust.
|
||||
if (targetDockerfile === null && targetCompose === null && targetExample === null) {
|
||||
return { checked: false, blockers: [], hostCommand: DOCKER_HOST_UPDATE_COMMAND };
|
||||
}
|
||||
|
||||
const applied = readAppliedEnvironmentFingerprints();
|
||||
const userEnv = readFileOrNull(join(installDir, ENV_REL));
|
||||
|
||||
const blockers = computeEnvironmentBlockers({
|
||||
appliedDockerfileHash: applied.dockerfile,
|
||||
targetDockerfileHash: targetDockerfile === null ? null : sha256(targetDockerfile),
|
||||
appliedComposeHash: applied.compose,
|
||||
targetComposeHash: targetCompose === null ? null : sha256(targetCompose),
|
||||
// A missing/unreadable .env cannot be diffed — report no missing keys rather
|
||||
// than every key, which would block on an install using a non-standard path.
|
||||
missingEnvKeys: targetExample !== null && userEnv !== null ? diffRequiredEnvKeys(targetExample, userEnv) : [],
|
||||
restartPolicy: detectOwnRestartPolicy(),
|
||||
});
|
||||
|
||||
return { checked: true, blockers, hostCommand: DOCKER_HOST_UPDATE_COMMAND };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which init system supervises us. Detection happens HERE (in the running
|
||||
* server, which has a rich env) and the result is passed to the updater script —
|
||||
* the detached child must not re-probe with a stripped-down environment.
|
||||
*/
|
||||
export function detectSupervisor(): SupervisorKind {
|
||||
// Checked FIRST: a container has no init system of its own, and its "restart"
|
||||
// is the server exiting so the Docker restart policy relaunches it. Probing
|
||||
// systemd here would find nothing and report `none`, which stages the update
|
||||
// and then asks the user to restart by hand for no reason.
|
||||
if (isRunningInContainer()) return 'docker-compose';
|
||||
if (process.platform === 'darwin') {
|
||||
if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd';
|
||||
// Headless Macs (no GUI login → no gui domain) run Codeman as a system-level
|
||||
@@ -389,17 +663,29 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
checkedAt,
|
||||
source: 'none',
|
||||
};
|
||||
if (info.installKind !== 'git') {
|
||||
return { ...base, error: 'Not a git install — self-update is unavailable.' };
|
||||
if (!canSelfUpdateInPlace(info.installKind)) {
|
||||
return {
|
||||
...base,
|
||||
error:
|
||||
info.installKind === 'unknown' && isRunningInContainer()
|
||||
? 'This container runs a baked image copy with no repository mounted — self-update is unavailable. See docs/docker-self-update.md.'
|
||||
: 'Not a git install — self-update is unavailable.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Attach the container environment gate to a finished check result. */
|
||||
const withGate = (result: UpdateCheckResult): UpdateCheckResult => {
|
||||
if (info.installKind !== 'docker-compose' || !result.latestTag || !result.updateAvailable) return result;
|
||||
return { ...result, environment: evaluateEnvironmentGate(info.installDir, result.latestTag) };
|
||||
};
|
||||
|
||||
const remote = tryExec('git', ['remote', 'get-url', 'origin'], info.installDir);
|
||||
const gh = remote ? parseGitHubRepo(remote) : null;
|
||||
|
||||
if (gh) {
|
||||
const rel = await fetchLatestReleaseFromGitHub(gh.owner, gh.repo);
|
||||
if (rel) {
|
||||
return {
|
||||
return withGate({
|
||||
...base,
|
||||
latestVersion: rel.version,
|
||||
latestTag: rel.tag,
|
||||
@@ -407,20 +693,20 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
htmlUrl: rel.htmlUrl,
|
||||
updateAvailable: isNewerStableVersion(info.currentVersion, rel.version),
|
||||
source: 'github-api',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: enumerate remote tags directly (works for non-GitHub remotes too).
|
||||
const viaGit = fetchLatestTagViaGit(info.installDir);
|
||||
if (viaGit) {
|
||||
return {
|
||||
return withGate({
|
||||
...base,
|
||||
latestVersion: viaGit.version,
|
||||
latestTag: viaGit.tag,
|
||||
updateAvailable: isNewerStableVersion(info.currentVersion, viaGit.version),
|
||||
source: 'git-ls-remote',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { ...base, error: 'Could not reach the update server (GitHub API + git ls-remote both failed).' };
|
||||
@@ -432,7 +718,11 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
|
||||
export type StartUpdateResult =
|
||||
| { ok: true; updateId: string; toTag: string; toVersion: string | null }
|
||||
| { ok: false; code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'error'; message: string };
|
||||
| {
|
||||
ok: false;
|
||||
code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'env-blocked' | 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy the updater script OUT of the repo before running it. The script lives in
|
||||
@@ -497,11 +787,13 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
if (!info.selfUpdateEnabled) {
|
||||
return { ok: false, code: 'disabled', message: 'Self-update is disabled (CODEMAN_DISABLE_SELF_UPDATE=1).' };
|
||||
}
|
||||
if (info.installKind !== 'git') {
|
||||
if (!canSelfUpdateInPlace(info.installKind)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'not-git',
|
||||
message: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
message: isRunningInContainer()
|
||||
? 'This container has no repository mounted. Update from the host with docker/Start-Codeman.sh.'
|
||||
: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
};
|
||||
}
|
||||
const existing = readUpdateStatus();
|
||||
@@ -517,6 +809,20 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
return { ok: false, code: 'bad-tag', message: `Refusing to update to an unrecognized tag: ${check.latestTag}` };
|
||||
}
|
||||
|
||||
// Re-evaluate rather than trusting the check the browser saw: the UI hides the
|
||||
// button when the gate blocks, but the endpoint is reachable directly and the
|
||||
// release could have moved between the check and the click.
|
||||
if (info.installKind === 'docker-compose') {
|
||||
const gate = evaluateEnvironmentGate(info.installDir, check.latestTag);
|
||||
if (gate.blockers.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'env-blocked',
|
||||
message: `${gate.blockers.map((b) => b.message).join(' ')} Run ${gate.hostCommand} on the Docker host to apply this release.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const prevSha = tryExec('git', ['rev-parse', 'HEAD'], info.installDir);
|
||||
const runner = stageRunner(info.installDir);
|
||||
if (!runner) {
|
||||
@@ -558,11 +864,18 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
process.execPath,
|
||||
'--log',
|
||||
logFile,
|
||||
// For the launchd-daemon restart path: the updater kills this PID and the
|
||||
// KeepAlive daemon respawns the server on the freshly built dist/.
|
||||
// For the launchd-daemon and docker-compose restart paths: the updater kills
|
||||
// this PID and the supervisor (KeepAlive daemon / Docker restart policy)
|
||||
// respawns the server on the freshly built dist/.
|
||||
'--server-pid',
|
||||
String(process.pid),
|
||||
];
|
||||
if (info.supervisor === 'docker-compose') {
|
||||
// Decided HERE, where the Docker socket and the Compose env are reachable;
|
||||
// the updater only reads the answer. Without a yes it never exits the server.
|
||||
const byExit = shouldRestartByExit(restartByExitDeclared(), detectOwnRestartPolicy());
|
||||
args.push('--restart-by-exit', byExit ? '1' : '0');
|
||||
}
|
||||
if (prevSha) args.push('--prev-sha', prevSha);
|
||||
if (info.dirty) args.push('--stash');
|
||||
|
||||
|
||||
@@ -1446,6 +1446,7 @@ export class WebServer extends EventEmitter {
|
||||
{ isPiAvailable },
|
||||
{ isGrokAvailable },
|
||||
{ isDeepSeekRunnable, isDeepSeekAvailable },
|
||||
{ isOmpAvailable },
|
||||
{ isCloudflaredAvailable },
|
||||
{ isGitAvailable },
|
||||
] = await Promise.all([
|
||||
@@ -1457,6 +1458,7 @@ export class WebServer extends EventEmitter {
|
||||
import('../utils/pi-cli-resolver.js'),
|
||||
import('../utils/grok-cli-resolver.js'),
|
||||
import('../utils/deepseek-cli-resolver.js'),
|
||||
import('../utils/omp-cli-resolver.js'),
|
||||
import('../utils/cloudflared-resolver.js'),
|
||||
import('../git-clone.js'),
|
||||
]);
|
||||
@@ -1475,6 +1477,7 @@ export class WebServer extends EventEmitter {
|
||||
// profile is offered the fix rather than a greyed-out entry.
|
||||
deepseek: isDeepSeekRunnable(),
|
||||
deepseekBinary: isDeepSeekAvailable(),
|
||||
omp: isOmpAvailable(),
|
||||
cloudflared: isCloudflaredAvailable(),
|
||||
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
|
||||
// keep without git (issue #236), same reasoning as cloudflared above.
|
||||
@@ -2783,6 +2786,7 @@ export class WebServer extends EventEmitter {
|
||||
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
|
||||
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
|
||||
deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined,
|
||||
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
|
||||
envOverrides: savedEnvOverrides,
|
||||
effort: savedState?.effort,
|
||||
attachmentHistory: savedAttachmentHistory,
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
MAX_SNIPPET_CONTEXT,
|
||||
} from '../config/agent-wait.js';
|
||||
import type { SessionMode, SessionStatus } from '../types.js';
|
||||
import { getCli } from '../config/cli-registry/registry.js';
|
||||
|
||||
// ─── Signals ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -174,7 +175,7 @@ const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
|
||||
export interface HookCapabilityOptions {
|
||||
/**
|
||||
* `deepSeekConfig.statusReporting`, verbatim (so `undefined` means "not sent",
|
||||
* i.e. ON). `false` is the per-session opt-out that stops `_configureDeepSeek()`
|
||||
* i.e. ON). `false` is the per-session opt-out that stops `_configureCliEnv()`
|
||||
* exporting the `HERDR_*` triple, which is the ONLY thing that makes a dsh
|
||||
* session emit hook events at all.
|
||||
*/
|
||||
@@ -223,18 +224,26 @@ export interface HookCapabilityOptions {
|
||||
* function only about hook SIGNALS.
|
||||
*/
|
||||
export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
|
||||
if (mode === 'claude') return true;
|
||||
// `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE
|
||||
// signals rather than having them inferred. The DeepSeek Harness terminal
|
||||
// front door reports idle/working/blocked to its supervisor, and Codeman is
|
||||
// that supervisor (see deepseek-status-shim.ts), so a dsh session really can
|
||||
// deliver `stop` and `blocked` — unless the user turned the bridge off, in
|
||||
// which case nothing on the box will ever post one. Every other mode is
|
||||
// output-stabilization guesswork and must keep failing the ask.
|
||||
if (mode === 'deepseek') {
|
||||
return options.deepSeekStatusReporting !== false && options.deepSeekBridgeUnreachable !== true;
|
||||
// A TRI-state capability, not a boolean, because the three answers are genuinely
|
||||
// different questions — see CliCapabilities.hooks.
|
||||
switch (getCli(mode)?.capabilities.hooks) {
|
||||
case 'always':
|
||||
// The CLI installs Codeman's own hooks block into its workspace (claude), so the
|
||||
// signals are unconditional.
|
||||
return true;
|
||||
case 'supervised':
|
||||
// The CLI REPORTS its own state to a supervisor and Codeman is that supervisor
|
||||
// (deepseek, via deepseek-status-shim.ts) — definitive signals rather than inferred
|
||||
// ones, which is what earns it a yes. But the user can disarm the bridge, and a
|
||||
// docker/remote session cannot reach it at all; in either case nothing on the box
|
||||
// will ever post one, so the answer has to come from the SESSION, not the mode.
|
||||
return options.deepSeekStatusReporting !== false && options.deepSeekBridgeUnreachable !== true;
|
||||
default:
|
||||
// 'none', and an unregistered mode. Every other CLI's idle is output-stabilization
|
||||
// guesswork, and must keep failing the ask rather than promising a signal that never
|
||||
// arrives.
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @fileoverview Egress policy for the web-tab proxy: which upstream ADDRESSES
|
||||
* a saved dashboard URL may never resolve to.
|
||||
*
|
||||
* Pure (no IO), so the same predicate serves three call sites that see the target
|
||||
* at different stages: the Zod schema (a URL being saved), the sync check on a
|
||||
* hostname that is already an IP literal (Node's `net.connect` skips DNS for
|
||||
* those, so a lookup hook never sees them), and the DNS lookup hook that judges
|
||||
* the RESOLVED addresses of a name (`webview-egress.ts`), which is what closes
|
||||
* the rebinding hole a hostname-string check alone leaves open.
|
||||
*
|
||||
* What is blocked, and only this: link-local ranges and the fixed cloud-metadata
|
||||
* addresses that live there or beside them. Loopback and RFC1918 are deliberately
|
||||
* ALLOWED: a `localhost` Grafana or a LAN Home Assistant is the documented use
|
||||
* case for web tabs (`docs/web-tabs.md`), and the proxy's reach into the server's
|
||||
* own network is a documented property, not a bug. Nothing a person would
|
||||
* embed as a dashboard lives at 169.254.169.254, while an IAM credential does.
|
||||
*/
|
||||
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
/**
|
||||
* Hostnames that are metadata-service aliases on the clouds that define them.
|
||||
* Belt and braces: each also RESOLVES to a blocked address, which the lookup hook
|
||||
* catches, but naming them here gives the user a clear refusal at save time
|
||||
* instead of a DNS-shaped failure at open time.
|
||||
*/
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
'metadata.google.internal', // GCP
|
||||
'metadata', // GCP short alias (resolves on every GCE VM)
|
||||
'instance-data', // AWS legacy IMDS alias
|
||||
]);
|
||||
|
||||
/** Fixed single-address metadata endpoints outside the link-local range. */
|
||||
const BLOCKED_IPV4_HOSTS = new Set([
|
||||
'168.63.129.16', // Azure WireServer (IMDS helper, DHCP/heartbeat endpoint)
|
||||
'100.100.100.200', // Alibaba Cloud metadata
|
||||
]);
|
||||
|
||||
function parseIpv4(host: string): [number, number, number, number] | null {
|
||||
const parts = host.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
const nums = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : NaN));
|
||||
if (nums.some((n) => Number.isNaN(n) || n > 255)) return null;
|
||||
return nums as [number, number, number, number];
|
||||
}
|
||||
|
||||
function isBlockedIpv4(host: string): boolean {
|
||||
const octets = parseIpv4(host);
|
||||
if (!octets) return false;
|
||||
const [a, b] = octets;
|
||||
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local, incl. 169.254.169.254 (AWS/Azure/GCP/OpenStack/Oracle/DO)
|
||||
return BLOCKED_IPV4_HOSTS.has(octets.join('.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand an IPv6 literal into its eight 16-bit groups. Accepts the compressed
|
||||
* forms `URL.hostname` and DNS produce (`::1`, `::ffff:7f00:1`, `fd00:ec2::254`)
|
||||
* plus a dotted IPv4 tail (`::ffff:127.0.0.1`). Returns null for anything it
|
||||
* cannot parse, and the caller treats null as "not blocked" because every caller
|
||||
* gates on `isIP()` first, so null only ever means a zone id or a form Node itself
|
||||
* would refuse to connect to.
|
||||
*/
|
||||
function expandIpv6(raw: string): number[] | null {
|
||||
let text = raw.toLowerCase();
|
||||
const zone = text.indexOf('%');
|
||||
if (zone !== -1) text = text.slice(0, zone);
|
||||
|
||||
const lastColon = text.lastIndexOf(':');
|
||||
const tail = text.slice(lastColon + 1);
|
||||
if (tail.includes('.')) {
|
||||
const v4 = parseIpv4(tail);
|
||||
if (!v4) return null;
|
||||
const hi = ((v4[0] << 8) | v4[1]).toString(16);
|
||||
const lo = ((v4[2] << 8) | v4[3]).toString(16);
|
||||
text = `${text.slice(0, lastColon + 1)}${hi}:${lo}`;
|
||||
}
|
||||
|
||||
const halves = text.split('::');
|
||||
if (halves.length > 2) return null;
|
||||
const head = halves[0] === '' ? [] : halves[0].split(':');
|
||||
const rest = halves.length === 2 && halves[1] !== '' ? halves[1].split(':') : [];
|
||||
const missing = 8 - head.length - rest.length;
|
||||
if (halves.length === 2 ? missing < 1 : missing !== 0) return null;
|
||||
const groups = halves.length === 2 ? [...head, ...new Array<string>(missing).fill('0'), ...rest] : head;
|
||||
if (groups.length !== 8) return null;
|
||||
const out = groups.map((g) => (/^[0-9a-f]{1,4}$/.test(g) ? parseInt(g, 16) : NaN));
|
||||
return out.some((n) => Number.isNaN(n)) ? null : out;
|
||||
}
|
||||
|
||||
function isBlockedIpv6(host: string): boolean {
|
||||
const groups = expandIpv6(host);
|
||||
if (!groups) return false;
|
||||
// fe80::/10 link-local.
|
||||
if ((groups[0] & 0xffc0) === 0xfe80) return true;
|
||||
// fd00:ec2::254, the AWS IMDS IPv6 endpoint.
|
||||
if (
|
||||
groups[0] === 0xfd00 &&
|
||||
groups[1] === 0x0ec2 &&
|
||||
groups[2] === 0 &&
|
||||
groups[3] === 0 &&
|
||||
groups[4] === 0 &&
|
||||
groups[5] === 0 &&
|
||||
groups[6] === 0 &&
|
||||
groups[7] === 0x0254
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// IPv4-mapped (::ffff:a.b.c.d): judge the embedded IPv4.
|
||||
if (
|
||||
groups[0] === 0 &&
|
||||
groups[1] === 0 &&
|
||||
groups[2] === 0 &&
|
||||
groups[3] === 0 &&
|
||||
groups[4] === 0 &&
|
||||
groups[5] === 0xffff
|
||||
) {
|
||||
const v4 = `${groups[6] >> 8}.${groups[6] & 0xff}.${groups[7] >> 8}.${groups[7] & 0xff}`;
|
||||
return isBlockedIpv4(v4);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `address` (an IP literal, bracket-free) is one the proxy must never
|
||||
* connect to. Non-IP input is never blocked here: names are judged by
|
||||
* `isBlockedWebviewHostname()` at save time and by their resolved addresses at
|
||||
* connect time.
|
||||
*/
|
||||
export function isBlockedEgressAddress(address: string): boolean {
|
||||
const kind = isIP(address);
|
||||
if (kind === 4) return isBlockedIpv4(address);
|
||||
if (kind === 6) return isBlockedIpv6(address);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge a URL hostname as `URL.hostname` hands it over: IPv6 literals arrive in
|
||||
* brackets, names may carry a trailing dot, and case is irrelevant.
|
||||
*
|
||||
* @returns a short human-readable reason when blocked, null when allowed.
|
||||
*/
|
||||
export function blockedWebviewHostReason(hostname: string): string | null {
|
||||
const host = hostname
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.replace(/\.$/, '')
|
||||
.toLowerCase();
|
||||
if (isBlockedEgressAddress(host)) return `${host} is a link-local or cloud-metadata address`;
|
||||
if (BLOCKED_HOSTNAMES.has(host)) return `${host} is a cloud-metadata hostname`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Schema-friendly boolean form of `blockedWebviewHostReason()` over a raw URL string. */
|
||||
export function isBlockedWebviewUrl(raw: string): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw.trim());
|
||||
} catch {
|
||||
return false; // not this predicate's job; the URL shape check rejects it
|
||||
}
|
||||
return blockedWebviewHostReason(url.hostname) !== null;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @fileoverview Guarded egress for the web-tab proxy: the IO half of the policy in
|
||||
* `webview-egress-policy.ts`.
|
||||
*
|
||||
* Three outbound paths exist for a saved dashboard URL (the "Test" probe, the
|
||||
* HTTP proxy, the WebSocket relay), and all three must judge the RESOLVED address
|
||||
* rather than the hostname string, or a name pointing at 169.254.169.254 (an
|
||||
* attacker's own DNS, or `metadata.google.internal` on GCP) walks straight past
|
||||
* a literal-only check. So:
|
||||
*
|
||||
* - `createEgressLookup()` is a `net.connect`-shaped `lookup` that resolves with
|
||||
* `all: true` and refuses when ANY returned address is blocked (Happy Eyeballs
|
||||
* may otherwise pick the one we did not inspect).
|
||||
* - `webviewFetch()` runs undici's own `fetch` through an `Agent` whose connector
|
||||
* uses that lookup. undici's fetch rather than Node's global one, and undici's
|
||||
* Agent rather than a dispatcher handed to the global fetch, so the two are
|
||||
* always the same undici version: Node bundles its own copy, and a mismatched
|
||||
* dispatch protocol between the two fails in ways no test here would catch.
|
||||
* - The WebSocket relay passes the same lookup to `ws`, which forwards it to
|
||||
* `http.request`.
|
||||
*
|
||||
* ⚠️ A lookup hook never sees an IP LITERAL: Node's `net.connect` skips DNS for
|
||||
* those. Every caller therefore runs `blockedWebviewHostReason()` on the URL's
|
||||
* hostname synchronously BEFORE connecting, and `webviewFetch()` does it for its
|
||||
* own callers. Neither half is redundant.
|
||||
*/
|
||||
|
||||
import { promises as dns, type LookupAddress, type LookupOptions } from 'node:dns';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
import { Agent, fetch as undiciFetch, type RequestInit, type Response } from 'undici';
|
||||
import { blockedWebviewHostReason, isBlockedEgressAddress } from './webview-egress-policy.js';
|
||||
|
||||
export const EGRESS_BLOCKED_CODE = 'CODEMAN_EGRESS_BLOCKED';
|
||||
|
||||
/** Thrown (or delivered as the lookup error) when a target resolves into a blocked range. */
|
||||
export class WebviewEgressBlockedError extends Error {
|
||||
readonly code = EGRESS_BLOCKED_CODE;
|
||||
constructor(reason: string) {
|
||||
super(`Blocked: ${reason}; the web-tab proxy never relays to link-local or cloud-metadata addresses`);
|
||||
this.name = 'WebviewEgressBlockedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal message when `err`, or anything in its `cause` chain, is an egress
|
||||
* refusal; null otherwise. undici's fetch wraps a connect failure as
|
||||
* `TypeError('fetch failed', { cause })`, so the interesting error is one level
|
||||
* down, and callers want ITS message, not "fetch failed".
|
||||
*/
|
||||
export function egressBlockedReason(err: unknown): string | null {
|
||||
let current: unknown = err;
|
||||
for (let depth = 0; depth < 8 && current && typeof current === 'object'; depth++) {
|
||||
const candidate = current as { code?: unknown; message?: unknown; cause?: unknown };
|
||||
if (candidate.code === EGRESS_BLOCKED_CODE) {
|
||||
return typeof candidate.message === 'string' ? candidate.message : 'Blocked by egress policy';
|
||||
}
|
||||
current = candidate.cause;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Boolean form of `egressBlockedReason()`. */
|
||||
export function isEgressBlockedError(err: unknown): boolean {
|
||||
return egressBlockedReason(err) !== null;
|
||||
}
|
||||
|
||||
/** `net.connect`'s `lookup` signature, which undici's connector and `ws` both forward to it. */
|
||||
export type EgressLookup = LookupFunction;
|
||||
|
||||
/** Resolver seam for tests: what the lookup consults for a name's addresses. */
|
||||
export type ResolveAll = (hostname: string, options: LookupOptions) => Promise<LookupAddress[]>;
|
||||
|
||||
const defaultResolveAll: ResolveAll = (hostname, options) => {
|
||||
const family = typeof options.family === 'string' ? Number(options.family.replace(/^IPv/i, '')) : options.family;
|
||||
return dns.lookup(hostname, {
|
||||
...(family === 4 || family === 6 ? { family } : {}),
|
||||
...(options.hints !== undefined ? { hints: options.hints } : {}),
|
||||
all: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a `lookup` for `net.connect` / undici's connector / `ws` that refuses
|
||||
* blocked resolved addresses. Every address is inspected, not just the first:
|
||||
* with `autoSelectFamily` Node races the whole list.
|
||||
*/
|
||||
export function createEgressLookup(resolve: ResolveAll = defaultResolveAll): EgressLookup {
|
||||
return (hostname, options, callback) => {
|
||||
// Node's callback type carries a non-optional address; on error `net` reads
|
||||
// only `err`, so the placeholder values are never looked at.
|
||||
const fail = (err: NodeJS.ErrnoException) => callback(err, '', 0);
|
||||
resolve(hostname, options ?? {}).then(
|
||||
(addresses) => {
|
||||
const blocked = addresses.find((entry) => isBlockedEgressAddress(entry.address));
|
||||
if (blocked) {
|
||||
fail(new WebviewEgressBlockedError(`${hostname} resolves to ${blocked.address}`));
|
||||
return;
|
||||
}
|
||||
if (options?.all) {
|
||||
callback(null, addresses, 0);
|
||||
return;
|
||||
}
|
||||
const first = addresses[0];
|
||||
if (!first) {
|
||||
const notFound: NodeJS.ErrnoException = new Error(`getaddrinfo ENOTFOUND ${hostname}`);
|
||||
notFound.code = 'ENOTFOUND';
|
||||
fail(notFound);
|
||||
return;
|
||||
}
|
||||
callback(null, first.address, first.family);
|
||||
},
|
||||
(err: NodeJS.ErrnoException) => fail(err)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** Process-wide lookup for the WebSocket relay (and anything else `net`-shaped). */
|
||||
export const webviewEgressLookup: EgressLookup = createEgressLookup();
|
||||
|
||||
/**
|
||||
* An undici `Agent` whose connections resolve through `lookup`. Exported as a
|
||||
* factory so a test can inject a resolver and prove the hook is honoured
|
||||
* end-to-end; production uses the lazily-built singleton below.
|
||||
*/
|
||||
export function createWebviewDispatcher(lookup: EgressLookup = webviewEgressLookup): Agent {
|
||||
return new Agent({ connect: { lookup } });
|
||||
}
|
||||
|
||||
let dispatcher: Agent | undefined;
|
||||
function webviewDispatcher(): Agent {
|
||||
dispatcher ??= createWebviewDispatcher();
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* `fetch` for dashboard targets. Refuses a blocked IP literal synchronously (the
|
||||
* lookup hook never sees one) and routes everything else through the guarded
|
||||
* Agent, where a name resolving into a blocked range fails the connect with a
|
||||
* `WebviewEgressBlockedError` as the `cause` of undici's `fetch failed` TypeError.
|
||||
* Check either shape with `isEgressBlockedError()`.
|
||||
*/
|
||||
export function webviewFetch(target: URL, init: RequestInit = {}): Promise<Response> {
|
||||
const reason = blockedWebviewHostReason(target.hostname);
|
||||
if (reason) return Promise.reject(new WebviewEgressBlockedError(reason));
|
||||
return undiciFetch(target.href, { ...init, dispatcher: webviewDispatcher() });
|
||||
}
|
||||
@@ -93,6 +93,10 @@ const DROP_RESPONSE_HEADERS = new Set([
|
||||
'access-control-allow-headers',
|
||||
'access-control-expose-headers',
|
||||
'access-control-max-age',
|
||||
// The capability rides in every proxied URL, so the upstream's own referrer
|
||||
// policy must not decide whether third parties receive it. Ours is stamped in
|
||||
// buildDownstreamResponseHeaders.
|
||||
'referrer-policy',
|
||||
]);
|
||||
|
||||
/** The same-origin path prefix an iframe loads for a given capability. */
|
||||
@@ -352,6 +356,15 @@ export function buildDownstreamResponseHeaders(
|
||||
headers[lower] = value;
|
||||
}
|
||||
|
||||
// Every URL inside the frame carries the capability, and a dashboard that sets
|
||||
// `no-referrer-when-downgrade` or `unsafe-url` would hand it to any third-party
|
||||
// host it links or embeds. `same-origin` keeps the Referer on requests back to
|
||||
// Codeman (the 404 fallback and `refererPath` rely on it; both compare URL
|
||||
// origins, which an opaque-origin frame still satisfies) and strips it for
|
||||
// everyone else. A `<meta name="referrer">` inside the document can still
|
||||
// override this; that is the dashboard author's own decision about their page.
|
||||
headers['referrer-policy'] = 'same-origin';
|
||||
|
||||
const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext));
|
||||
|
||||
return { headers, setCookie, csp };
|
||||
|
||||
Reference in New Issue
Block a user