diff --git a/CHANGELOG.md b/CHANGELOG.md index e90c26c6..82f0b3c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,76 @@ # aicodeman +## 1.5.1 + +### Patch Changes + +- Docker session-mode deep-review fixes — the work intended for the skipped **1.4.2**, now merged onto the 1.5.x line — plus a recap of the multi-user mode shipped in 1.5.0. + + **Docker resume actually works now.** `DockerCase.lastClaudeSessionId` was read at quick-start but never written, so the documented resume-after-container-stop never fired. Claude-mode docker panes now pin a deterministic conversation id (`claudeDockerPaneCommand()`): a fresh launch runs `claude --session-id || claude --resume ` (a duplicate `--session-id` exits 1 "already in use", so the fallback resumes after a container stop/reboot — verified CLI behavior), an explicit resume runs `--resume || --session-id ` so a stale id never dead-panes. The id is persisted at launch and again on hook / last-response conversation-id adoption. Verified end-to-end across a `docker stop` + relaunch and a full container recreate. + + **Config-drift detection + recreate (was documented but entirely missing).** The `codeman.confighash` label was stamped but never read, so docker-host config edits silently never applied. Quick-start now compares via `checkDockerConfigDrift()` and refuses a drifted launch with `CONFLICT`; the UI confirms and calls the new `POST /api/docker-cases/:name/recreate` (refused while the case has live sessions), then relaunches with the new config. New SSE event `docker:containerRecreated`. + + **Model picker now applies to docker sessions.** `modelOverride` was absent from `QuickStartSchema`, so the App Settings Claude Model choice was silently inert for docker runs. It is now accepted and applied via `updateCaseModel` for local and docker quick-starts (still rejected for remote, where the settings file would land on the wrong machine). + + **Import hardening.** `importDockerBundle` validates the untrusted cross-machine manifest before trusting any field (`validateImportManifest`: engine/image/containerWorkdir/network/caseName/schemaVersion — a hostile `engine` could previously select the probe binary); the outer bundle tar gets the same member-traversal guard as the inner workspace tar; the quarantine image tag derives from the schema-validated case name. + + **Remote-daemon correctness.** All docker probes and the base-image auto-build now honor a host's `context`/`daemonHost` (`dockerEngineArgv`) instead of always probing the local daemon. + + **Smaller fixes:** commas are rejected in docker workspace/workdir/destination paths (a comma corrupts the `--mount type=bind,src=…` CSV spec, which shell escaping cannot protect); a dead `this.escapeHtml` reference in the exports refresh is fixed; `docker:importComplete` / `docker:containerRecreated` get frontend SSE listeners so other open tabs refresh; the File Viewer header button is hidden on phone headers like its siblings. + + **Docs.** CLAUDE.md + READMEs synced with the current feature set, including a full zh-CN README re-translation. + + **Multi-user mode (recap — shipped in 1.5.0).** Opt-in named users (`--multiuser` / `CODEMAN_MULTIUSER=1`, off by default) with per-user case spaces and full ownership scoping of sessions, cases, cron jobs, scheduled runs, search, file previews, and real-time SSE/WS streams. Non-admin users default to Claude's classifier-guarded `--permission-mode auto`; raw shell mode, cron `launchCommand`, skip-permissions, and the Codex/Gemini bypass switches require an explicit per-user `canBypassPermissions` grant. Machine-level resources are admin-only. Admin API (`/api/admin/users*`) with one-time passwords, last-admin invariants, and an append-only audit log; self-service `/api/me` + password change; and a `codeman users add|passwd|list|rm` CLI. Off by default is byte-identical to single-user. Note: multi-user separates workspaces for a trusted team; it is not a security boundary between mutually-distrusting users (all sessions share the host OS account) — pair with Docker cases for real isolation. + +## 1.5.0 + +### Minor Changes + +- 0ab2416: Opt-in multi-user mode (`--multiuser` / `CODEMAN_MULTIUSER=1`, off by default). + + Named users with individually scrypt-hashed passwords in `~/.codeman/users.json`, per-user case spaces under `~/codeman-users//cases`, and ownership scoping of sessions (create/list/delete/mutate, incl. bulk delete), cases, cron jobs + run history, scheduled runs, search, file previews, session history, away digest, subagent/workflow monitors, and real-time SSE/WS streams (including the debounced session/task update path, clipboard, and push notifications). A non-admin's `workingDir` is realpath-confined to their own space at every spawn/link path (session create, quick-start, cron create/fire, scheduled runs, case link/docker-link, docker import). Non-admin users default to Claude's classifier-guarded `--permission-mode auto`; raw shell mode, cron `launchCommand`, skip-permissions, and the Codex/Gemini bypass switches require an explicit per-user `canBypassPermissions` grant (enforced at every spawn site incl. one-shots, plan generation, scheduled runs, and remote launches). Machine-level resources (remote/Docker hosts + host reads, mux sessions, orchestrator, tunnel, self-update, settings) are admin-only. Admin API (`/api/admin/users*`) with one-time passwords, last-admin invariants (validated before any teardown), and an append-only audit log; self-service `/api/me` + password change; a frontend admin Users tab + change-password modal; and `codeman users add|passwd|list|rm` CLI. Also adds a global `auto` Claude startup permission mode. When off, behavior is byte-identical to single-user. + + Auth hardening: the login throttle verifies the password before consulting the per-account failure bucket (a correct password can never be locked out); the `mustChangePassword` lockbox covers the WebSocket terminal; the cookie fast-path re-validates identity against the store each request (so a CLI/admin delete/disable/demote takes effect promptly); a role/grant change revokes the target's sessions. (Known limitation: a bare CLI `codeman users passwd` reset — no delete — does not by itself revoke an already-active cookie until it expires; use `codeman users rm`, the admin API, or a restart to force-revoke.) Data-integrity hardening: the store distinguishes a missing users file from a corrupt/unreadable one (so a transient read error can't overwrite all accounts) and writes via a unique per-process temp file; the earlier fire-and-forget `touchLastLogin` corruption race is serialized. + + Note: multi-user mode separates workspaces for a trusted team; it is not a security boundary between users (all sessions share the host OS account). Pair with Docker cases for real isolation. + +## 1.4.1 + +### Patch Changes + +- **Docker session mode** hardening + fixes, plus a File Viewer header button. + + **What Docker session mode is** (recap): a case can run inside an isolated, hardened Docker container instead of on the host, and any of the CLI backends (Claude, Codex, Gemini, OpenCode, or a plain shell) runs inside it. It is a location overlay on cases — not a new session mode — and the container analog of remote-SSH cases: a local tmux pane `docker exec`s into a durable in-container tmux, with exactly one long-lived container per case that multiple sessions share. The workspace, credentials, and conversation transcripts are bind-mounted so the agent is authenticated and resumable; containers are hardened by default (`--cap-drop ALL`, `--security-opt no-new-privileges`, non-root, pids/memory caps, `--init`, never `--privileged` or the docker socket) and export-safe. Start one with the one-click "Run in Docker" checkbox on Create Case, or the Docker tab for full control. + + This release fixes the rough edges found running it for real: + + Docker cases: + - **Seamless Claude auth in containers**: `~/.claude.json` is no longer bind-mounted as a single file (a mount point that broke Claude's atomic-rename config writes — forcing re-auth and, via failed in-place writes, corrupting the host `~/.claude.json`). It is now seeded as a writable, onboarding-complete copy, so a docker session boots straight to the prompt (no theme picker, login, or folder-trust prompt). + - **Claude-state isolation**: containers no longer bind-mount the whole `~/.claude` directory (which wrote backups/tasks/teams/settings back into the host). Only `~/.claude/projects` transcripts are shared (host watchers + `--resume`); credentials, settings, and stats-cache are seeded as writable copies; everything else stays container-local. + - **Codex/Gemini/gcloud/opencode isolation**: same treatment — codex shares `sessions/` + `history.jsonl` (response-viewer + resume) and seeds `auth.json`/`config.toml`; gemini/gcloud/opencode are whole seed-copies. Containers never write their credential state back into the host dirs. + - **Base image auto-builds on first use**: a missing `codeman/agent:base` no longer blocks case creation or launch; it builds locally on first use (concurrency-safe, with SSE progress toasts). + - **UTF-8 locale**: containers set `LANG`/`LC_ALL=C.UTF-8` so tmux renders Claude's box-drawing correctly (fixes `qqqq` line artifacts). + - **Create Case UI**: larger, collapsed-by-default "Run in Docker" settings with a shorter hint; dockerized cases show a short `(docker)` tag (or the custom host id) in the case menus. + - **Tab naming**: docker/remote (and codex/gemini/opencode) sessions now follow the `w-` convention instead of `codeman-`. + + Other: + - **File Viewer header button** (opt-in via App Settings, Header Displays): toggle the file browser panel from the header. + - Fixed a timezone-boundary flaky test in the away-digest route suite. + +## 1.4.0 + +### Minor Changes + +- Add **Docker session mode**: a case can now run inside an isolated Docker container instead of on the host, with configurable network / resource / credential settings, multiple sessions sharing one per-case container, and one-click export to move a container (toolchain + workspace) to another machine. + - Docker is a location overlay on cases (not a new session mode), mirroring the remote-SSH feature: a local tmux pane runs `docker exec -it` into a durable in-container tmux server. The container is scoped to the case (`codeman-case-`), so multiple sessions share it; killing one session never stops the shared container. + - New `/api/docker-hosts` CRUD, `/api/cases/docker-link`, and a `/api/quick-start` docker branch. Create Case gains a **Docker** tab. Base image is built locally via `scripts/build-agent-image.mjs` (node + claude/codex/gemini/opencode + tmux, secret-free, arbitrary-uid-writable HOME). + - Hardened by default: `--cap-drop ALL`, `--security-opt no-new-privileges`, non-root, `--pids-limit`, `--memory`==`--memory-swap`, `--init`; never `--privileged` or the docker socket. Convenient credential default bind-mounts host `~/.claude` etc. read-write (never captured by `docker commit`); a sealed profile is opt-in. + - Two-layer durability: reconnect after a Codeman restart reattaches the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript via `--resume`. + - Export / import: full-image (`docker commit` + `save` + workspace tar + manifest) or workspace-only, to one portable `.codeman-container.tgz`; import validates checksums, guards path traversal, and re-tags the loaded image into a quarantined namespace. Instance-scoped boot reaper cleans orphaned containers. New `docker:*` SSE events. Docs in `docs/docker-cases.md`. + - Robustness: sets `CLAUDE_CODE_TMPDIR` in the container so claude launches regardless of workspace path. In-container hooks require the server to be reachable from the container (documented); on a loopback-only bind, idle detection falls back to output-based. + + Also wire session, away-digest, and cron header-button visibility toggles in App Settings. + ## 1.3.5 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index e39051e5..9569ded1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,15 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Quick Reference -| Task | Command | -|------|---------| -| Dev server | `npm run dev` (or `npx tsx src/index.ts web`) | -| Type check | `tsc --noEmit` | -| Lint | `npm run lint` (fix: `npm run lint:fix`) | -| Format | `npm run format` (check: `npm run format:check`) | +| Task | Command | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Dev server | `npm run dev` (or `npx tsx src/index.ts web`) | +| Type check | `tsc --noEmit` | +| Lint | `npm run lint` (fix: `npm run lint:fix`) | +| Format | `npm run format` (check: `npm run format:check`) | | Single test | `npm test -- test/.test.ts` (or `npx vitest run --config config/vitest.config.ts test/.test.ts`) — ⚠ **never** run bare `npm test`, see Testing section | -| Build | `npm run build` (esbuild via `scripts/build.mjs`, NOT tsc — `tsc --noEmit` is type-check only) | -| Production | `npm run build && systemctl --user restart codeman-web` | +| Build | `npm run build` (esbuild via `scripts/build.mjs`, NOT tsc — `tsc --noEmit` is type-check only) | +| Production | `npm run build && systemctl --user restart codeman-web` | ## CRITICAL: Session Safety @@ -30,15 +30,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 2. **Frontend changes**: Use Playwright to load the page and assert the UI renders correctly. Use `waitUntil: 'domcontentloaded'` (not `networkidle` — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values 3. **Only after verification passes**, proceed with COM -The production server caches static files for 1 year, `immutable` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an *absolute* URL or from JS rather than a ` + + + +
+
+
+ + With zerolag-input + instant local echo +
+
+
+
keystrokes echo in 0 ms
+
+
+
+
+ + Without + server round-trip echo +
+
+
+
keystrokes echo after ~350 ms
+
+ +`; + +async function recordZerolagScene(browser, videoDir) { + console.log('\n2/2 Recording zerolag-demo...'); + + const context = await browser.newContext({ + viewport: ZEROLAG_VIEWPORT, + deviceScaleFactor: 1, + recordVideo: { dir: videoDir, size: ZEROLAG_VIEWPORT }, + }); + const recStart = Date.now(); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + + await page.setContent(ZEROLAG_HTML, { waitUntil: 'load' }); + await page.waitForFunction(() => typeof Terminal !== 'undefined'); + + await page.evaluate(() => { + const theme = { + background: '#131316', + foreground: '#e8e8ea', + cursor: '#22c55e', + cursorAccent: '#131316', + }; + const mk = (id) => { + const term = new Terminal({ + cols: 44, + rows: 5, + fontSize: 20, + fontFamily: "'SF Mono', 'Cascadia Code', Menlo, monospace", + cursorBlink: true, + cursorStyle: 'block', + theme, + }); + term.open(document.getElementById(id)); + term.write('\x1b[32m❯\x1b[0m '); + return term; + }; + window.termLeft = mk('termLeft'); + window.termRight = mk('termRight'); + }); + await sleep(600); + + const sceneStart = Date.now(); + const typingMs = TYPED_TEXT.length * TYPE_INTERVAL_MS; + const totalMs = typingMs + REMOTE_FLUSH_MS + ZEROLAG_TAIL_HOLD; + + await page.evaluate( + ({ text, interval, flushEvery }) => { + let i = 0; + const remoteQueue = []; + const typer = setInterval(() => { + if (i >= text.length) { clearInterval(typer); return; } + const ch = text[i++]; + window.termLeft.write(ch); // local echo: instant + remoteQueue.push(ch); // server echo: waits for the round-trip + }, interval); + const flusher = setInterval(() => { + if (remoteQueue.length) window.termRight.write(remoteQueue.splice(0).join('')); + if (i >= text.length && remoteQueue.length === 0) clearInterval(flusher); + }, flushEvery); + }, + { text: TYPED_TEXT, interval: TYPE_INTERVAL_MS, flushEvery: REMOTE_FLUSH_MS } + ); + + await sleep(totalMs + 400); + + await page.close(); + const videoPath = await page.video().path(); + await context.close(); + + return { + videoPath, + ss: (sceneStart - recStart) / 1000 - 0.6, // small lead-in with idle cursors + duration: (totalMs + 600) / 1000, + }; +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + console.log('='.repeat(60)); + console.log('Codeman README GIF Capture'); + console.log('='.repeat(60)); + + const server = await startStaticServer(); + const videoDir = mkdtempSync(join(tmpdir(), 'codeman-gifs-')); + let browser; + + try { + browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], + }); + + const sub = await recordSubagentScene(browser, videoDir); + const subGif = outPath('images', 'subagent-demo.gif'); + webmToGif(sub.videoPath, subGif, { ss: Math.max(0, sub.ss), duration: sub.duration, width: 960, fps: 8 }); + console.log(` Saved: ${subGif}`); + + const zl = await recordZerolagScene(browser, videoDir); + const zlGif = outPath('images', 'zerolag-demo.gif'); + webmToGif(zl.videoPath, zlGif, { ss: Math.max(0, zl.ss), duration: zl.duration, width: 900, fps: 10 }); + console.log(` Saved: ${zlGif}`); + + console.log('\nDone.'); + } catch (err) { + console.error('\nFatal error:', err.message); + console.error(err.stack); + process.exitCode = 1; + } finally { + if (browser) await browser.close().catch(() => {}); + server.close(); + rmSync(videoDir, { recursive: true, force: true }); + } +} + +process.on('SIGINT', () => process.exit(1)); + +main(); diff --git a/scripts/capture-readme-real.mjs b/scripts/capture-readme-real.mjs index 9427b93d..7142c106 100644 --- a/scripts/capture-readme-real.mjs +++ b/scripts/capture-readme-real.mjs @@ -51,6 +51,9 @@ async function newCtx(browser) { localStorage.setItem('codeman:skin', skin); localStorage.setItem('codeman-font-size', String(font)); const blob = { skin, showFileBrowser: false, showProjectInsights: false }; + // Don't auto-hide subagent windows that belong to a non-active tab — the + // subagent scene re-homes agents and needs both windows visible at once. + blob.subagentActiveTabOnly = false; if (planUsage) blob.showPlanUsageLimits = true; localStorage.setItem('codeman-app-settings', JSON.stringify(blob)); } catch { @@ -136,9 +139,9 @@ async function sceneSubagent(browser) { const sessions = await listSessions(page); const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id; if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId); - // Wait (up to ~25s) for live subagents to arrive via SSE into app.subagents. + // Wait (up to ~45s) for live subagents to arrive via SSE into app.subagents. let agents = []; - for (let i = 0; i < 25; i++) { + for (let i = 0; i < 45; i++) { agents = await page.evaluate(() => Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' })) ); @@ -151,6 +154,44 @@ async function sceneSubagent(browser) { await context.close(); return; } + // The window body renders from app.subagentActivity, which fills ONLY from live + // SSE tool-call/progress events — a fresh client never gets past activity replayed. + // So sit connected and wait for live activity to accumulate, then open the two + // agents that actually have content (otherwise the windows read "No activity yet"). + let active = []; + for (let i = 0; i < 100; i++) { + active = await page.evaluate(() => + Array.from(window.app.subagentActivity?.entries?.() || []) + .filter(([, arr]) => Array.isArray(arr) && arr.length >= 1) + .map(([id, arr]) => ({ id, n: arr.length })) + .sort((a, b) => b.n - a.n) + ); + if (active.length >= 2) break; + // xhigh-effort agents churn in bursts between long thinking pauses, so be + // patient (~150s); accept a single populated window after ~45s if that's all. + if (i >= 30 && active.length >= 1) break; + await sleep(1500); + } + console.log(' agents with live activity:', JSON.stringify(active)); + const openIds = (active.length ? active : agents).map((a) => a.id); + // Capture-only DOM nudge: on fresh dev sessions, a tab's claudeSessionId stays the + // Codeman id and never becomes the real Claude conversation UUID, so the window + // open-gate (claudeSessionId === agent.sessionId) + the activeTabOnly hide rule both + // fail. Re-home the chosen agents onto the active tab and align its claudeSessionId + // to the agents' (shared) sessionId so the windows open AND show their live activity. + await page.evaluate( + (ids) => { + const activeId = window.app.activeSessionId; + const tab = window.app.sessions.get(activeId); + ids.slice(0, 2).forEach((id) => { + const a = window.app.subagents.get(id); + if (!a) return; + a.parentSessionId = activeId; + if (tab && a.sessionId) tab.claudeSessionId = a.sessionId; + }); + }, + openIds + ); await page.evaluate( (ids) => { ids.slice(0, 2).forEach((id) => { @@ -159,22 +200,33 @@ async function sceneSubagent(browser) { } catch {} }); }, - agents.map((a) => a.id) + openIds ); await sleep(2000); await page.evaluate(() => { + // Viewport-relative tiling: center two subagent windows over the terminal so + // the layout adapts to whatever VW/VH the capture uses (e.g. the HQ 1100×650 + // recipe) instead of overflowing at narrower widths. const wins = Array.from(window.app.subagentWindows.values()); - const place = [ - { left: 360, top: 60, w: 430, h: 330 }, - { left: 810, top: 60, w: 430, h: 330 }, - ]; + const W = window.innerWidth; + const H = window.innerHeight; + const winW = Math.min(440, Math.floor((W - 60) / 2 - 10)); + const winH = Math.min(360, Math.floor(H * 0.56)); + const top = Math.floor(H * 0.16); + const gap = 16; + const totalW = winW * 2 + gap; + const startLeft = Math.max(16, Math.floor((W - totalW) / 2)); wins.slice(0, 2).forEach((win, i) => { const el = win.element; - const p = place[i]; - el.style.left = p.left + 'px'; - el.style.top = p.top + 'px'; - el.style.width = p.w + 'px'; - el.style.height = p.h + 'px'; + // Force visible: a freshly opened window may be hidden by the activeTabOnly + // rule before we override it (we also seed subagentActiveTabOnly:false). + win.hidden = false; + win.minimized = false; + el.style.display = 'flex'; + el.style.left = startLeft + i * (winW + gap) + 'px'; + el.style.top = top + 'px'; + el.style.width = winW + 'px'; + el.style.height = winH + 'px'; }); }); await sleep(1500); diff --git a/scripts/capture-readme-skin.mjs b/scripts/capture-readme-skin.mjs new file mode 100644 index 00000000..0c1f0c17 --- /dev/null +++ b/scripts/capture-readme-skin.mjs @@ -0,0 +1,1074 @@ +#!/usr/bin/env node + +/** + * capture-readme-screenshots.mjs + * + * Captures deterministic README screenshots using Playwright + page.route() mock injection. + * No real Claude CLI or server needed — all API responses are mocked. + * + * Usage: node scripts/capture-readme-screenshots.mjs + * Port: 3199 (static file server) + * Output: docs/images/ and docs/screenshots/ + */ + +import { chromium } from 'playwright'; +import { createServer } from 'http'; +import { readFileSync, existsSync } from 'fs'; +import { join, extname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const PROJECT_ROOT = join(__dirname, '..'); +const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public'); + +// Non-destructive review run: which skin to force + where to write. +// Does NOT touch docs/images or docs/screenshots. Override via env. +const SKIN = process.env.SKIN || 'daylight-blue'; +const OUT_DIR = process.env.SCREENSHOT_OUT_DIR || join(PROJECT_ROOT, 'screenshots-readme', SKIN); +const PORT = 3199; +const VIEWPORT = { width: 1280, height: 720 }; +const DEVICE_SCALE_FACTOR = 1; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ─── MIME Types ────────────────────────────────────────────────────────────── + +const MIME_TYPES = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.ttf': 'font/ttf', +}; + +// ─── Static File Server ────────────────────────────────────────────────────── + +function startStaticServer() { + return new Promise((resolve) => { + const server = createServer((req, res) => { + let urlPath = req.url.split('?')[0]; + if (urlPath === '/') urlPath = '/index.html'; + + const filePath = join(PUBLIC_DIR, urlPath); + + if (!existsSync(filePath)) { + res.writeHead(404); + res.end('Not Found'); + return; + } + + try { + const data = readFileSync(filePath); + const ext = extname(filePath); + res.writeHead(200, { + 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream', + 'Cache-Control': 'no-cache', + }); + res.end(data); + } catch { + res.writeHead(500); + res.end('Internal Server Error'); + } + }); + + server.listen(PORT, () => { + console.log(`Static server on http://localhost:${PORT}`); + resolve(server); + }); + }); +} + +// ─── Mock Data ─────────────────────────────────────────────────────────────── + +const SESSION_IDS = { + w1: 'sess-w1-0001', + w3: 'sess-w3-0003', + w4: 'sess-w4-0004', + w5: 'sess-w5-0005', + s1: 'sess-s1-0006', + s2: 'sess-s2-0007', + s3: 'sess-s3-0008', +}; + +const RALPH_SESSION_ID = 'sess-ralph-demo'; +const RALPH_SHELL_ID = 'sess-ralph-shell'; + +function makeSession(id, name, mode, status, extra = {}) { + return { + id, + pid: status === 'idle' ? null : 12345 + Math.floor(Math.random() * 10000), + status, + workingDir: '/home/arkon/codeman-cases/testcase', + currentTaskId: null, + createdAt: Date.now() - 3600000, + lastActivityAt: Date.now() - (status === 'idle' ? 60000 : 5000), + name, + mode, + autoClearEnabled: false, + autoClearThreshold: 140000, + autoCompactEnabled: false, + autoCompactThreshold: 110000, + autoCompactPrompt: '', + imageWatcherEnabled: false, + totalCost: mode === 'claude' ? 0.12 : 0, + inputTokens: mode === 'claude' ? 18000 : 0, + outputTokens: mode === 'claude' ? 11500 : 0, + ralphEnabled: false, + niceEnabled: false, + niceValue: 10, + color: 'default', + flickerFilterEnabled: false, + cliVersion: '2.1.177', + cliModel: 'Opus 4.8', + cliAccountType: 'Claude Max', + cliLatestVersion: '2.1.177', + messageCount: mode === 'claude' ? 15 : 0, + isWorking: status === 'busy', + lastPromptTime: Date.now() - 30000, + bufferStats: { + terminalBufferSize: 4096, + textOutputSize: 2048, + messageCount: 15, + maxTerminalBuffer: 2097152, + maxTextOutput: 1048576, + maxMessages: 1000, + }, + taskStats: { total: 0, running: 0, completed: 0, failed: 0 }, + taskTree: [], + tokens: { + input: mode === 'claude' ? 18000 : 0, + output: mode === 'claude' ? 11500 : 0, + total: mode === 'claude' ? 29500 : 0, + }, + autoClear: { enabled: false, threshold: 140000 }, + nice: { enabled: false, niceValue: 10 }, + ralphLoop: null, + ralphTodos: [], + ralphTodoStats: { total: 0, completed: 0, percentComplete: 0 }, + respawnEnabled: false, + respawnConfig: null, + respawn: null, + claudeSessionId: `claude-${id}`, + ...extra, + }; +} + +// Standard session set — kept small (4 tabs) so the header isn't crowded. +// w1/w3/w4 are the sessions the scenes select; s1 adds a shell tab for variety. +const STANDARD_SESSIONS = [ + makeSession(SESSION_IDS.w1, 'w1-testcase', 'claude', 'busy'), + makeSession(SESSION_IDS.w3, 'w3-testcase', 'claude', 'busy'), + makeSession(SESSION_IDS.w4, 'w4-testcase', 'claude', 'idle'), + makeSession(SESSION_IDS.s1, 's1-testcase', 'shell', 'busy'), +]; + +// Ralph demo sessions (2 tabs) +const RALPH_SESSIONS = [ + makeSession(RALPH_SESSION_ID, 'ralph-8tasks-demo', 'claude', 'busy', { + ralphEnabled: true, + tokens: { input: 22000, output: 15300, total: 37300 }, + inputTokens: 22000, + outputTokens: 15300, + totalCost: 0.28, + ralphLoop: { + enabled: true, + active: true, + completionPhrase: 'ALL_TASKS_DONE', + startedAt: Date.now() - 60000, + cycleCount: 3, + maxIterations: null, + elapsedHours: null, + }, + ralphTodos: [ + { id: '1', text: 'Add TypeScript types to all functions', status: 'completed' }, + { id: '2', text: 'Add input validation with proper error messages', status: 'completed' }, + { id: '3', text: 'Add JSDoc documentation to each function', status: 'completed' }, + { id: '4', text: 'Add unit tests for formatDate and parseJSON', status: 'in_progress' }, + { id: '5', text: 'Add unit tests for debounce and deepClone', status: 'pending' }, + { id: '6', text: 'Add unit tests for slugify and truncate', status: 'pending' }, + { id: '7', text: 'Add unit tests for randomId and groupBy', status: 'pending' }, + { id: '8', text: 'Create an index.ts that exports all utilities', status: 'pending' }, + { id: '9', text: 'Add TypeScript types to all functions', status: 'completed' }, + ], + ralphTodoStats: { total: 9, completed: 4, percentComplete: 44 }, + }), + makeSession(RALPH_SHELL_ID, 's1-demo-testing', 'shell', 'busy'), +]; + +// Mock mux (tmux) sessions for Monitor panel +const MUX_SESSIONS = [ + { + sessionId: 'mux-w1', + name: 'w1-testcase', + muxName: 'w1-testcase', + mode: 'claude', + pid: 292239, + stats: { memoryMB: 2.3, cpuPercent: 0, childCount: 1 }, + }, + { + sessionId: 'mux-w3', + name: 'w3-testcase', + muxName: 'w3-testcase', + mode: 'claude', + pid: 292394, + stats: { memoryMB: 2.4, cpuPercent: 0.1, childCount: 1 }, + }, + { + sessionId: 'mux-w4', + name: 'w4-testcase', + muxName: 'w4-testcase', + mode: 'claude', + pid: 292497, + stats: { memoryMB: 2.4, cpuPercent: 0, childCount: 1 }, + }, +]; + +// Mock subagents for subagent-spawn screenshot +const MOCK_SUBAGENTS = [ + { + agentId: 'agent-001', + sessionId: 'claude-sess-w1-0001', + projectHash: 'abc123', + filePath: '/tmp/agent-001.jsonl', + startedAt: new Date(Date.now() - 120000).toISOString(), + lastActivityAt: Date.now() - 5000, + status: 'active', + toolCallCount: 12, + entryCount: 45, + fileSize: 32000, + description: 'Find and document all API endpoints in src/', + model: 'claude-haiku-4-5-20251001', + modelShort: 'haiku', + totalInputTokens: 15000, + totalOutputTokens: 8000, + parentSessionId: SESSION_IDS.w1, + }, + { + agentId: 'agent-002', + sessionId: 'claude-sess-w1-0001', + projectHash: 'abc123', + filePath: '/tmp/agent-002.jsonl', + startedAt: new Date(Date.now() - 90000).toISOString(), + lastActivityAt: Date.now() - 3000, + status: 'active', + toolCallCount: 8, + entryCount: 30, + fileSize: 22000, + description: 'Explore and understand test structure in test/', + model: 'claude-haiku-4-5-20251001', + modelShort: 'haiku', + totalInputTokens: 12000, + totalOutputTokens: 6000, + parentSessionId: SESSION_IDS.w1, + }, + { + agentId: 'agent-003', + sessionId: 'claude-sess-w1-0001', + projectHash: 'abc123', + filePath: '/tmp/agent-003.jsonl', + startedAt: new Date(Date.now() - 60000).toISOString(), + lastActivityAt: Date.now() - 8000, + status: 'active', + toolCallCount: 5, + entryCount: 18, + fileSize: 14000, + description: 'Analyze TypeScript type definitions in src/types.ts', + model: 'claude-haiku-4-5-20251001', + modelShort: 'haiku', + totalInputTokens: 8000, + totalOutputTokens: 4000, + parentSessionId: SESSION_IDS.w1, + }, +]; + +function buildInitPayload(sessions, subagents = []) { + const respawnStatus = {}; + for (const s of sessions) { + respawnStatus[s.id] = { + state: 'idle', + cycleCount: 0, + lastActivityTime: Date.now(), + timeSinceActivity: 0, + promptDetected: false, + workingDetected: false, + detection: {}, + config: null, + }; + } + + return { + version: '0.1556', + sessions, + scheduledRuns: [], + respawnStatus, + globalStats: { + totalInputTokens: 145000, + totalOutputTokens: 87000, + totalCost: 1.82, + totalSessionsCreated: 12, + firstRecordedAt: Date.now() - 86400000, + lastUpdatedAt: Date.now(), + }, + subagents, + timestamp: Date.now(), + }; +} + +// ─── Terminal Content (ANSI) ───────────────────────────────────────────────── + +// Colors +const RST = '\x1b[0m'; +const RED = '\x1b[31m'; +const GRN = '\x1b[32m'; +const YEL = '\x1b[33m'; +const BLU = '\x1b[34m'; +const MAG = '\x1b[35m'; +const CYN = '\x1b[36m'; +const GRY = '\x1b[90m'; +const WHT = '\x1b[37m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; + +// Claude Code init banner (matching the overview screenshot) +// Claude TUI input-box border — spans nearly the full terminal width (~158 cols +// at 1280px) so the input box and status reach the right edge like the wrapped +// conversation above. A short rule leaves an empty bottom-right corner. +const BOX = '─'.repeat(157); + +// Right-align `right` against `left` across `width` cols, ignoring ANSI SGR codes +// for length — used to push token/version status to the right edge of the footer. +// 157 ≈ the terminal's full col count (158 at 1280px), so the rule + status reach +// the same right edge the wrapped conversation does (no lower-right sliver). +const visibleLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length; +const spread = (left, right, width = 157) => + left + ' '.repeat(Math.max(2, width - visibleLen(left) - visibleLen(right))) + right; + +// Claude scenes are { body, footer }: `body` scrolls from the top, `footer` +// (the input box + status bar) is pinned to the bottom of the terminal by +// injectState(), with blank padding filling the gap — exactly like the real +// Claude Code TUI. This keeps every screenshot looking full-height regardless +// of how short the scripted conversation is. +const TERMINAL_INIT = { + body: [ + '', + `${GRN}●${RST} I'll implement the utility module end-to-end — strict TypeScript types on every export, runtime input validation with clear error messages, JSDoc with examples, and a full Vitest suite. Let me read the current file and the test setup first so I match the existing conventions.`, + '', + `${GRN}●${RST} ${BOLD}Read${RST}(src/utils.ts)`, + ` ${GRY}░${RST} Read ${BOLD}84${RST} lines ${GRY}·${RST} exports: formatDate, parseJSON, debounce, deepClone, slugify, truncate, randomId, groupBy`, + '', + `${GRN}●${RST} ${BOLD}Read${RST}(config/vitest.config.ts)`, + ` ${GRY}░${RST} Read ${BOLD}41${RST} lines ${GRY}·${RST} globals enabled, node environment, coverage via v8`, + '', + `${GRN}●${RST} The functions currently accept ${BOLD}any${RST} and assume well-formed input. I'll add explicit parameter and return types, guard every one against null/undefined with descriptive errors, and document the expected shapes with JSDoc examples.`, + '', + `${GRN}●${RST} ${BOLD}Edit${RST}(src/utils.ts)`, + ` ${GRY}░${RST} Updated with ${GRN}+42${RST} ${GRY}/${RST} ${RED}-6${RST} ${GRY}·${RST} src/utils.ts is now ${BOLD}126${RST} lines, fully typed and validated`, + '', + `${GRN}●${RST} ${BOLD}Write${RST}(src/index.ts)`, + ` ${GRY}░${RST} Wrote ${BOLD}11${RST} lines ${GRY}·${RST} barrel file re-exporting every utility for one clean import surface`, + '', + `${GRN}●${RST} ${BOLD}Write${RST}(test/utils.test.ts)`, + ` ${GRY}░${RST} Wrote ${BOLD}96${RST} lines ${GRY}·${RST} 4 describe blocks, 18 assertions covering happy paths and edge cases`, + '', + `${GRN}●${RST} ${BOLD}Bash${RST}(npm test -- utils.test.ts)`, + ` ${GRY}░${RST} ${GRN}✓${RST} formatDate ${GRY}(3ms)${RST} ${GRN}✓${RST} parseJSON ${GRY}(1ms)${RST} ${GRN}✓${RST} debounce ${GRY}(12ms)${RST} ${GRN}✓${RST} deepClone ${GRY}(2ms)${RST} ${GRN}✓${RST} slugify ${GRY}(1ms)${RST}`, + ` ${GRY}░${RST} ${GRN}✓${RST} truncate ${GRY}(1ms)${RST} ${GRN}✓${RST} randomId ${GRY}(1ms)${RST} ${GRN}✓${RST} groupBy ${GRY}(2ms)${RST} ${GRY}·${RST} ${BOLD}Tests${RST} ${GRN}8 passed (8)${RST} ${GRY}·${RST} ${GRY}Duration 312ms${RST}`, + '', + `${GRN}●${RST} All green. The utilities now have strict types across all 8 exports, runtime validation with descriptive errors, JSDoc usage examples, a barrel export in src/index.ts, and 8 passing unit tests.`, + '', + `${GRN}●${RST} Done — fully typed, validated, documented, re-exported, and covered by green tests. Want me to wire these into the existing modules next, or add property-based tests with fast-check?`, + ], + footer: [ + BOX, + `${GRY}❯${RST} now add property-based tests with fast-check for parseJSON and deepClone█`, + BOX, + ` ${GRY}Opus 4.8 (1M context)${RST} ${DIM}·${RST} ${GRY}in: 84,712 out: 1,203 ctx: 21%${RST}`, + spread( + ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, + `${BOLD}84.7k tokens${RST}` + ), + spread('', `${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`), + ], +}; + +// Claude working output (for multi-session-dashboard / subagent-spawn) +const TERMINAL_WORKING = { + body: [ + '', + `${GRN}●${RST} The codebase is essentially a template/scaffold with just a CLAUDE.md file and empty ${BOLD}src/${RST} directory. Since there's already a comprehensive`, + `CLAUDE.md file present, let me read it fully and suggest improvements based on the /init command requirements:`, + '', + `${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`, + ` ${GRY}░${RST} Read ${BOLD}407${RST} lines`, + '', + `${GRN}●${RST} ${BOLD}Bash${RST}(find . -maxdepth 2 -type f -not -path "*/node_modules/*" | head)`, + ` ${GRY}░${RST} ./CLAUDE.md ${GRY}·${RST} ./package.json ${GRY}·${RST} ./tsconfig.json ${GRY}·${RST} ${GRY}(src/ is empty)${RST}`, + '', + `${GRN}●${RST} Now I understand the situation. This is a ${BOLD}template CLAUDE.md file${RST} designed for the Claude Code framework with the "Ralph Loop" autonomous work`, + `mode. The current file is very comprehensive for workflow instructions but lacks the key technical information that /init is supposed to generate:`, + '', + `1. ${BOLD}No build/test/lint commands${RST} - The file references npm test, npm run lint, npm run build but these are generic examples, not project-specific`, + `2. ${BOLD}No architecture documentation${RST} - The session log mentions files that don't exist in the empty repo`, + `3. ${BOLD}Placeholder tech stack${RST} - Still shows [TECHNOLOGIES_USED]`, + `4. ${BOLD}Too much workflow/process documentation${RST} - Much of this duplicates Claude Code's built-in behavior`, + '', + `The /init command is asking me to create a CLAUDE.md focused on ${BOLD}codebase-specific guidance${RST} (commands, architecture), not workflow processes. Since`, + `this is essentially an empty project, I should create a minimal CLAUDE.md that:`, + `1. Has the required header`, + `2. Notes that the project needs initialization`, + `3. Removes the verbose workflow documentation that's either built-in to Claude Code or should be in separate docs`, + '', + `Let me create an improved, streamlined CLAUDE.md appropriate for this empty project state.`, + '', + `${MAG}✻${RST} ${YEL}Caramelizing...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 54s · ↓ 2.1k tokens)${RST}`, + ], + footer: [ + BOX, + `${GRY}❯${RST} █`, + BOX, + ` ${GRY}Opus 4.8 (1M context)${RST} ${DIM}·${RST} ${GRY}in: 28,163 out: 2,104 ctx: 14%${RST}`, + spread( + ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, + `${BOLD}28.1k tokens${RST}` + ), + spread('', `${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`), + ], +}; + +// Ralph terminal content (matching ralph-tracker screenshot) +const TERMINAL_RALPH = { + body: [ + '', + `${GRN}●${RST} ${BOLD}Search${RST}(pattern: "**/*.test.ts")`, + ` ${GRY}░${RST} Found ${BOLD}0${RST} files`, + '', + `${GRN}●${RST} ${BOLD}Search${RST}(pattern: "**/test/**")`, + ` ${GRY}░${RST} Found ${BOLD}0${RST} files`, + '', + `${GRN}●${RST} ${BOLD}Search${RST}(pattern: "**/package.json")`, + ` ${GRY}░${RST} Found ${BOLD}0${RST} files`, + '', + `${MAG}✻${RST} ${YEL}Adding unit tests for formatDate and parseJSON...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · ${BOLD}ctrl+t${RST}${GRY} to hide todos · 1m 27s · ↓ 5.8k tokens · thinking)${RST}`, + ` ${GRY}░${RST} ${GRY}☒${RST} Add TypeScript types to all functions`, + ` ${GRY}☒${RST} Add input validation with proper error messages`, + ` ${GRY}☒${RST} Add JSDoc documentation to each function`, + ` ${GRY}░${RST} ${BOLD}Add unit tests for formatDate and parseJSON${RST}`, + ` ${GRY}░${RST} ☐ Add unit tests for debounce and deepClone`, + ` ${GRY}░${RST} ☐ Add unit tests for slugify and truncate`, + ` ${GRY}░${RST} ☐ Add unit tests for randomId and groupBy`, + ` ${GRY}░${RST} ☐ Create an index.ts that exports all utilities`, + ], + footer: [ + BOX, + `${GRY}❯${RST} █`, + '', + BOX, + ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, + ` ${BOLD}37267 tokens${RST}`, + ` ${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`, + ], +}; + +// Subagent window content (tool call activity) +const SUBAGENT_ACTIVITY = { + 'agent-001': [ + { type: 'tool', tool: 'Glob', input: { pattern: 'src/**/*.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-001' }, + { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/src/web/server.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-001' }, + { type: 'tool', tool: 'Grep', input: { pattern: 'app\\.get|app\\.post|app\\.put|app\\.delete', path: 'src/' }, timestamp: new Date().toISOString(), agentId: 'agent-001' }, + { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/src/web/schemas.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-001' }, + { type: 'message', role: 'assistant', text: 'Found 47 API endpoints across server.ts. Documenting REST paths...', timestamp: new Date().toISOString(), agentId: 'agent-001' }, + ], + 'agent-002': [ + { type: 'tool', tool: 'Glob', input: { pattern: 'test/**/*.test.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' }, + { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' }, + { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/config/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' }, + { type: 'message', role: 'assistant', text: 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...', timestamp: new Date().toISOString(), agentId: 'agent-002' }, + ], +}; + +// Subagent spawn terminal content +const TERMINAL_SUBAGENT = { + body: [ + '', + `${GRN}●${RST} Working on ${CYN}/home/arkon/codeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`, + '', + `${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`, + ` ${GRY}░${RST} Read ${BOLD}127${RST} lines ${GRY}│${RST} ${CYN}1.2KB${RST}`, + '', + `${GRN}●${RST} ${BOLD}Bash${RST}(find . -name "*.ts" -not -path "*/node_modules/*" | head -20)`, + ` ${GRY}░${RST} ./src/index.ts`, + ` ${GRY}░${RST} ./src/types.ts`, + ` ${GRY}░${RST} ./src/session.ts`, + ` ${GRY}░${RST} ./src/web/server.ts`, + ` ${GRY}░${RST} ./src/web/schemas.ts`, + ` ${GRY}░${RST} ./test/session.test.ts`, + ` ${GRY}░${RST} ${GRY}... (14 more)${RST}`, + '', + `${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`, + '', + `${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`, + ` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`, + '', + `${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`, + ` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`, + '', + `${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`, + ` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`, + '', + `${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`, + '', + ` ${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls${RST} — Glob, Read(server.ts), Grep(endpoints)...`, + ` ${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls${RST} — Glob, Read(test-utils), Read(vitest.config)...`, + ` ${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}5 tool calls${RST} — Read(types.ts), Grep(interface)...`, + '', + `${GRN}●${RST} ${DIM}171.8k, 13s │ 1.7k tokens │ thinking${RST}`, + ], + footer: [ + spread( + ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, + `${BOLD}171.8k tokens${RST}` + ), + spread('', `${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`), + ], +}; + +// Flatten a { body, footer } scene into the raw buffer the mocked /terminal +// fetch returns. injectState() re-renders it bottom-anchored once the terminal +// exists and its real row count is known; this naive join is only the fallback +// the fetch path needs before that happens. +function joinScene(scene) { + return [...scene.body, '', '', ...scene.footer].join('\r\n'); +} + +// ─── Route Interceptors ────────────────────────────────────────────────────── + +async function setupRoutes(page, initPayload, terminalContent) { + const terminalBuffer = joinScene(terminalContent); + // CRITICAL: Block SSE entirely to prevent reconnection loops that clear state. + // We'll inject data directly via page.evaluate() instead. + await page.route('**/api/events', async (route) => { + await route.abort(); + }); + + // Terminal buffer endpoint — used by selectSession() + await page.route('**/api/sessions/*/terminal**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + terminalBuffer, + status: 'busy', + fullSize: terminalBuffer.length, + truncated: false, + }), + }); + }); + + // Mux sessions (subpath routes before base route) + await page.route('**/api/mux-sessions/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); + + await page.route('**/api/mux-sessions', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ sessions: MUX_SESSIONS, muxAvailable: true }), + }); + }); + + // Settings + await page.route('**/api/settings', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + showSubagents: true, + subagentTrackingEnabled: true, + subagentActiveTabOnly: false, + showMonitor: true, + }), + }); + }); + + // Subagent window states (restore) + await page.route('**/api/subagent-window-states', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + // Subagent parents (restore) + await page.route('**/api/subagent-parents', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + // Session-specific subagents + await page.route('**/api/sessions/*/subagents', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, data: initPayload.subagents || [] }), + }); + }); + + // Interactive attach (no-op) + await page.route('**/api/sessions/*/interactive', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); + + // Resize (no-op) + await page.route('**/api/sessions/*/resize', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); + + // Catch-all for any remaining API endpoints + await page.route('**/api/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); +} + +/** + * Inject mock state into the app and render. + * Bypasses SSE entirely — calls handleInit directly, then selects a session + * and writes terminal content. + */ +async function injectState(page, initPayload, terminalContent, activeSessionId) { + // Wait for app to be ready + await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 15000 }); + await sleep(1000); + + // Cancel the SSE fallback timer and inject state directly + await page.evaluate((payload) => { + const app = window.app; + // Cancel the init fallback timer (prevents double handleInit) + if (app._initFallbackTimer) { + clearTimeout(app._initFallbackTimer); + app._initFallbackTimer = null; + } + // Inject state + app.handleInit(payload); + }, initPayload); + + await sleep(1500); + + // Select the target session (this triggers terminal fetch via our mocked route) + if (activeSessionId) { + await page.evaluate((sid) => { + window.app.activeSessionId = null; // Force re-select + window.app.selectSession(sid); + }, activeSessionId); + await sleep(2000); + } + + await sleep(1000); + + // Clean up UI elements that look wrong in screenshots + await page.evaluate(() => { + // Kill SSE reconnection entirely and hide the connection indicator + const app2 = window.app; + if (app2) { + // Stop reconnection timers + if (app2.sseReconnectTimeout) clearTimeout(app2.sseReconnectTimeout); + if (app2.eventSource) { app2.eventSource.close(); app2.eventSource = null; } + app2._connectionStatus = 'connected'; + // Monkey-patch so it never re-shows + app2._updateConnectionIndicator = () => {}; + app2.connectSSE = () => {}; + app2.setConnectionStatus = () => {}; + } + // Remove the indicator element from the DOM entirely + const indicator = document.getElementById('connectionIndicator'); + if (indicator) indicator.remove(); + + // Hide respawn banner (shows "idle" state which isn't needed in screenshots) + const respawnBanner = document.getElementById('respawnBanner'); + if (respawnBanner) respawnBanner.style.display = 'none'; + + // Fix system stats display (CPU/MEM) — inject mock values + const statsEl = document.getElementById('headerSystemStats'); + if (statsEl) { + statsEl.innerHTML = ` + CPU +
+ 24% + MEM +
+ 16.9G + `; + } + + // Hide subagents panel by default (will be shown per-scenario) + const subagentsPanel = document.getElementById('subagentsPanel'); + if (subagentsPanel) subagentsPanel.style.display = 'none'; + + // Ensure monitor panel is closed by default (will be opened per-scenario) + const monitorPanel = document.getElementById('monitorPanel'); + if (monitorPanel) monitorPanel.classList.remove('open'); + + // Slim the header for a cleaner screenshot: keep ONLY CPU/MEM stats and the + // settings gear. Hide the token count and the other header affordances + // (font A-/A+ controls, lifecycle-log doc icon). The response-viewer / + // multi-monitor / notification buttons are already hidden by default. + const headerHide = [ + '#headerTokens', + '.header-font-controls', + '.btn-lifecycle-log', + '.btn-response-viewer-header', + '.btn-multimonitor', + '.btn-notifications', + ]; + for (const sel of headerHide) { + document.querySelectorAll(sel).forEach((el) => { + el.style.setProperty('display', 'none', 'important'); + }); + } + + // Make the terminal / Claude pane dominate the window: slim the header and + // toolbar (default 36px + 42px ≈ 13% chrome) down to ~7%, so the terminal + // fills ~93% of the height. injectState re-fits the terminal to the enlarged + // container right after this, so the extra space becomes real terminal rows. + const slim = document.createElement('style'); + slim.id = 'codeman-screenshot-slim'; + slim.textContent = ` + :root { --header-height: 22px !important; --toolbar-height: 28px !important; } + .header { min-height: 22px !important; padding: 1px 10px !important; align-items: center !important; } + .session-tab { padding-top: 1px !important; padding-bottom: 1px !important; } + .toolbar { height: 28px !important; min-height: 28px !important; padding: 0 10px !important; } + .btn-toolbar { padding: 2px 8px !important; } + .toolbar-select, .toolbar-input { padding-top: 2px !important; padding-bottom: 2px !important; } + `; + document.head.appendChild(slim); + }); + + // Let the layout reflow to its FINAL height before sizing the terminal. The + // cleanup above hides the respawn banner (~48px), so the terminal container + // only grows to its full height now — fitting earlier under-counts rows and + // leaves a dead strip below the last row. + await sleep(700); + + // Render the scene bottom-anchored at the terminal's true full size: re-fit to + // the settled container, size rows to fill it exactly (fit() under-counts on + // initial load — the WebGL cell metric lags — which is the "switch tabs/reload + // to fix it" symptom), then neutralize later re-fits. `body` scrolls from the + // top; `footer` (input box + status bar) is pinned to the bottom with blank + // padding between — exactly like the real Claude Code TUI. Runs unconditionally + // so it overrides whatever the mocked /terminal fetch wrote. + await page.evaluate((scene) => { + const app = window.app; + const term = app.terminal; + if (!term) return; + try { + app.fitAddon?.fit(); + } catch { + /* ignore */ + } + try { + const cont = document.getElementById('terminalContainer'); + const cellH = term._core?._renderService?.dimensions?.css?.cell?.height || 21; + if (cont && cellH) { + const fitRows = Math.max(1, Math.floor(cont.clientHeight / cellH)); + if (fitRows !== term.rows) term.resize(term.cols, fitRows); + } + } catch { + /* ignore */ + } + if (app.fitAddon) app.fitAddon.fit = () => {}; + const rows = term.rows || 27; + const cols = term.cols || 158; + const body = scene.body || []; + const footer = scene.footer || []; + // Count how many TERMINAL ROWS a line array actually occupies, accounting for + // line wrapping — a long paragraph wraps to 2+ rows. Using the raw array + // length under-counts wrapped lines and leaves a dead band above the footer + // (and term.write() is async so measuring the live cursor afterward is + // unreliable). Strip ANSI SGR codes first so only VISIBLE width counts. + const rowsFor = (arr) => + arr.reduce((sum, line) => { + const visible = line.replace(/\x1b\[[0-9;]*m/g, ''); + return sum + Math.max(1, Math.ceil(visible.length / cols)); + }, 0); + const pad = Math.max(0, rows - rowsFor(body) - rowsFor(footer)); + const lines = [...body, ...Array(pad).fill(''), ...footer]; + term.clear(); + term.reset(); + term.write(lines.join('\r\n')); + term.scrollToBottom(); + }, terminalContent); + + // Final settle — let xterm.js WebGL renderer, fonts, and layout stabilize + await sleep(1500); +} + +// ─── Screenshot Scenarios ──────────────────────────────────────────────────── + +async function captureOverview(page) { + console.log('\n1/4 Capturing claude-overview.png...'); + + const initPayload = buildInitPayload(STANDARD_SESSIONS); + await setupRoutes(page, initPayload, TERMINAL_INIT); + + await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await injectState(page, initPayload, TERMINAL_INIT, SESSION_IDS.w4); + + // Open Monitor panel and populate mux sessions + await page.evaluate((muxSessions) => { + const app = window.app; + if (!app) return; + app.muxSessions = muxSessions; + const panel = document.getElementById('monitorPanel'); + if (panel) { + panel.classList.add('open'); + app._renderMuxSessionsImmediate(); + } + }, MUX_SESSIONS); + await sleep(1500); + + await page.screenshot({ + path: join(OUT_DIR, 'claude-overview.png'), + fullPage: false, + }); + console.log(' Saved: ' + join(OUT_DIR, 'claude-overview.png')); +} + +async function captureSubagentSpawn(page) { + console.log('\n2/4 Capturing subagent-spawn.png...'); + + const initPayload = buildInitPayload(STANDARD_SESSIONS, MOCK_SUBAGENTS); + await setupRoutes(page, initPayload, TERMINAL_SUBAGENT); + + await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await injectState(page, initPayload, TERMINAL_SUBAGENT, SESSION_IDS.w1); + + // Open subagent windows and populate activity + await page.evaluate((activity) => { + const app = window.app; + if (!app) return; + + // Populate subagent activity data + for (const [agentId, entries] of Object.entries(activity)) { + app.subagentActivity.set(agentId, entries); + } + + // Open subagent windows (first two for cleaner screenshot) + app.openSubagentWindow('agent-001'); + app.openSubagentWindow('agent-002'); + }, SUBAGENT_ACTIVITY); + await sleep(2000); + + // Position windows nicely for 1280x720 viewport + await page.evaluate(() => { + const app = window.app; + if (!app) return; + + const windows = Array.from(app.subagentWindows.values()); + if (windows.length >= 2) { + const w1 = windows[0].element; + w1.style.left = '420px'; + w1.style.top = '40px'; + w1.style.width = '420px'; + w1.style.height = '320px'; + + const w2 = windows[1].element; + w2.style.left = '850px'; + w2.style.top = '40px'; + w2.style.width = '420px'; + w2.style.height = '320px'; + } + }); + await sleep(1500); + + await page.screenshot({ + path: join(OUT_DIR, 'subagent-spawn.png'), + fullPage: false, + }); + console.log(' Saved: ' + join(OUT_DIR, 'subagent-spawn.png')); +} + +async function captureRalphTracker(page) { + console.log('\n3/5 Capturing ralph-tracker-8tasks-44percent.png...'); + + const initPayload = buildInitPayload(RALPH_SESSIONS); + await setupRoutes(page, initPayload, TERMINAL_RALPH); + + await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await injectState(page, initPayload, TERMINAL_RALPH, RALPH_SESSION_ID); + + // Ensure Ralph state panel is visible and expanded + await page.evaluate((sessionId) => { + const app = window.app; + if (!app) return; + + const session = app.sessions.get(sessionId); + if (session) { + app.ralphStates.set(sessionId, { + loop: session.ralphLoop, + todos: session.ralphTodos || [], + }); + app.ralphStatePanelCollapsed = false; + app.ralphClosedSessions.delete(sessionId); + app._renderRalphStatePanelImmediate(); + } + }, RALPH_SESSION_ID); + await sleep(1500); + + await page.screenshot({ + path: join(OUT_DIR, 'ralph-tracker-8tasks-44percent.png'), + fullPage: false, + }); + console.log(' Saved: ' + join(OUT_DIR, 'ralph-tracker-8tasks-44percent.png')); +} + +async function captureMultiSessionDashboard(page) { + console.log('\n3/4 Capturing multi-session-dashboard.png...'); + + const initPayload = buildInitPayload(STANDARD_SESSIONS); + await setupRoutes(page, initPayload, TERMINAL_WORKING); + + await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await injectState(page, initPayload, TERMINAL_WORKING, SESSION_IDS.w3); + + await page.screenshot({ + path: join(OUT_DIR, 'multi-session-dashboard.png'), + fullPage: false, + }); + console.log(' Saved: ' + join(OUT_DIR, 'multi-session-dashboard.png')); +} + +async function captureMultiSessionMonitor(page) { + console.log('\n4/4 Capturing multi-session-monitor.png...'); + + const initPayload = buildInitPayload(STANDARD_SESSIONS); + await setupRoutes(page, initPayload, TERMINAL_INIT); + + await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await injectState(page, initPayload, TERMINAL_INIT, SESSION_IDS.w4); + + // Open Monitor panel and populate mux sessions + await page.evaluate((muxSessions) => { + const app = window.app; + if (!app) return; + app.muxSessions = muxSessions; + const panel = document.getElementById('monitorPanel'); + if (panel) { + panel.classList.add('open'); + app._renderMuxSessionsImmediate(); + } + }, MUX_SESSIONS); + await sleep(1500); + + await page.screenshot({ + path: join(OUT_DIR, 'multi-session-monitor.png'), + fullPage: false, + }); + console.log(' Saved: ' + join(OUT_DIR, 'multi-session-monitor.png')); +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + console.log('='.repeat(60)); + console.log(`Codeman README Screenshot Capture — skin: ${SKIN}`); + console.log('='.repeat(60)); + console.log(`Port: ${PORT} | Viewport: ${VIEWPORT.width}x${VIEWPORT.height}`); + console.log(`Output: ${OUT_DIR}`); + console.log(''); + + const server = await startStaticServer(); + let browser; + + try { + browser = await chromium.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + ], + }); + + // Each scenario gets its own fresh page to avoid route conflicts. + // (Ralph tracker intentionally omitted — de-prioritized feature.) + for (const scenario of [ + captureOverview, + captureSubagentSpawn, + captureMultiSessionDashboard, + captureMultiSessionMonitor, + ]) { + const context = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: DEVICE_SCALE_FACTOR }); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + + // Force the chosen theme skin before any page script runs (matches the + // pre-paint contract in index.html: localStorage 'codeman:skin'). + await page.addInitScript((skin) => { + try { + localStorage.setItem('codeman:skin', skin); + } catch { + /* ignore */ + } + }, SKIN); + + try { + await scenario(page); + } catch (err) { + console.error(` ERROR: ${err.message}`); + } finally { + await context.close(); + } + } + + console.log('\n' + '='.repeat(60)); + console.log('All screenshots captured!'); + console.log('='.repeat(60)); + console.log(`\nOutput files (in ${OUT_DIR}):`); + console.log(' claude-overview.png'); + console.log(' subagent-spawn.png'); + console.log(' multi-session-dashboard.png'); + console.log(' multi-session-monitor.png'); + } catch (err) { + console.error('\nFatal error:', err.message); + console.error(err.stack); + process.exitCode = 1; + } finally { + if (browser) await browser.close().catch(() => {}); + server.close(); + console.log('\nDone.'); + } +} + +// Handle interrupts +process.on('SIGINT', () => { + console.log('\nInterrupted.'); + process.exit(1); +}); + +main(); diff --git a/src/cli.ts b/src/cli.ts index 03883bc3..c27e8ded 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -584,7 +584,11 @@ program '--allow-unauthenticated-network', 'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)' ) + .option('--multiuser', 'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)') .action(async (options) => { + // The flag is surfaced to the rest of the process via the env var so + // isMultiUserMode() has a single source of truth (see config/multiuser.ts). + if (options.multiuser) process.env.CODEMAN_MULTIUSER = '1'; const { startWebServer } = await import('./web/server.js'); const host = options.host; const port = parseInt(options.port, 10); @@ -626,6 +630,168 @@ program } }); +// ============ Multi-user Commands ============ +// +// Operate directly on ~/.codeman/users.json (via user-store) with NO running +// server, honoring CODEMAN_INSTANCE. This is the headless bootstrap path and the +// recovery answer to "locked out: last admin forgot password". + +/** Read a password from stdin without echoing. Falls back to plain read on non-TTY. */ +function promptHiddenPassword(question: string): Promise { + const stdin = process.stdin; + if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') { + // Non-interactive: read a single line from stdin. + return new Promise((resolve) => { + let buf = ''; + stdin.setEncoding('utf8'); + stdin.on('data', (d) => (buf += d)); + stdin.on('end', () => resolve(buf.replace(/\r?\n$/, ''))); + }); + } + return new Promise((resolve) => { + process.stdout.write(question); + let input = ''; + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + const onData = (chunk: string) => { + for (const c of chunk) { + if (c === '\n' || c === '\r' || c === '\u0004') { + stdin.setRawMode!(false); + stdin.pause(); + stdin.removeListener('data', onData); + process.stdout.write('\n'); + resolve(input); + return; + } else if (c === '\u0003') { + process.stdout.write('\n'); + process.exit(1); + } else if (c === '\u007f' || c === '\b') { + input = input.slice(0, -1); + } else { + input += c; + } + } + }; + stdin.on('data', onData); + }); +} + +function readAllStdin(): Promise { + return new Promise((resolve) => { + let buf = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (d) => (buf += d)); + process.stdin.on('end', () => resolve(buf.replace(/\r?\n$/, ''))); + }); +} + +const usersCmd = program.command('users').description('Manage multi-user accounts (~/.codeman/users.json)'); + +usersCmd + .command('add ') + .description('Create a user (prompts for password; use --password-stdin for scripts)') + .option('--admin', 'Create as an admin') + .option('--password-stdin', 'Read the password from stdin instead of prompting') + .action(async (name, options) => { + const { createUser, isValidUsername } = await import('./user-store.js'); + if (!isValidUsername(name)) { + console.error(chalk.red('✗ Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])')); + process.exit(1); + } + try { + let password: string; + if (options.passwordStdin) { + password = await readAllStdin(); + } else { + password = await promptHiddenPassword('New password: '); + const confirm = await promptHiddenPassword('Confirm password: '); + if (password !== confirm) { + console.error(chalk.red('✗ Passwords do not match')); + process.exit(1); + } + } + if (!password || password.length < 8) { + console.error(chalk.red('✗ Password must be at least 8 characters')); + process.exit(1); + } + const user = await createUser({ username: name, role: options.admin ? 'admin' : 'user', password }); + console.log(chalk.green(`✓ Created ${user.role} "${user.username}"`)); + } catch (err) { + console.error(chalk.red(`✗ ${getErrorMessage(err)}`)); + process.exit(1); + } + }); + +usersCmd + .command('passwd ') + .description('Reset a user password') + .option('--password-stdin', 'Read the new password from stdin instead of prompting') + .action(async (name, options) => { + const { setPassword } = await import('./user-store.js'); + try { + let password: string; + if (options.passwordStdin) { + password = await readAllStdin(); + } else { + password = await promptHiddenPassword('New password: '); + const confirm = await promptHiddenPassword('Confirm password: '); + if (password !== confirm) { + console.error(chalk.red('✗ Passwords do not match')); + process.exit(1); + } + } + await setPassword(name, password, { mustChangePassword: false }); + console.log(chalk.green(`✓ Password updated for "${name}"`)); + } catch (err) { + console.error(chalk.red(`✗ ${getErrorMessage(err)}`)); + process.exit(1); + } + }); + +usersCmd + .command('list') + .alias('ls') + .description('List all users') + .action(async () => { + const { readUsers } = await import('./user-store.js'); + const users = await readUsers(true); + if (users.length === 0) { + console.log(chalk.yellow('No users defined (run: codeman users add --admin)')); + return; + } + console.log(chalk.bold('\nUsers:')); + for (const u of users) { + const role = u.role === 'admin' ? chalk.magenta('admin') : chalk.cyan('user '); + const state = u.disabled ? chalk.red('disabled') : chalk.green('enabled '); + const flags = [u.mustChangePassword ? 'must-change-pw' : '', u.canBypassPermissions ? 'can-bypass' : ''] + .filter(Boolean) + .join(' '); + console.log(` ${role} ${state} ${u.username}${flags ? chalk.gray(` [${flags}]`) : ''}`); + } + console.log(''); + }); + +usersCmd + .command('rm ') + .description('Delete a user') + .option('--delete-space', "Also delete the user's ~/codeman-users/ space") + .action(async (name, options) => { + const { deleteUser, deleteUserSpace } = await import('./user-store.js'); + try { + await deleteUser(name); + if (options.deleteSpace) { + await deleteUserSpace(name); + console.log(chalk.green(`✓ Deleted user "${name}" and their space`)); + } else { + console.log(chalk.green(`✓ Deleted user "${name}" (space left on disk)`)); + } + } catch (err) { + console.error(chalk.red(`✗ ${getErrorMessage(err)}`)); + process.exit(1); + } + }); + program .command('doctor') .alias('check-deps') diff --git a/src/config/multiuser.ts b/src/config/multiuser.ts new file mode 100644 index 00000000..cc2340b6 --- /dev/null +++ b/src/config/multiuser.ts @@ -0,0 +1,63 @@ +/** + * @fileoverview Multi-user mode gating + limits (opt-in, off by default). + * + * Multi-user mode is enabled by `codeman web --multiuser` (which sets + * `CODEMAN_MULTIUSER=1`) or the env var directly. When OFF, behavior is + * byte-identical to today: `users.json` is never read and all ownership scoping + * is bypassed. Everything here is per-instance like the rest of Codeman: a beta + * instance (`CODEMAN_INSTANCE=beta`) has its own `users.json` via `dataPath()`, + * and its user spaces live under the same shared `~/codeman-users` as prod (like + * `~/codeman-cases`), unless `CODEMAN_USER_SPACES_DIR` overrides it. + * + * See `docs/multi-user-plan.md` sections 3, 4.2, and 11. + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { MAX_CONCURRENT_SESSIONS } from './map-limits.js'; + +/** + * Whether multi-user mode is active. Read from the environment each call so it is + * stable for the process lifetime (env does not change after boot) and trivially + * overridable in tests. Accepts `1` or `true`. + */ +export function isMultiUserMode(): boolean { + const v = process.env.CODEMAN_MULTIUSER; + return v === '1' || v === 'true'; +} + +/** + * Root of per-user spaces: `~/codeman-users` (sibling of `~/codeman-cases`). + * Overridable via `CODEMAN_USER_SPACES_DIR` (used by tests). Resolved lazily so a + * test can point it at a temp dir before the first call. + */ +export function getUserSpacesDir(): string { + return process.env.CODEMAN_USER_SPACES_DIR || join(homedir(), 'codeman-users'); +} + +/** Absolute path to a user's top-level space: `/[/segments]`. */ +export function userSpacePath(username: string, ...segments: string[]): string { + return join(getUserSpacesDir(), username, ...segments); +} + +/** Absolute path to a user's cases dir: `//cases`. */ +export function userCasesDir(username: string): string { + return join(getUserSpacesDir(), username, 'cases'); +} + +/** Maximum number of user accounts (default 25, env `CODEMAN_MAX_USERS`). */ +export function maxUsers(): number { + const n = Number(process.env.CODEMAN_MAX_USERS); + return Number.isInteger(n) && n > 0 ? n : 25; +} + +/** + * Per-user concurrent-session cap (the fairness lever). Defaults to half the + * global cap; overridable via `CODEMAN_MAX_SESSIONS_PER_USER`. The global cap + * (MAX_CONCURRENT_SESSIONS) still applies on top and is shared across users. + */ +export function maxSessionsPerUser(): number { + const n = Number(process.env.CODEMAN_MAX_SESSIONS_PER_USER); + if (Number.isInteger(n) && n > 0) return n; + return Math.max(1, Math.floor(MAX_CONCURRENT_SESSIONS / 2)); +} diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index e1fb64cc..63c83064 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -15,6 +15,8 @@ import { SseEvent } from '../web/sse-events.js'; import { CronJobSchema } from '../web/schemas.js'; import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.js'; import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js'; +import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js'; +import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.js'; import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js'; import { DEFAULT_BLOCKED_TREES, @@ -25,6 +27,7 @@ import { validateSessionFilePath } from '../web/route-helpers.js'; import { computeNextRunAt, dueKeyFor } from './cron-time.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.js'; import type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js'; +import type { GeminiConfig } from '../types/session.js'; import type { CronJobInput } from './cron-input.js'; /** The subset of the route context the cron depends on. */ @@ -108,7 +111,7 @@ export class CronService { // ──────────────────────────── Mutations ─────────────────────────── - createJob(input: CronJobInput): CronJob { + createJob(input: CronJobInput, owner?: string): CronJob { if (Object.keys(this.store.getCronJobs()).length >= MAX_CRON_JOBS) { throw this.badRequest(`Maximum number of cron jobs (${MAX_CRON_JOBS}) reached`); } @@ -117,6 +120,7 @@ export class CronService { const job: CronJob = { id: uuidv4(), name: input.name, + owner, agentType: input.agentType, workingDir: input.workingDir, launchCommand: input.launchCommand, @@ -328,6 +332,12 @@ export class CronService { return this.failRun(job, run, 'workingDir does not exist'); } + // Section 6.3: defense-in-depth workingDir confinement re-check at FIRE time against the + // owner's CURRENT space (complements the create/update gate). No-op in single-user / unset owner. + if (!(await isWorkingDirAllowedForUsername(job.owner, job.workingDir))) { + return this.failRun(job, run, 'workingDir is outside the owner workspace'); + } + // Recurring jobs: close the still-open session created by this job's // previous run before launching the next (default ON, opt-out via // autoClosePreviousSession:false) — otherwise an unattended interval/daily @@ -336,10 +346,21 @@ export class CronService { await this.closePreviousRunSessions(job, run.id); } - // Respect the global session cap. - if (this.deps.sessions.size >= MAX_CONCURRENT_SESSIONS) { + // Respect the global cap AND the owner's per-user cap (multi-user). + const cap = sessionCapacityState(this.deps.sessions, job.owner); + if (cap.atGlobalCap) { return this.failRun(job, run, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`); } + if (cap.atUserCap) { + return this.failRun(job, run, `Owner's per-user session limit reached`); + } + + // Section 6.3: re-resolve the owner's grant at FIRE time (it may have been revoked + // since create). Gates shell/launchCommand AND clamps the external-CLI bypass below. + const ownerGranted = await canUsernameRunPrivilegedCommands(job.owner); + if ((job.agentType === 'shell' || job.launchCommand) && !ownerGranted) { + return this.failRun(job, run, 'Owner lacks the can-bypass-permissions grant for shell/launchCommand jobs'); + } // Create + start the session (mirrors the quick-start route flow). let session: Session; @@ -348,7 +369,15 @@ export class CronService { const globalNice = await this.deps.getGlobalNiceConfig(); const modelConfig = await this.deps.getModelConfig(); const claudeModeConfig = await this.deps.getClaudeModeConfig(); + const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner); const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined; + // Section 6.3: cron carries no per-CLI config, so buildGeminiCommand(undefined) + // would default a non-granted owner to `--approval-mode yolo` (classifier-free) — + // materialize auto_edit for a non-granted gemini owner, mirroring the route clamp + // (#15). Granted/admin/single-user leave it undefined → yolo parity. Codex's absent + // config already defaults to the safe sandbox, so no clamp is needed there. + const geminiConfig: GeminiConfig | undefined = + mode === 'gemini' && !ownerGranted ? { approvalMode: 'auto_edit' } : undefined; session = new Session({ workingDir: job.workingDir, mode, @@ -357,8 +386,10 @@ export class CronService { useMux: true, niceConfig: globalNice, model, - claudeMode: claudeModeConfig.claudeMode, + claudeMode: effectiveClaudeMode, allowedTools: claudeModeConfig.allowedTools, + geminiConfig, + owner: job.owner, }); this.deps.addSession(session); this.store.incrementSessionsCreated(); diff --git a/src/docker-export.ts b/src/docker-export.ts new file mode 100644 index 00000000..e1e259e3 --- /dev/null +++ b/src/docker-export.ts @@ -0,0 +1,465 @@ +/** + * @fileoverview Docker case export / import: move a container (toolchain + any + * in-image changes) PLUS its workspace to another machine as one portable + * `.codeman-container.tgz`, and restore it. + * + * A full-image export = `docker commit` the running container to an image -> + * `docker save` that image -> tar the bind-mounted workspace -> a manifest, all + * bundled into one gzip tarball. A workspace-only export skips the image (fast, + * files-only). Import validates the manifest + per-member checksums, extracts the + * workspace with a path-traversal guard, `docker load`s the image and RE-TAGS it + * into a quarantined namespace (never overwriting a local tag), and hands the + * caller enough to recreate a hardened case on the destination. + * + * Safety (all from the design critic): pause the container spanning the workspace + * tar AND the commit so the two artifacts are mutually consistent; a free-space + * precheck (a full docker graph wedges EVERY session on the host); `docker rmi` + * the intermediate image in a finally; sealed containers refuse a full-image + * export (an in-container login would ride the committed layer); import rejects + * absolute / `..` tar members and checksum mismatches. Bounded by + * runWithConversionLimit so N exports cannot fork-bomb the host. + * + * @module docker-export + */ + +import { createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { join, basename } from 'node:path'; +import { createHash } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { pipeline } from 'node:stream/promises'; +import type { DockerEngine, SessionDocker } from './types.js'; +import { runWithConversionLimit } from './document-conversion-limiter.js'; + +const IS_TEST_MODE = !!process.env.VITEST; + +/** Refuse to export when the target filesystem has less than this free (a full graph wedges the daemon). */ +export const DOCKER_EXPORT_MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB + +/** Manifest schema version (bump on any breaking field change). */ +export const DOCKER_EXPORT_SCHEMA = 1; + +export type DockerExportMode = 'full' | 'workspace'; + +export interface DockerExportManifest { + schemaVersion: number; + caseName: string; + mode: DockerExportMode; + engine: DockerEngine; + image: string; + containerWorkdir: string; + network: string; + createdAt: number; + codemanVersion: string; + mountCredentials: boolean; + /** True when the bundle provably carries no credentials (convenient-mode workspace, or a full image whose creds were bind-mounted and thus never committed). */ + secretFree: boolean; + /** sha256 of each bundle member that is present. */ + checksums: { image?: string; workspace?: string }; +} + +// ========== Pure helpers (unit-tested) ========== + +/** Raw argv prefix for the engine (NO shell escaping — used with spawn). */ +export function dockerArgv(docker: Pick): string[] { + const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker']; + if (docker.context) argv.push('--context', docker.context); + if (docker.daemonHost) argv.push('-H', docker.daemonHost); + return argv; +} + +/** Portable bundle filename for a case export. */ +export function exportBundleName(caseName: string, timestamp: number, mode: DockerExportMode): string { + const suffix = mode === 'workspace' ? 'workspace' : 'container'; + return `${caseName}-${timestamp}.codeman-${suffix}.tgz`; +} + +/** Quarantined image tag for an imported bundle (never overwrites a local tag). */ +export function importedImageTag(caseName: string, timestamp: number): string { + return `codeman/imported-${caseName}:${timestamp}`; +} + +/** Intermediate commit tag for a full-image export (unique per export, rmi'd in finally). */ +export function exportImageTag(caseName: string, timestamp: number): string { + return `codeman/export-${caseName}:${timestamp}`; +} + +/** + * Reject a tar member path that would escape the extraction root (absolute path + * or a `..` component). The import-side traversal guard. + */ +export function isSafeTarMember(member: string): boolean { + const trimmed = member.trim(); + if (!trimmed || trimmed === './') return true; + if (trimmed.startsWith('/')) return false; + // Normalize separators and check each component. + return !trimmed.split('/').some((part) => part === '..'); +} + +/** Parse the image id/ref from `docker load` output ("Loaded image: x" / "Loaded image ID: sha256:..."). */ +export function parseLoadedImageRef(loadOutput: string): string | null { + const idMatch = loadOutput.match(/Loaded image ID:\s*(sha256:[0-9a-f]+)/i); + if (idMatch) return idMatch[1]; + const refMatch = loadOutput.match(/Loaded image:\s*(\S+)/i); + if (refMatch) return refMatch[1]; + return null; +} + +/** + * Validate an imported bundle's manifest BEFORE any of its fields are trusted. + * A bundle is cross-machine input (potentially authored by someone else), and its + * fields flow into stored host/case config that the schema layer never sees: + * `engine` becomes the probe/launch binary selector, `image`/`containerWorkdir` + * reach the shellescaped launch string, `network` is a create arg. Mirror the + * DockerHostSchema/DockerCaseLinkSchema constraints here (throwing, since this is + * not a web-layer module). Exported for unit tests. + */ +export function validateImportManifest(manifest: DockerExportManifest): void { + const fail = (msg: string): never => { + throw new Error(`invalid bundle manifest: ${msg}`); + }; + if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) { + fail(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`); + } + if (manifest.mode !== 'full' && manifest.mode !== 'workspace') fail(`unknown mode ${String(manifest.mode)}`); + if (manifest.engine !== 'docker' && manifest.engine !== 'podman') fail(`unknown engine ${String(manifest.engine)}`); + if (typeof manifest.caseName !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(manifest.caseName)) fail('bad caseName'); + if ( + typeof manifest.image !== 'string' || + manifest.image.length > 512 || + !/^[a-zA-Z0-9][\w./:@-]*$/.test(manifest.image) + ) { + fail('bad image reference'); + } + if ( + typeof manifest.containerWorkdir !== 'string' || + manifest.containerWorkdir.length > 2000 || + !manifest.containerWorkdir.startsWith('/') || + // comma: --mount specs are comma-delimited CSV; shell escaping cannot protect it + /[`$\\"'\n\r;&|<>,]/.test(manifest.containerWorkdir) + ) { + fail('bad containerWorkdir'); + } + if (!['bridge', 'none', 'custom'].includes(manifest.network)) fail(`unknown network ${String(manifest.network)}`); + if (typeof manifest.checksums !== 'object' || manifest.checksums === null) fail('missing checksums'); +} + +// ========== IO helpers ========== + +function run( + cmd: string, + args: string[], + opts: { timeout?: number } = {} +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + let timer: NodeJS.Timeout | undefined; + if (opts.timeout) { + timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`${cmd} timed out after ${opts.timeout}ms`)); + }, opts.timeout); + } + child.stdout.on('data', (d) => (stdout += d)); + child.stderr.on('data', (d) => (stderr += d)); + child.on('error', (err) => { + if (timer) clearTimeout(timer); + reject(err); + }); + child.on('close', (code) => { + if (timer) clearTimeout(timer); + if (code === 0) resolve({ stdout, stderr }); + else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}: ${stderr.trim()}`)); + }); + }); +} + +/** + * Stream `docker save ` stdout to a raw tar file (no shell, no double-gzip). + * Uses stream `pipeline` so completion means the write stream is FULLY flushed to + * disk (a naive child 'close' resolves before the last chunks land, truncating the + * file — a real bug caught in end-to-end testing), AND waits for a clean exit code. + */ +async function saveImageToTar(argv: string[], tag: string, outPath: string): Promise { + const child = spawn(argv[0], [...argv.slice(1), 'save', tag], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = ''; + child.stderr.on('data', (d) => (stderr += d)); + const exited = new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`docker save exited ${code}: ${stderr.trim()}`)) + ); + }); + // pipeline resolves only after the destination has fully flushed. + await Promise.all([pipeline(child.stdout, createWriteStream(outPath)), exited]); +} + +async function sha256File(path: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash('sha256'); + const stream = createReadStream(path); + stream.on('data', (d) => hash.update(d)); + stream.on('error', reject); + stream.on('end', () => resolve(hash.digest('hex'))); + }); +} + +async function freeBytes(path: string): Promise { + try { + const stat = await fs.statfs(path); + return Number(stat.bavail) * Number(stat.bsize); + } catch { + return Number.POSITIVE_INFINITY; // statfs unsupported — don't block + } +} + +async function isContainerRunning(argv: string[], container: string): Promise { + try { + const { stdout } = await run(argv[0], [...argv.slice(1), 'inspect', '-f', '{{.State.Running}}', container], { + timeout: 15_000, + }); + return stdout.trim() === 'true'; + } catch { + return false; + } +} + +export interface ExportResult { + bundlePath: string; + manifest: DockerExportManifest; + sizeBytes: number; +} + +/** + * Export a docker case to a portable bundle. Bounded by runWithConversionLimit. + * `full` mode commits + saves the image AND tars the workspace; `workspace` mode + * tars just the workspace. The container is paused across the artifact capture so + * image and workspace are mutually consistent. + */ +export async function exportDockerCase(params: { + docker: SessionDocker; + caseName: string; + timestamp: number; + exportsDir: string; + mode: DockerExportMode; + codemanVersion: string; +}): Promise { + const { docker, caseName, timestamp, exportsDir, mode, codemanVersion } = params; + + if (mode === 'full' && !docker.mountCredentials) { + throw new Error( + 'full-image export is refused for a sealed (mountCredentials:false) container: an in-container login would ride the committed image layer. Use a workspace-only export.' + ); + } + + if (IS_TEST_MODE) { + // No real docker/tar under vitest — return a deterministic stub. + const manifest: DockerExportManifest = { + schemaVersion: DOCKER_EXPORT_SCHEMA, + caseName, + mode, + engine: docker.engine, + image: docker.image, + containerWorkdir: docker.containerWorkdir, + network: docker.network, + createdAt: timestamp, + codemanVersion, + mountCredentials: docker.mountCredentials, + secretFree: true, + checksums: {}, + }; + return { bundlePath: join(exportsDir, exportBundleName(caseName, timestamp, mode)), manifest, sizeBytes: 0 }; + } + + return runWithConversionLimit(async () => { + if (!existsSync(exportsDir)) mkdirSync(exportsDir, { recursive: true }); + + const free = await freeBytes(exportsDir); + if (free < DOCKER_EXPORT_MIN_FREE_BYTES) { + throw new Error( + `not enough free space to export (need >= ${Math.round(DOCKER_EXPORT_MIN_FREE_BYTES / 1e9)}GB, have ${Math.round(free / 1e9)}GB). A full docker graph wedges every session on the host.` + ); + } + + const argv = dockerArgv(docker); + const bundlePath = join(exportsDir, exportBundleName(caseName, timestamp, mode)); + const stageDir = join(exportsDir, `.stage-${caseName}-${timestamp}`); + mkdirSync(stageDir, { recursive: true }); + const wasRunning = await isContainerRunning(argv, docker.containerName); + let commitTag: string | undefined; + + try { + if (wasRunning) { + await run(argv[0], [...argv.slice(1), 'pause', docker.containerName], { timeout: 30_000 }).catch(() => {}); + } + + const checksums: DockerExportManifest['checksums'] = {}; + + if (mode === 'full') { + commitTag = exportImageTag(caseName, timestamp); + // Blank instance-specific committed env so the image carries no stale host refs. + await run( + argv[0], + [ + ...argv.slice(1), + 'commit', + '-c', + 'ENV CODEMAN_API_URL=', + '-c', + 'ENV CODEMAN_HOOK_SECRET_FILE=', + docker.containerName, + commitTag, + ], + { timeout: 300_000 } + ); + const imageTar = join(stageDir, 'image.tar'); + await saveImageToTar(argv, commitTag, imageTar); + checksums.image = await sha256File(imageTar); + } + + const workspaceTar = join(stageDir, 'workspace.tar'); + await run('tar', ['-cf', workspaceTar, '-C', docker.hostWorkspacePath, '.'], { timeout: 300_000 }); + checksums.workspace = await sha256File(workspaceTar); + + const manifest: DockerExportManifest = { + schemaVersion: DOCKER_EXPORT_SCHEMA, + caseName, + mode, + engine: docker.engine, + image: docker.image, + containerWorkdir: docker.containerWorkdir, + network: docker.network, + createdAt: timestamp, + codemanVersion, + mountCredentials: docker.mountCredentials, + // Convenient mode keeps creds on bind mounts (never committed), so the bundle is secret-free. + secretFree: docker.mountCredentials, + checksums, + }; + await fs.writeFile(join(stageDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); + + const members = + mode === 'full' ? ['manifest.json', 'image.tar', 'workspace.tar'] : ['manifest.json', 'workspace.tar']; + await run('tar', ['-czf', bundlePath, '-C', stageDir, ...members], { timeout: 300_000 }); + + const stat = await fs.stat(bundlePath); + return { bundlePath, manifest, sizeBytes: stat.size }; + } finally { + // Always remove the intermediate image + stage dir, and unpause. + if (commitTag) { + await run(argv[0], [...argv.slice(1), 'rmi', commitTag], { timeout: 60_000 }).catch(() => {}); + } + await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {}); + if (wasRunning) { + await run(argv[0], [...argv.slice(1), 'unpause', docker.containerName], { timeout: 30_000 }).catch(() => {}); + } + } + }); +} + +export interface ImportResult { + manifest: DockerExportManifest; + /** Quarantined image ref the destination case should use (full mode only). */ + importedImage?: string; + /** Directory the workspace was extracted into. */ + workspacePath: string; +} + +/** + * Import a bundle produced by exportDockerCase: validate the manifest + per-member + * checksums, extract the workspace (traversal-guarded) into destWorkspace, and, in + * full mode, `docker load` the image and re-tag it into a quarantined namespace. + */ +export async function importDockerBundle(params: { + bundlePath: string; + destWorkspace: string; + engine: DockerEngine; + timestamp: number; + /** Schema-validated destination case name; the quarantine tag derives from THIS, + * never from the (attacker-authored) manifest.caseName. */ + newCaseName: string; +}): Promise { + const { bundlePath, destWorkspace, engine, timestamp, newCaseName } = params; + const argv: string[] = [engine === 'podman' ? 'podman' : 'docker']; + + if (IS_TEST_MODE) { + const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}'); + const manifest = JSON.parse(raw) as DockerExportManifest; + validateImportManifest(manifest); + return { manifest, workspacePath: destWorkspace }; + } + + const stageDir = `${destWorkspace}.import-stage-${timestamp}`; + mkdirSync(stageDir, { recursive: true }); + try { + // Outer-bundle traversal guard (defense in depth: GNU/bsd tar already refuse + // `..`/absolute members by default, but the bundle is cross-machine input). + const { stdout: bundleMembers } = await run('tar', ['-tzf', bundlePath], { timeout: 60_000 }); + for (const member of bundleMembers.split('\n').filter(Boolean)) { + if (!isSafeTarMember(member)) throw new Error(`unsafe path in bundle archive: ${member}`); + } + await run('tar', ['--no-same-owner', '-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 }); + + const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8'); + const manifest = JSON.parse(manifestRaw) as DockerExportManifest; + validateImportManifest(manifest); + + // Integrity: verify checksums before trusting any member. + const workspaceTar = join(stageDir, 'workspace.tar'); + if (manifest.checksums.workspace) { + const actual = await sha256File(workspaceTar); + if (actual !== manifest.checksums.workspace) + throw new Error('workspace checksum mismatch (corrupt or tampered bundle)'); + } + + // Traversal guard: reject absolute / `..` members before extraction. + const { stdout: memberList } = await run('tar', ['-tf', workspaceTar], { timeout: 60_000 }); + for (const member of memberList.split('\n').filter(Boolean)) { + if (!isSafeTarMember(member)) throw new Error(`unsafe path in workspace archive: ${member}`); + } + mkdirSync(destWorkspace, { recursive: true }); + await run('tar', ['--no-same-owner', '-xf', workspaceTar, '-C', destWorkspace], { timeout: 300_000 }); + + let importedImage: string | undefined; + if (manifest.mode === 'full') { + const imageTar = join(stageDir, 'image.tar'); + if (manifest.checksums.image) { + const actual = await sha256File(imageTar); + if (actual !== manifest.checksums.image) + throw new Error('image checksum mismatch (corrupt or tampered bundle)'); + } + const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 }); + const loadedRef = parseLoadedImageRef(stdout); + if (!loadedRef) throw new Error('could not determine loaded image ref'); + // Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original + // tag; the tag name derives from the caller's schema-validated newCaseName. + importedImage = importedImageTag(newCaseName, timestamp); + await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 }); + } + + return { manifest, importedImage, workspacePath: destWorkspace }; + } finally { + await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {}); + } +} + +/** List export bundles in the exports dir (newest first), with size + mtime. */ +export async function listDockerExports( + exportsDir: string +): Promise> { + if (!existsSync(exportsDir)) return []; + const entries = await fs.readdir(exportsDir).catch(() => [] as string[]); + const out: Array<{ name: string; sizeBytes: number; mtimeMs: number }> = []; + for (const name of entries) { + if (!name.endsWith('.tgz')) continue; + try { + const stat = await fs.stat(join(exportsDir, name)); + out.push({ name: basename(name), sizeBytes: stat.size, mtimeMs: stat.mtimeMs }); + } catch { + /* skip */ + } + } + return out.sort((a, b) => b.mtimeMs - a.mtimeMs); +} diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts new file mode 100644 index 00000000..328757b4 --- /dev/null +++ b/src/docker-hosts.ts @@ -0,0 +1,1055 @@ +/** + * @fileoverview Docker cases: storage, pure command-arg builders, and daemon probes. + * + * Docker mode is a LOCATION OVERLAY on cases (not a 6th SessionMode), the direct + * analog of the remote-SSH feature in `remote-hosts.ts`. Instead of a local tmux + * pane running `ssh host` into a durable remote tmux server, a local tmux pane + * runs `docker exec -it` into a durable IN-CONTAINER tmux server. The container is + * scoped to the CASE (`codeman-case-`), so multiple sessions can `docker + * exec` into the same long-lived container. + * + * This module mirrors `remote-hosts.ts`: + * - JSON storage for hosts (`docker-hosts.json`) and cases (`docker-cases.json`) + * - `toSessionDocker()` (mirror of `toSessionRemote`) + * - `buildDockerBaseArgs()` / `buildDockerCreateArgs()` (mirror of `buildSshConnectionArgs`) + * - `checkDockerAvailable()` / `checkDockerTmuxAvailable()` (mirror of `checkRemoteTmuxAvailable`) + * + * The launch/kill command orchestration (`buildDockerLaunchCommand`, + * `buildDockerKillCommand`, `dockerTmuxSessionName`) lives in `tmux-manager.ts`, + * mirroring where `buildRemoteLaunchCommand` lives. + * + * @module docker-hosts + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; +import { createHash } from 'node:crypto'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { dataPath } from './config/instance.js'; +import type { + DockerCase, + DockerCommandMode, + DockerEngine, + DockerHost, + DockerNetworkMode, + DockerResourceLimits, + SessionDocker, + SessionMode, +} from './types.js'; + +const execFileAsync = promisify(execFile); + +/** Under vitest, all real `docker` invocations no-op (mirror of tmux-manager's IS_TEST_MODE). */ +const IS_TEST_MODE = !!process.env.VITEST; + +const DOCKER_HOSTS_FILE = 'docker-hosts.json'; +const DOCKER_CASES_FILE = 'docker-cases.json'; + +/** Locally-built base image (see scripts/build-agent-image.mjs). */ +export const DEFAULT_AGENT_IMAGE = 'codeman/agent:base'; + +/** HOME inside the base image (the `agent` user). Cred mounts + hook-secret land under it. */ +export const CONTAINER_HOME = '/home/agent'; + +/** Per-case container name prefix. The `case` letters deliberately do NOT matter to + * tmux; this is a DOCKER name (`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`), and case names are + * already validated `^[a-zA-Z0-9_-]+$`, so `codeman-case-` is always valid. */ +const CONTAINER_NAME_PREFIX = 'codeman-case-'; + +/** Sensible resource defaults (all overridable per host). */ +export const DEFAULT_DOCKER_RESOURCES: DockerResourceLimits = { + memory: '4g', + cpus: '2', + pidsLimit: 512, + nofile: '4096:8192', +}; + +// ========== Storage (mirror of remote-hosts.ts) ========== + +export function dockerHostsPath(configDir: string): string { + return join(configDir, DOCKER_HOSTS_FILE); +} + +export function dockerCasesPath(configDir: string): string { + return join(configDir, DOCKER_CASES_FILE); +} + +async function readJsonArray(path: string): Promise { + try { + const raw = await fs.readFile(path, 'utf-8'); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as T[]) : []; + } catch { + return []; + } +} + +async function writeJsonArray(configDir: string, path: string, value: T[]): Promise { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + await fs.writeFile(path, JSON.stringify(value, null, 2)); +} + +export async function readDockerHosts(configDir: string): Promise { + return readJsonArray(dockerHostsPath(configDir)); +} + +export async function writeDockerHosts(configDir: string, hosts: DockerHost[]): Promise { + await writeJsonArray(configDir, dockerHostsPath(configDir), hosts); +} + +export async function readDockerCases(configDir: string): Promise { + return readJsonArray(dockerCasesPath(configDir)); +} + +export async function writeDockerCases(configDir: string, cases: DockerCase[]): Promise { + await writeJsonArray(configDir, dockerCasesPath(configDir), cases); +} + +/** + * Persist the case's last Claude conversation id (the `--resume` seed for the + * container-recreated relaunch, docs/docker-cases-plan.md two-layer durability). + * Keyed by container name so callers that only hold a SessionDocker can update it. + * No-op when the id is unchanged or the case is gone. + */ +export async function persistDockerCaseClaudeSessionId( + configDir: string, + containerName: string, + claudeSessionId: string +): Promise { + const cases = await readDockerCases(configDir); + const idx = cases.findIndex((c) => (c.container ?? dockerContainerName(c.name)) === containerName); + if (idx === -1 || cases[idx].lastClaudeSessionId === claudeSessionId) return; + cases[idx] = { ...cases[idx], lastClaudeSessionId: claudeSessionId }; + await writeDockerCases(configDir, cases); +} + +// ========== Naming / display / defaults ========== + +/** Per-case container name. Mirrors how remote derives a stable name from the case. */ +export function dockerContainerName(caseName: string): string { + return `${CONTAINER_NAME_PREFIX}${caseName}`; +} + +/** Default pane command per CLI mode (mirror of defaultRemoteCommandForMode). */ +export function defaultDockerCommandForMode(mode: SessionMode): string { + const commands: Record = { + shell: 'exec bash -l', + // Mirror the LOCAL claude default so the in-container agent runs non-interactively. + claude: 'exec claude --dangerously-skip-permissions', + opencode: 'exec opencode', + codex: 'exec codex', + gemini: 'exec gemini', + }; + return commands[mode as DockerCommandMode] || commands.shell; +} + +/** `container:/workdir` display string (mirror of remoteDisplayPath's `user@host:path`). */ +export function dockerDisplayPath( + docker: Pick | { container: string; path: string } +): string { + if ('containerName' in docker) return `${docker.containerName}:${docker.containerWorkdir}`; + return `${docker.container}:${docker.path}`; +} + +/** + * The host-callback gateway alias is ENGINE-SPECIFIC: Docker exposes the host as + * `host.docker.internal`, Podman as `host.containers.internal`. Both are added to + * the host-guard allowlist so a mixed fleet keeps working. + */ +export function hostGatewayAlias(engine: DockerEngine): string { + return engine === 'podman' ? 'host.containers.internal' : 'host.docker.internal'; +} + +/** + * Rewrite the server's own `CODEMAN_API_URL` to a container-reachable one by + * swapping ONLY the hostname for the engine's host-gateway alias, preserving + * scheme AND port (prod is HTTPS on 3000, so hardcoding http://…:3000 breaks + * every hook). Falls back to `https://:3000` when the input is absent or + * unparseable. + */ +export function containerApiUrl(processApiUrl: string | undefined, engine: DockerEngine): string { + const alias = hostGatewayAlias(engine); + if (!processApiUrl) return `https://${alias}:3000`; + try { + const url = new URL(processApiUrl); + url.hostname = alias; + // origin drops any trailing path/slash and keeps scheme + (non-default) port + return url.origin; + } catch { + return `https://${alias}:3000`; + } +} + +/** + * Stable hash of the drift-relevant `docker create` inputs, stored on the + * container as the `codeman.confighash` label. On launch, a mismatch between the + * desired hash and the running container's label triggers the recreate-on-drift + * prompt (host config edits actually take effect). + */ +export function dockerConfigHash( + docker: Pick< + SessionDocker, + | 'engine' + | 'image' + | 'containerWorkdir' + | 'network' + | 'networkName' + | 'resources' + | 'gpus' + | 'mountCredentials' + | 'extraCreateArgs' + > +): string { + const normalized = JSON.stringify({ + engine: docker.engine, + image: docker.image, + containerWorkdir: docker.containerWorkdir, + network: docker.network, + networkName: docker.networkName ?? null, + resources: docker.resources ?? null, + gpus: docker.gpus ?? null, + mountCredentials: docker.mountCredentials, + extraCreateArgs: docker.extraCreateArgs ?? null, + }); + return createHash('sha256').update(normalized).digest('hex').slice(0, 12); +} + +/** + * Build the flattened per-session Docker metadata from a host profile + a case, + * resolving every default (mirror of toSessionRemote). The `configHash` is + * computed last over the resolved values. + */ +export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): SessionDocker { + const engine: DockerEngine = host.engine ?? 'docker'; + const containerWorkdir = dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath; + const base: Omit = { + hostId: host.id, + label: host.label, + engine, + image: host.image || DEFAULT_AGENT_IMAGE, + containerName: dockerCase.container ?? dockerContainerName(dockerCase.name), + hostWorkspacePath: dockerCase.hostWorkspacePath, + containerWorkdir, + network: host.network ?? 'bridge', + networkName: host.networkName, + resources: host.resources ?? DEFAULT_DOCKER_RESOURCES, + gpus: host.gpus, + mountCredentials: host.mountCredentials ?? true, + hooksEnabled: host.hooksEnabled ?? true, + resumeOnStart: host.resumeOnStart ?? true, + daemonHost: host.daemonHost, + context: host.context, + commands: host.commands, + extraCreateArgs: host.extraCreateArgs, + extraExecArgs: host.extraExecArgs, + }; + return { ...base, configHash: dockerConfigHash(base) }; +} + +// ========== Shell escaping ========== + +/** + * POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote). + * Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value + * interpolated into the outer `bash -c "..."` launch layer is escaped through + * this so a path with spaces stays a single shell token. Operator-entered fields + * are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth. + */ +export function shellescape(str: string): string { + return "'" + str.replace(/'/g, "'\\''") + "'"; +} + +// ========== Pure command-arg builders ========== + +/** A resolved bind mount (source existence already checked by the caller). */ +export interface DockerMount { + src: string; + dst: string; + readonly?: boolean; +} + +/** + * Resolved, IO-free context for buildDockerCreateArgs. The caller (tmux-manager) + * resolves the environment-dependent bits (host uid, existing cred mounts, the + * derived api url, Desktop detection) so this builder stays pure and unit-testable. + */ +export interface DockerCreateContext { + docker: SessionDocker; + /** Codeman session id (only the first 8 chars are used, for the codeman.session label). */ + sessionId: string; + /** CODEMAN_INSTANCE ('' for prod) — scopes the boot reaper so a beta never reaps prod. */ + instance: string; + /** Pre-resolved uid/userns tokens: ['--user','1000:0'] | ['--userns','keep-id'] | []. */ + userArgs: string[]; + /** Existing host credential bind mounts (convenient mode). Empty in sealed mode. */ + credentialMounts: DockerMount[]; + /** Extra bind mounts (e.g. the read-only hook-secret file). */ + extraMounts: DockerMount[]; + /** Create-time env (NON-secret, committed-safe): HOME, TERM, COLORTERM, CODEMAN_API_URL, CODEMAN_HOOK_SECRET_FILE. */ + envCreate: Record; + /** Whether to add `--add-host :host-gateway` (skipped on Docker Desktop, where the alias is native). */ + addHostGateway: boolean; + /** Engine host-gateway alias (host.docker.internal / host.containers.internal). */ + gatewayAlias: string; +} + +/** + * Engine prefix tokens shared by every docker invocation (mirror of + * buildSshConnectionArgs). Returns e.g. ['docker'] or ['podman','--context','ctx']. + */ +export function buildDockerBaseArgs(docker: Pick): string[] { + const parts: string[] = [docker.engine === 'podman' ? 'podman' : 'docker']; + if (docker.context) parts.push('--context', shellescape(docker.context)); + if (docker.daemonHost) parts.push('-H', shellescape(docker.daemonHost)); + return parts; +} + +function mountSpec(m: DockerMount): string { + return `type=bind,src=${m.src},dst=${m.dst}${m.readonly ? ',readonly' : ''}`; +} + +function resourceFlags(resources?: DockerResourceLimits): string[] { + if (!resources) return []; + const flags: string[] = []; + if (resources.memory) { + // memory-swap == memory disables swap, making --memory a REAL OOM cap. + flags.push('--memory', resources.memory, '--memory-swap', resources.memory); + } + if (resources.cpus) flags.push('--cpus', resources.cpus); + if (resources.pidsLimit) flags.push('--pids-limit', String(resources.pidsLimit)); + if (resources.nofile) flags.push('--ulimit', `nofile=${resources.nofile}`); + if (resources.shmSize) flags.push('--shm-size', resources.shmSize); + return flags; +} + +function networkArg(network: DockerNetworkMode, networkName?: string): string { + if (network === 'custom' && networkName) return networkName; + return network; // 'bridge' | 'none' +} + +/** + * Build the `docker create` token list (from `create` through the `sleep + * infinity` CMD) for a long-lived, hardened, per-case container. PURE: every + * dynamic value is shellescaped; the caller joins with spaces into the launch + * string. Security invariants baked in: --cap-drop ALL, --security-opt + * no-new-privileges, --pids-limit, --memory==--memory-swap, --init, + * --pull=never, --restart no, NEVER --privileged, NEVER the docker socket. + */ +export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] { + const { + docker, + sessionId, + instance, + userArgs, + credentialMounts, + extraMounts, + envCreate, + addHostGateway, + gatewayAlias, + } = ctx; + + const args: string[] = [ + 'create', + '--name', + shellescape(docker.containerName), + '--label', + 'codeman.managed=1', + '--label', + shellescape(`codeman.instance=${instance}`), + '--label', + shellescape(`codeman.session=${sessionId.slice(0, 8)}`), + '--label', + shellescape(`codeman.confighash=${docker.configHash ?? dockerConfigHash(docker)}`), + '--pull=never', + '--init', + '--restart', + 'no', + ...userArgs, + '--workdir', + shellescape(docker.containerWorkdir), + // Workspace bind: mirror the host path inside the container so the transcript + // projHash correlates and file features read real host bytes. + '--mount', + shellescape(mountSpec({ src: docker.hostWorkspacePath, dst: docker.containerWorkdir })), + ...credentialMounts.flatMap((m) => ['--mount', shellescape(mountSpec(m))]), + ...extraMounts.flatMap((m) => ['--mount', shellescape(mountSpec(m))]), + ]; + + if (addHostGateway) args.push('--add-host', `${gatewayAlias}:host-gateway`); + + args.push( + ...resourceFlags(docker.resources), + // GPU passthrough (needs the NVIDIA container toolkit on the host). No storage + // cap is set, so the container's writable layer + volumes grow elastically as + // data flows in (bounded only by host disk). + ...(docker.gpus ? ['--gpus', shellescape(docker.gpus)] : []), + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', + '--network', + networkArg(docker.network, docker.networkName) + ); + + for (const [key, value] of Object.entries(envCreate)) { + args.push('--env', shellescape(`${key}=${value}`)); + } + + // Operator escape-hatch args (schema-validated NO_SHELL_INJECTION), escaped again here. + for (const extra of docker.extraCreateArgs ?? []) { + args.push(shellescape(extra)); + } + + args.push(shellescape(docker.image), 'sleep', 'infinity'); + return args; +} + +/** + * PURE argv for building the agent base image locally (the programmatic mirror of + * scripts/build-agent-image.mjs): `build -f -t [--no-cache] + * `. Kept pure + unit-testable; the caller prepends the engine binary. + */ +export function agentImageBuildArgs(dockerfile: string, image: string, contextDir: string, noCache = false): string[] { + return ['build', '-f', dockerfile, '-t', image, ...(noCache ? ['--no-cache'] : []), contextDir]; +} + +// ========== Credential mount resolution (IO) ========== + +/** Container Claude config dir (created gid-0 writable in the image). */ +export const CONTAINER_CLAUDE_DIR = `${CONTAINER_HOME}/.claude`; +/** In-container path of the seeded (writable) `~/.claude.json`. */ +export const CLAUDE_JSON_HOME = `${CONTAINER_HOME}/.claude.json`; +/** In-container path of the read-only host-seeded `~/.claude.json` (copied into HOME at launch). */ +export const CLAUDE_JSON_SEED = `${CONTAINER_HOME}/.codeman/claude.seed.json`; +/** Read-only seed paths for the files copied into the container's `.claude`. */ +const CLAUDE_CREDS_SEED = `${CONTAINER_HOME}/.codeman/claude-creds.seed.json`; +const CLAUDE_SETTINGS_SEED = `${CONTAINER_HOME}/.codeman/claude-settings.seed.json`; +const CLAUDE_STATS_SEED = `${CONTAINER_HOME}/.codeman/claude-stats.seed.json`; +/** Staging root for read-only host-cred seed mounts (codex/gemini/gcloud/opencode). */ +const CRED_SEED_DIR = `${CONTAINER_HOME}/.codeman/cred-seeds`; + +/** + * PURE: merge the host `~/.claude.json` into a config that makes an + * already-authenticated Claude skip its INTERACTIVE onboarding inside the container + * (the host file itself lacks these flags — the host install is grandfathered, so a + * verbatim copy still triggers the theme picker + login wizard + folder-trust + * prompt). Forces `hasCompletedOnboarding`, a `theme` (so the theme picker is + * skipped), and marks the workspace project trusted + onboarded. Auth still comes + * from the copied `oauthAccount` + the dir-mounted `~/.claude/.credentials.json`. + */ +export function buildSeamlessClaudeConfig( + hostConfig: Record, + workspacePath: string, + theme = 'dark' +): Record { + const merged: Record = { ...hostConfig }; + merged.hasCompletedOnboarding = true; + if (typeof merged.theme !== 'string') merged.theme = theme; + const projects = { ...((merged.projects as Record> | undefined) ?? {}) }; + const existing = (projects[workspacePath] as Record | undefined) ?? {}; + const seenCount = existing.projectOnboardingSeenCount; + projects[workspacePath] = { + ...existing, + hasTrustDialogAccepted: true, + hasCompletedProjectOnboarding: true, + projectOnboardingSeenCount: typeof seenCount === 'number' && seenCount > 0 ? seenCount : 1, + }; + merged.projects = projects; + return merged; +} + +/** Best-effort read of the host `~/.claude/settings.json` theme (drives the seed's theme). */ +function readHostClaudeTheme(home: string): string | undefined { + try { + const parsed = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf-8')) as { theme?: unknown }; + return typeof parsed.theme === 'string' ? parsed.theme : undefined; + } catch { + return undefined; + } +} + +/** + * Resolve the read-only seed mount for `~/.claude.json`. Reads the host file, merges + * in the seamless-onboarding flags + workspace trust (buildSeamlessClaudeConfig), + * writes the result to a per-container seed file under `~/.codeman/docker-seeds/`, + * and returns its mount. The launch chain copies it to `~/.claude.json` inside HOME + * once — giving Claude a NORMAL writable, already-onboarded config (no atomic-rename + * EBUSY, no re-auth, no theme/trust prompts). Falls back to the RAW host file when + * parse/write fails (auth still works; the wizard may show). Returns null when the + * host has no `~/.claude.json`. IO; under VITEST returns the raw mount (no write). + */ +export function resolveClaudeJsonSeedMount( + home: string = homedir(), + containerName?: string, + workspacePath?: string +): DockerMount | null { + const src = join(home, '.claude.json'); + if (!existsSync(src)) return null; + const rawMount: DockerMount = { src, dst: CLAUDE_JSON_SEED, readonly: true }; + if (IS_TEST_MODE || !containerName || !workspacePath) return rawMount; + try { + const hostConfig = JSON.parse(readFileSync(src, 'utf-8')) as Record; + const merged = buildSeamlessClaudeConfig(hostConfig, workspacePath, readHostClaudeTheme(home) ?? 'dark'); + const seedsDir = dataPath('docker-seeds'); + if (!existsSync(seedsDir)) mkdirSync(seedsDir, { recursive: true }); + const seedFile = join(seedsDir, `${containerName}.json`); + writeFileSync(seedFile, JSON.stringify(merged), { mode: 0o600 }); + return { src: seedFile, dst: CLAUDE_JSON_SEED, readonly: true }; + } catch { + return rawMount; // partial host write / unreadable — auth still carries, wizard may show + } +} + +/** A file (or dir, when `recursive`) copied into the container HOME once at launch + * (`[ -e to ] || cp [-a] from to`). */ +export interface DockerSeedCopy { + from: string; + to: string; + /** `cp -a` for whole-directory credential seeds (gemini/gcloud/opencode). */ + recursive?: boolean; +} + +export interface DockerClaudeArtifacts { + /** Bind mounts to add: the shared `projects/` transcripts (RW) + read-only seed files. */ + mounts: DockerMount[]; + /** Files copied into the container's writable HOME/.claude (+ HOME/.claude.json) at launch. */ + seedCopies: DockerSeedCopy[]; +} + +/** + * Resolve the ISOLATED Claude artifacts for a docker session (replaces the old + * whole-`~/.claude` RW mount that polluted the host). Shares ONLY what must cross + * the boundary and seeds the rest as writable copies: + * - `~/.claude/projects` → RW dir mount (transcripts: host watchers + `--resume`). + * - `~/.claude.json` → merged onboarding seed, copied to HOME (no re-auth/wizard). + * - `~/.claude/.credentials.json` + `~/.claude/settings.json` → read-only seeds + * copied into the container's own `~/.claude` (token + global prefs carry in; + * the container refreshes its own copy and never writes back to the host). + * Everything else Claude writes (backups, tasks, teams, session-env, history) stays + * container-local. IO (reads host files, writes the merged `.claude.json` seed). + */ +export function resolveDockerClaudeArtifacts( + home: string, + containerName: string, + workspacePath: string +): DockerClaudeArtifacts { + const mounts: DockerMount[] = []; + const seedCopies: DockerSeedCopy[] = []; + + // The ONE genuinely-shared part: conversation transcripts (dir mount → renames work). + const projectsSrc = join(home, '.claude', 'projects'); + if (existsSync(projectsSrc)) { + mounts.push({ src: projectsSrc, dst: `${CONTAINER_CLAUDE_DIR}/projects` }); + } + + // ~/.claude.json → merged, onboarding-complete seed at HOME root. + const jsonSeed = resolveClaudeJsonSeedMount(home, containerName, workspacePath); + if (jsonSeed) { + mounts.push(jsonSeed); + seedCopies.push({ from: CLAUDE_JSON_SEED, to: CLAUDE_JSON_HOME }); + } + + // credentials (token) + settings (theme/model/effort/permissions) + stats-cache + // (drives the model/effort status indicator) → writable copies inside the + // container's own ~/.claude (never a wholesale mount → no host pollution). + const files: Array<[rel: string, seed: string, dest: string]> = [ + ['.credentials.json', CLAUDE_CREDS_SEED, `${CONTAINER_CLAUDE_DIR}/.credentials.json`], + ['settings.json', CLAUDE_SETTINGS_SEED, `${CONTAINER_CLAUDE_DIR}/settings.json`], + ['stats-cache.json', CLAUDE_STATS_SEED, `${CONTAINER_CLAUDE_DIR}/stats-cache.json`], + ]; + for (const [rel, seed, dest] of files) { + const src = join(home, '.claude', rel); + if (existsSync(src)) { + mounts.push({ src, dst: seed, readonly: true }); + seedCopies.push({ from: seed, to: dest }); + } + } + + return { mounts, seedCopies }; +} + +/** + * Per-CLI credential-store isolation policy (the codex/gemini/gcloud/opencode analog + * of resolveDockerClaudeArtifacts). Codex is the direct Claude-analog: its + * `sessions/` rollouts + `history.jsonl` are read HOST-SIDE (response-viewer + + * `codex resume`), so they are SHARED (RW), while `auth.json`/`config.toml` are + * seeded. The other three have no host-read/resume dependency and are fully + * seed-copied (writable copy in the container, no write-back to the host). + */ +interface CredStorePolicy { + /** Path relative to HOME (host + container), e.g. '.codex' or '.config/gcloud'. */ + rel: string; + /** Subdirs bind-mounted RW (shared: resume + host reads). */ + shareDirs?: string[]; + /** Files bind-mounted RW (append-only, e.g. codex history.jsonl — never renamed). */ + shareFiles?: string[]; + /** Files seeded (RO mount → cp) into the container's own copy. */ + seedFiles?: string[]; + /** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */ + seedWhole?: boolean; +} + +const CRED_STORES: CredStorePolicy[] = [ + { rel: '.codex', shareDirs: ['sessions'], shareFiles: ['history.jsonl'], seedFiles: ['auth.json', 'config.toml'] }, + { rel: '.gemini', seedWhole: true }, + { rel: '.config/gcloud', seedWhole: true }, + { rel: '.config/opencode', seedWhole: true }, +]; + +/** + * Resolve the ISOLATED codex/gemini/gcloud/opencode artifacts (replaces the old + * whole-dir RW mounts that let each in-container CLI write its refreshed tokens + + * session state back into the host). Every path is existsSync-gated (on most hosts + * only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs). + */ +export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts { + const mounts: DockerMount[] = []; + const seedCopies: DockerSeedCopy[] = []; + for (const store of CRED_STORES) { + const hostBase = join(home, store.rel); + if (!existsSync(hostBase)) continue; + const containerBase = `${CONTAINER_HOME}/${store.rel}`; + const seedName = store.rel.replace(/\//g, '-'); // '.config/gcloud' → '.config-gcloud' + if (store.seedWhole) { + const seed = `${CRED_SEED_DIR}/${seedName}`; + mounts.push({ src: hostBase, dst: seed, readonly: true }); + seedCopies.push({ from: seed, to: containerBase, recursive: true }); + continue; + } + for (const sub of store.shareDirs ?? []) { + const src = join(hostBase, sub); + if (existsSync(src)) mounts.push({ src, dst: `${containerBase}/${sub}` }); + } + for (const file of store.shareFiles ?? []) { + const src = join(hostBase, file); + if (existsSync(src)) mounts.push({ src, dst: `${containerBase}/${file}` }); + } + for (const file of store.seedFiles ?? []) { + const src = join(hostBase, file); + if (existsSync(src)) { + const seed = `${CRED_SEED_DIR}/${seedName}-${file}`; + mounts.push({ src, dst: seed, readonly: true }); + seedCopies.push({ from: seed, to: `${containerBase}/${file}` }); + } + } + } + return { mounts, seedCopies }; +} + +// ========== Daemon probes (IO; no-op under VITEST) ========== + +/** + * UNESCAPED argv prefix for execFile-based probes. The shellescaped + * buildDockerBaseArgs variant is for interpolation into the `bash -c` launch + * string; argv arrays must NOT carry literal quotes (mirror of docker-export's + * dockerArgv). + */ +function dockerEngineArgv(docker: Pick): string[] { + const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker']; + if (docker.context) argv.push('--context', docker.context); + if (docker.daemonHost) argv.push('-H', docker.daemonHost); + return argv; +} + +export interface DockerDriftStatus { + /** Container exists (daemon reachable AND a container with this name is present). */ + exists: boolean; + running: boolean; + /** The desired configHash no longer matches the container's codeman.confighash label. */ + drifted: boolean; + currentHash?: string; +} + +/** + * Drift check (docs/docker-cases-plan.md §4): compare the DESIRED configHash + * against the existing container's `codeman.confighash` label so docker-host + * config edits actually take effect instead of being silently ignored by the + * idempotent inspect-or-create launch chain. `exists:false` (no container / + * daemon down) means there is nothing to drift. No-op under VITEST. + */ +export async function checkDockerConfigDrift( + docker: Pick +): Promise { + if (IS_TEST_MODE) return { exists: false, running: false, drifted: false }; + const argv = dockerEngineArgv(docker); + try { + const { stdout } = await execFileAsync( + argv[0], + [ + ...argv.slice(1), + 'inspect', + '-f', + '{{.State.Running}}\t{{index .Config.Labels "codeman.confighash"}}', + docker.containerName, + ], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + const [running = '', hash = ''] = stdout.trim().split('\t'); + return { exists: true, running: running === 'true', drifted: hash !== docker.configHash, currentHash: hash }; + } catch { + return { exists: false, running: false, drifted: false }; + } +} + +/** + * `docker rm -f` the case container (the recreate-on-drift confirm action; the + * launch chain recreates it with the new config on next start). Workspace + + * transcripts ride bind mounts and survive; the conversation resumes via the + * case's lastClaudeSessionId. No-op under VITEST. + */ +export async function removeDockerContainer( + docker: Pick +): Promise { + if (IS_TEST_MODE) return; + const argv = dockerEngineArgv(docker); + await execFileAsync(argv[0], [...argv.slice(1), 'rm', '-f', docker.containerName], { timeout: 30_000 }); +} + +export interface DockerAvailability { + ok: boolean; + engine: DockerEngine; + rootless: boolean; + isDesktop: boolean; + cgroupV2: boolean; + /** Best-effort: are --memory/--cpus/--pids-limit actually enforced on this engine? */ + capsEnforced: boolean; + error?: string; +} + +const DOCKER_PROBE_TIMEOUT_MS = 15_000; + +interface DockerInfoJson { + ServerVersion?: string; + CgroupVersion?: string; + SecurityOptions?: string[]; + OperatingSystem?: string; + OSType?: string; + Name?: string; +} + +async function runDockerInfo(engine: DockerEngine): Promise { + try { + const { stdout } = await execFileAsync(engine, ['info', '--format', '{{json .}}'], { + timeout: DOCKER_PROBE_TIMEOUT_MS, + }); + return JSON.parse(stdout) as DockerInfoJson; + } catch { + return null; + } +} + +function classifyDockerInfo(engine: DockerEngine, info: DockerInfoJson): DockerAvailability { + const security = info.SecurityOptions ?? []; + const rootless = security.some((opt) => opt.includes('rootless')); + const cgroupV2 = info.CgroupVersion === '2'; + const os = `${info.OperatingSystem ?? ''}`.toLowerCase(); + const isDesktop = os.includes('docker desktop') || os.includes('desktop'); + // Under rootless, resource caps are only reliably enforced with cgroup v2 + + // systemd delegation. We can't detect delegation from `docker info`, so we + // treat rootless+cgroupv2 as "likely enforced" and rootless+cgroupv1 as not. + const capsEnforced = !rootless || cgroupV2; + return { ok: true, engine, rootless, isDesktop, cgroupV2, capsEnforced }; +} + +/** + * Probe the container engine: server up, cgroup version, rootless, Desktop, and + * whether resource caps are enforceable. Auto-detects docker then podman when no + * engine is given. No-op canned value under VITEST. + */ +export async function checkDockerAvailable(engine?: DockerEngine): Promise { + if (IS_TEST_MODE) { + return { + ok: true, + engine: engine ?? 'docker', + rootless: false, + isDesktop: false, + cgroupV2: true, + capsEnforced: true, + }; + } + const candidates: DockerEngine[] = engine ? [engine] : ['docker', 'podman']; + for (const candidate of candidates) { + const info = await runDockerInfo(candidate); + if (info) return classifyDockerInfo(candidate, info); + } + return { + ok: false, + engine: engine ?? 'docker', + rootless: false, + isDesktop: false, + cgroupV2: false, + capsEnforced: false, + error: 'Docker/Podman not available. Install docker (or podman) and ensure the daemon is running.', + }; +} + +/** Is the base image present on the host's daemon? (never triggers an auto-pull). + * Honors context/daemonHost so a remote-daemon host is probed on the RIGHT daemon. */ +export async function checkDockerImagePresent( + docker: Pick, + image: string +): Promise { + if (IS_TEST_MODE) return true; + const argv = dockerEngineArgv(docker); + try { + await execFileAsync(argv[0], [...argv.slice(1), 'image', 'inspect', '--format', '{{.Id}}', image], { + timeout: DOCKER_PROBE_TIMEOUT_MS, + }); + return true; + } catch { + return false; + } +} + +export interface EnsureImageResult { + ok: boolean; + /** true when this call actually ran a build (vs. the image already existing). */ + built: boolean; + alreadyPresent: boolean; + error?: string; +} + +/** In-flight builds keyed by `engine:image`, so concurrent callers share ONE build. */ +const inFlightImageBuilds = new Map>(); + +/** + * Resolve the repo's Dockerfile + build context. Works from BOTH src (dev/tsx) and + * dist/index.js (esbuild prod: dist sits at repo root), since both are one level + * under the repo root. Returns null when the Dockerfile is absent (npm-global + * installs don't ship docker/ — Docker cases are a git-clone feature). + */ +function resolveAgentDockerfile(): { dockerfile: string; contextDir: string } | null { + const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + const dockerfile = join(repoRoot, 'docker', 'agent.Dockerfile'); + return existsSync(dockerfile) ? { dockerfile, contextDir: repoRoot } : null; +} + +/** + * Ensure the agent base image exists, BUILDING it locally on first use so a missing + * image is never a hard blocker (decision: "build locally on first use", + * docs/docker-cases-plan.md). Idempotent, concurrency-safe (one build per + * engine:image shared by concurrent callers), and a no-op under VITEST. Only the + * DEFAULT image is auto-built — we can never build a user's custom ref, and the + * `--pull=never` invariant forbids pulling. `onProgress` receives build output + * lines for SSE surfacing. + */ +export async function ensureAgentBaseImage( + docker: Pick, + image: string, + opts: { onProgress?: (line: string) => void; noCache?: boolean } = {} +): Promise { + if (IS_TEST_MODE) return { ok: true, built: false, alreadyPresent: true }; + if (await checkDockerImagePresent(docker, image)) { + return { ok: true, built: false, alreadyPresent: true }; + } + if (image !== DEFAULT_AGENT_IMAGE) { + return { + ok: false, + built: false, + alreadyPresent: false, + error: `image ${image} is not present and only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull ${image} yourself.`, + }; + } + const key = `${docker.engine}:${image}`; + const existing = inFlightImageBuilds.get(key); + if (existing) return existing; + const build = buildAgentImage(docker, image, opts).finally(() => inFlightImageBuilds.delete(key)); + inFlightImageBuilds.set(key, build); + return build; +} + +function buildAgentImage( + docker: Pick, + image: string, + opts: { onProgress?: (line: string) => void; noCache?: boolean } +): Promise { + const resolved = resolveAgentDockerfile(); + if (!resolved) { + return Promise.resolve({ + ok: false, + built: false, + alreadyPresent: false, + error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`, + }); + } + const argv = dockerEngineArgv(docker); + const args = [ + ...argv.slice(1), + ...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache), + ]; + return new Promise((resolve) => { + // async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop. + const child = spawn(argv[0], args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const forward = (buf: Buffer) => { + for (const line of buf.toString('utf-8').split('\n')) { + const trimmed = line.trimEnd(); + if (trimmed) opts.onProgress?.(trimmed); + } + }; + child.stdout?.on('data', forward); + child.stderr?.on('data', forward); + child.on('error', (err) => { + resolve({ + ok: false, + built: false, + alreadyPresent: false, + error: `could not spawn ${argv[0]} build: ${err.message}`, + }); + }); + child.on('exit', (code) => { + if (code === 0) resolve({ ok: true, built: true, alreadyPresent: false }); + else resolve({ ok: false, built: false, alreadyPresent: false, error: `${argv[0]} build failed (exit ${code})` }); + }); + }); +} + +export interface DockerTmuxCheckResult { + ok: boolean; + tmuxPath?: string; + /** Distinguishes "image missing" (build it) from "tmux missing in image" (rebuild it). */ + imageMissing?: boolean; + error?: string; +} + +/** + * Verify the base image is present AND contains tmux (a HARD prerequisite: the + * in-container tmux is what makes reconnect durable). Never triggers a pull + * (`--pull=never`). No-op under VITEST. Mirror of checkRemoteTmuxAvailable. + */ +export async function checkDockerTmuxAvailable( + docker: Pick +): Promise { + if (IS_TEST_MODE) return { ok: true, tmuxPath: '/usr/bin/tmux' }; + if (!(await checkDockerImagePresent(docker, docker.image))) { + return { + ok: false, + imageMissing: true, + error: `image ${docker.image} not present (the default image is auto-built on first use; a custom image must be built or pulled first)`, + }; + } + const argv = dockerEngineArgv(docker); + try { + const { stdout } = await execFileAsync( + argv[0], + [...argv.slice(1), 'run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + const tmuxPath = stdout.trim(); + if (!tmuxPath) { + return { ok: false, error: `base image ${docker.image} is missing tmux (required for durable sessions)` }; + } + return { ok: true, tmuxPath }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, error: `could not verify tmux in ${docker.image}: ${msg}` }; + } +} + +/** + * Resolve the host's IP on the default docker bridge (the address a container + * reaches as `host.docker.internal`), so the server can bind a hooks-only listener + * there and in-container hooks can call back. Defaults to the conventional + * 172.17.0.1 when the inspect fails but docker is up; null when docker is absent. + * No-op canned value under VITEST. + */ +export async function detectDockerBridgeGateway(engine: DockerEngine = 'docker'): Promise { + if (IS_TEST_MODE) return '172.17.0.1'; + const bin = engine === 'podman' ? 'podman' : 'docker'; + try { + const { stdout } = await execFileAsync( + bin, + ['network', 'inspect', 'bridge', '--format', '{{(index .IPAM.Config 0).Gateway}}'], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + const ip = stdout.trim(); + return /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) ? ip : '172.17.0.1'; + } catch { + return null; // docker not available — nothing to bind + } +} + +/** + * Instance-scoped boot reaper: `docker rm -f` any MANAGED container that belongs + * to THIS instance (by the `codeman.instance` label) but whose case is no longer + * in `docker-cases.json`. The instance scoping is what stops a beta from reaping + * prod's containers (the cross-instance hazard). No-op under VITEST. Best-effort. + */ +export async function reapOrphanedDockerContainers( + configDir: string, + instance: string, + engine: DockerEngine = 'docker' +): Promise { + if (IS_TEST_MODE) return []; + const bin = engine === 'podman' ? 'podman' : 'docker'; + let rows: Array<{ name: string; inst: string }> = []; + try { + const { stdout } = await execFileAsync( + bin, + [ + 'ps', + '-a', + '--filter', + 'label=codeman.managed=1', + '--format', + '{{.Names}}\t{{index .Labels "codeman.instance"}}', + ], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + rows = stdout + .split('\n') + .filter(Boolean) + .map((line) => { + const [name, inst = ''] = line.split('\t'); + return { name, inst }; + }); + } catch { + return []; // daemon down / engine absent — nothing to reap + } + const cases = await readDockerCases(configDir); + const expected = new Set(cases.map((c) => c.container ?? dockerContainerName(c.name))); + const reaped: string[] = []; + for (const { name, inst } of rows) { + if (inst !== instance) continue; // only THIS instance's containers + if (expected.has(name)) continue; // still referenced by a live case + try { + await execFileAsync(bin, ['rm', '-f', name], { timeout: DOCKER_PROBE_TIMEOUT_MS }); + reaped.push(name); + } catch { + /* best-effort */ + } + } + return reaped; +} + +/** + * Read the IN-CONTAINER Claude CLI version (`docker exec claude + * --version`). Feeds Session.cliVersion for docker sessions (the LOCAL claude + * would report the wrong version and disable trackpad wheel-forwarding, #154). + * Returns undefined on any failure. No-op under VITEST. + */ +export async function probeDockerCliVersion( + docker: Pick, + mode: SessionMode +): Promise { + if (IS_TEST_MODE) return undefined; + const bin = mode === 'shell' ? null : mode; + if (!bin) return undefined; + const argv = dockerEngineArgv(docker); + try { + const { stdout } = await execFileAsync( + argv[0], + [...argv.slice(1), 'exec', docker.containerName, bin, '--version'], + { + timeout: DOCKER_PROBE_TIMEOUT_MS, + } + ); + const match = stdout.trim().match(/\d+\.\d+\.\d+/); + return match ? match[0] : stdout.trim() || undefined; + } catch { + return undefined; + } +} diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 74982250..d6e58d4e 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -18,6 +18,7 @@ import type { EffortLevel, GeminiConfig, SessionRemote, + SessionDocker, } from './types.js'; /** @@ -36,6 +37,10 @@ export interface MuxSession { workingDir: string; /** Remote execution metadata for local tmux sessions wrapping SSH */ remote?: SessionRemote; + /** Docker execution metadata for local tmux sessions wrapping `docker exec` */ + docker?: SessionDocker; + /** Owning username in multi-user mode (round-tripped through recovery like remote/docker) */ + owner?: string; /** Session mode */ mode: SessionMode; /** Whether webserver is attached to this session */ @@ -79,6 +84,10 @@ export interface CreateSessionOptions { historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ remote?: SessionRemote; + /** Docker execution metadata for local tmux sessions wrapping `docker exec` */ + docker?: SessionDocker; + /** Owning username in multi-user mode; persisted for recovery. */ + owner?: string; } /** Options for respawning a dead pane. */ @@ -103,6 +112,10 @@ export interface RespawnPaneOptions { historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ remote?: SessionRemote; + /** Docker execution metadata for local tmux sessions wrapping `docker exec` */ + docker?: SessionDocker; + /** Owning username (multi-user); redundant on respawn since the Session object survives, kept for shape parity. */ + owner?: string; } /** Options for pane buffer capture (COD-47 full-history mode). */ diff --git a/src/plan-orchestrator.ts b/src/plan-orchestrator.ts index 5edddb1e..4a5f7db5 100644 --- a/src/plan-orchestrator.ts +++ b/src/plan-orchestrator.ts @@ -20,7 +20,7 @@ import type { TerminalMultiplexer } from './mux-interface.js'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js'; -import { getErrorMessage, type PlanItem } from './types.js'; +import { getErrorMessage, type PlanItem, type ClaudeMode } from './types.js'; // Re-export for backward compatibility export type { PlanItem }; @@ -130,18 +130,28 @@ export class PlanOrchestrator { private taskDescription = ''; private researchModel: string; private plannerModel: string; + // Multi-user permission threading: the resolved claudeMode/owner/allowedTools for the + // internal research/planner one-shots. Left undefined = today's single-user behavior + // (the caller threads the resolved global mode, byte-identical when !isMultiUserMode()). + private claudeMode?: ClaudeMode; + private owner?: string; + private allowedTools?: string; constructor( mux: TerminalMultiplexer, workingDir: string = process.cwd(), outputDir?: string, - modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record } + modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record }, + security?: { claudeMode?: ClaudeMode; owner?: string; allowedTools?: string } ) { this.mux = mux; this.workingDir = workingDir; this.outputDir = outputDir; this.researchModel = modelConfig?.agentTypeOverrides?.explore || modelConfig?.defaultModel || DEFAULT_MODEL; this.plannerModel = modelConfig?.agentTypeOverrides?.review || modelConfig?.defaultModel || DEFAULT_MODEL; + this.claudeMode = security?.claudeMode; + this.owner = security?.owner; + this.allowedTools = security?.allowedTools; } private saveAgentOutput(agentType: string, prompt: string, result: unknown, durationMs: number): void { @@ -424,6 +434,12 @@ export class PlanOrchestrator { mux: this.mux, useMux: false, mode: 'claude', + // Section 6.3: run this one-shot under the caller-resolved permission mode/owner so a + // non-granted multi-user user cannot regain --dangerously-skip-permissions. Undefined + // (single-user, not threaded) is byte-identical to today (Session keeps its default). + claudeMode: this.claudeMode, + allowedTools: this.allowedTools, + owner: this.owner, }); this.runningSessions.add(session); @@ -580,6 +596,10 @@ export class PlanOrchestrator { mux: this.mux, useMux: false, mode: 'claude', + // Section 6.3: same permission-mode/owner threading as the research one-shot above. + claudeMode: this.claudeMode, + allowedTools: this.allowedTools, + owner: this.owner, }); this.runningSessions.add(session); diff --git a/src/push-store.ts b/src/push-store.ts index 5cee424f..059cf612 100644 --- a/src/push-store.ts +++ b/src/push-store.ts @@ -9,10 +9,23 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import webpush from 'web-push'; -import type { VapidKeys, PushSubscriptionRecord } from './types.js'; +import type { VapidKeys, PushSubscriptionRecord, UserRole } from './types.js'; import { Debouncer } from './utils/index.js'; import { getDataDir } from './config/instance.js'; +/** + * A push subscription plus the multi-user owner identity stamped at subscribe time. + * `username`/`role` are undefined in single-user mode (and for legacy records saved + * before this field existed). sendPushNotifications uses them to scope a + * session-notification to its owner's devices (+ admins) instead of fanning out to + * every user. Kept as a store-local widening of PushSubscriptionRecord so the shared + * type stays untouched; the extra keys serialize/persist transparently. + */ +export type OwnedPushSubscriptionRecord = PushSubscriptionRecord & { + username?: string; + role?: UserRole; +}; + const DATA_DIR = getDataDir(); const KEYS_FILE = join(DATA_DIR, 'push-keys.json'); const SUBS_FILE = join(DATA_DIR, 'push-subscriptions.json'); @@ -20,7 +33,7 @@ const SAVE_DEBOUNCE_MS = 500; export class PushSubscriptionStore { private vapidKeys: VapidKeys | null = null; - private subscriptions: Map = new Map(); + private subscriptions: Map = new Map(); private saveDeb = new Debouncer(SAVE_DEBOUNCE_MS); private _disposed = false; @@ -67,17 +80,19 @@ export class PushSubscriptionStore { } /** Register or update a push subscription (deduplicates by endpoint) */ - addSubscription(sub: Omit): PushSubscriptionRecord { + addSubscription(sub: Omit): OwnedPushSubscriptionRecord { // Check for existing subscription with same endpoint for (const [existingId, existing] of this.subscriptions) { if (existing.endpoint === sub.endpoint) { - // Update existing - const updated: PushSubscriptionRecord = { + // Update existing (re-stamp owner identity so it tracks the current caller) + const updated: OwnedPushSubscriptionRecord = { ...existing, keys: sub.keys, userAgent: sub.userAgent, lastUsedAt: Date.now(), pushPreferences: sub.pushPreferences, + username: sub.username, + role: sub.role, }; this.subscriptions.set(existingId, updated); this.scheduleSave(); @@ -86,7 +101,7 @@ export class PushSubscriptionStore { } // New subscription - const record: PushSubscriptionRecord = { + const record: OwnedPushSubscriptionRecord = { ...sub, lastUsedAt: Date.now(), }; @@ -96,7 +111,7 @@ export class PushSubscriptionStore { } /** Update push preferences for a subscription */ - updatePreferences(id: string, preferences: Record): PushSubscriptionRecord | null { + updatePreferences(id: string, preferences: Record): OwnedPushSubscriptionRecord | null { const sub = this.subscriptions.get(id); if (!sub) return null; sub.pushPreferences = preferences; @@ -124,12 +139,12 @@ export class PushSubscriptionStore { } /** Get all subscriptions */ - getAll(): PushSubscriptionRecord[] { + getAll(): OwnedPushSubscriptionRecord[] { return Array.from(this.subscriptions.values()); } /** Get a single subscription by ID */ - get(id: string): PushSubscriptionRecord | null { + get(id: string): OwnedPushSubscriptionRecord | null { return this.subscriptions.get(id) ?? null; } @@ -138,7 +153,7 @@ export class PushSubscriptionStore { if (!existsSync(SUBS_FILE)) return; try { const raw = readFileSync(SUBS_FILE, 'utf-8'); - const arr = JSON.parse(raw) as PushSubscriptionRecord[]; + const arr = JSON.parse(raw) as OwnedPushSubscriptionRecord[]; for (const sub of arr) { this.subscriptions.set(sub.id, sub); } diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index dbf82cff..571924a3 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -8,6 +8,7 @@ import type { RemoteCase, RemoteCommandMode, RemoteHost, + RemoteSessionInfo, RemoteSshOptions, SessionMode, SessionRemote, @@ -173,6 +174,14 @@ export interface RemoteTmuxCheckResult { export async function checkRemoteTmuxAvailable( host: Pick & RemoteSshOptions ): Promise { + // Under vitest, never open a real ssh connection — mirrors TmuxManager's + // no-op-shell-under-VITEST (IS_TEST_MODE). Without this, remote-case + // create-path tests hit a real ~10s ssh timeout. The command construction is + // covered by buildRemoteTmuxCheckCommand unit tests; only the live probe is + // short-circuited here. + if (process.env.VITEST) { + return { ok: true, tmuxPath: '(test-mode)' }; + } const command = buildRemoteTmuxCheckCommand(host); try { const { stdout } = await execAsync(command, { timeout: 15_000 }); @@ -202,6 +211,109 @@ export async function checkRemoteTmuxAvailable( } } +/** + * COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a + * remote host's canonical `-L codeman` socket. + * + * `list-sessions` exits NON-ZERO with empty output when no sessions exist (and + * the server isn't running), so `2>/dev/null` swallows tmux's "no server + * running" stderr; the caller treats a non-zero exit / empty output as "no + * sessions" rather than an error. + * + * COD-107 — connection options come from the shared `buildSshConnectionArgs`, so + * discovery connects with the SAME port/identity/proxy/jump-host as the launch + * and the tmux prereq probe. + */ +export function buildRemoteListSessionsCommand( + host: Pick & RemoteSshOptions +): string { + const [ssh, ...connectionArgs] = buildSshConnectionArgs(host); + const parts = [ssh, connectionArgs[0], '-o ConnectTimeout=10', ...connectionArgs.slice(1)]; + // The tmux list-sessions invocation is passed as ONE shell-quoted argument so + // the remote login shell runs it verbatim. The `-F` format uses literal `\t` + // separators (tmux expands them); `2>/dev/null` is inside the quoted command. + const remoteCmd = + 'tmux -L codeman list-sessions -F "#{session_name}\\t#{session_attached}\\t#{session_created}\\t#{session_windows}" 2>/dev/null'; + parts.push(remoteSshTarget(host), shellescape(remoteCmd)); + return parts.join(' '); +} + +/** + * COD-105 — pure parser for the `tmux list-sessions -F` output emitted by + * `buildRemoteListSessionsCommand`. Factored out so the parse is unit-testable + * without opening a real ssh connection. + * + * - Splits each non-empty line into [name, attached, created, windows] on the + * field separator. IMPORTANT: the remote tmux's `-F "…\t…"` format does NOT + * expand `\t` to a real tab — it emits the LITERAL two-character sequence + * `\t` (verified on aa-desktop / tmux next-3.7). So we split on the literal + * backslash-t sequence; we also tolerate a real tab in case a tmux build + * does expand it. (A real TAB is the regex `\t`; a literal backslash-t is the + * regex `\\t`.) + * - Keeps ONLY sessions whose name starts with `codeman-` (ignores foreign tmux + * sessions that happen to share the socket). + * - Coerces: `attached` → boolean (`'1'`), `created`/`windows` → finite ints. + * - Skips malformed lines (wrong column count or non-numeric created/windows) + * rather than emitting garbage. + */ +export function parseRemoteSessionList(stdout: string): RemoteSessionInfo[] { + const out: RemoteSessionInfo[] = []; + for (const rawLine of stdout.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + // Split on a literal `\t` (backslash + t, what the remote tmux emits) OR a + // real tab character. `/\\t|\t/` = the two-char sequence, or a TAB. + const cols = line.split(/\\t|\t/); + if (cols.length !== 4) continue; + const [name, attachedStr, createdStr, windowsStr] = cols; + if (!name.startsWith('codeman-')) continue; + const created = Number(createdStr); + const windows = Number(windowsStr); + if (!Number.isFinite(created) || !Number.isFinite(windows)) continue; + // COD-106 — `session_attached` is the CLIENT COUNT (not a 0/1 flag); >1 = shared. + const attachedNum = Number(attachedStr.trim()); + const attachedClients = Number.isFinite(attachedNum) ? Math.max(0, Math.trunc(attachedNum)) : 0; + out.push({ + name, + attached: attachedClients > 0, + attachedClients, + created: Math.trunc(created), + windows: Math.trunc(windows), + }); + } + return out; +} + +/** + * COD-105 — discover `codeman-*` tmux sessions already running on a remote host + * (created by the remote's own Codeman, another instance, or this one), so the + * operator can attach to one this Codeman didn't launch. + * + * NEVER throws: returns `[]` on unreachable host / no tmux / no sessions + * (`list-sessions` exits non-zero with empty output when there are none). + * + * VITEST guard — like `checkRemoteTmuxAvailable`, returns `[]` under test so a + * real ssh never runs in a request path (which would make route tests hit a + * ~10s timeout). The command construction is covered by + * `buildRemoteListSessionsCommand` and the parse by `parseRemoteSessionList`. + */ +export async function listRemoteCodemanSessions( + remote: Pick & RemoteSshOptions +): Promise { + if (process.env.VITEST) { + return []; + } + const command = buildRemoteListSessionsCommand(remote); + try { + const { stdout } = await execAsync(command, { timeout: 15_000 }); + return parseRemoteSessionList(stdout); + } catch { + // Unreachable host, no tmux server, or no sessions (non-zero exit). All map + // to "nothing to attach to" — never surface as an error to the caller. + return []; + } +} + export function remoteDisplayPath( remote: Pick | { username: string; host: string; path: string } ): string { @@ -218,6 +330,10 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi port: host.port, remotePath: remoteCase.remotePath, commands: host.commands, + // COD-105 — the COD-104 launch path creates the remote session, so we own it + // (an explicit kill may propagate a remote kill-session). Discovered+attached + // sessions go through `toAttachedSessionRemote` with `owned: false`. + owned: true, // COD-107 — carry the advanced SSH options from host config into the session // so the launch/prereq commands connect the same way the operator configured. identityFile: host.identityFile, @@ -226,3 +342,38 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi extraSshOptions: host.extraSshOptions, }; } + +/** + * COD-105 — build a NON-owned `SessionRemote` for ATTACHING to a `codeman-*` + * session already running on a remote host (discovered via + * `listRemoteCodemanSessions`). The resulting session's pane runs + * `tmux -L codeman attach -t ` (see + * `buildRemoteAttachCommand`), and because we did NOT create the remote session, + * `owned: false` means closing the tab DETACHES rather than killing it. + * + * `remotePath` is informational here (the attached remote session keeps its own + * cwd); we record the host's nominal path so display helpers still show + * `user@host:path`. + */ +export function toAttachedSessionRemote( + host: RemoteHost, + remoteSessionName: string, + remotePath: string +): SessionRemote { + return { + hostId: host.id, + label: host.label, + host: host.host, + username: host.username, + port: host.port, + remotePath, + commands: host.commands, + // Discovered + attached — another Codeman created it. Detach-not-kill. + owned: false, + remoteSessionName, + identityFile: host.identityFile, + socksProxy: host.socksProxy, + jumpHost: host.jumpHost, + extraSshOptions: host.extraSshOptions, + }; +} diff --git a/src/remote-reconnect.ts b/src/remote-reconnect.ts new file mode 100644 index 00000000..6e5a709a --- /dev/null +++ b/src/remote-reconnect.ts @@ -0,0 +1,184 @@ +/** + * @fileoverview Pure logic for the remote-session auto-reconnect watcher (COD-108). + * + * COD-104 made remote tmux sessions durable + idempotently reattachable, but a + * reconnect only fired at explicit trigger points. COD-108 adds a continuous + * watcher (in `TmuxManager`) that detects a dead remote pane and emits + * `remoteSessionDropped`; `SessionManager`/server then reassembles the respawn + * options and reattaches (re-running the idempotent remote command). + * + * This module holds the SIDE-EFFECT-FREE pieces so they can be unit-tested + * without real tmux: + * - the bounded exponential **backoff schedule** (attempt → delay, capped), + * - the per-session **reconnect state** shape, + * - the **eligibility decision** (`decideReconnect`) given a session + its + * reconnect state + the current time + the guard set. + * + * The watcher in `tmux-manager.ts` owns the live `isPaneDead` probe and the + * timers; everything here is pure and deterministic (time is injected). + * + * @module remote-reconnect + */ + +/** + * Bounded exponential backoff delays (ms) between reconnect attempts. + * Attempt N (1-based) waits `BACKOFF_SCHEDULE_MS[N-1]` from the previous emit + * before the next emit is eligible. After the last entry the session is + * considered `reconnect-exhausted` and the watcher stops emitting for it. + * + * 5s, 15s, 45s, 2m, 5m, 5m → ~6 attempts spanning ~13 minutes. + */ +export const BACKOFF_SCHEDULE_MS: readonly number[] = [5_000, 15_000, 45_000, 120_000, 300_000, 300_000]; + +/** Maximum number of reconnect attempts before exhaustion. */ +export const MAX_RECONNECT_ATTEMPTS = BACKOFF_SCHEDULE_MS.length; + +/** + * Delay (ms) to wait AFTER emitting attempt `attempt` (1-based) before the next + * attempt is eligible. `attempt <= 0` returns the first delay; an attempt at or + * beyond the cap returns the last delay (callers should check exhaustion via + * {@link isExhausted} rather than relying on this for the stop decision). + * + * Pure — no clock, no I/O. + */ +export function reconnectDelayForAttempt(attempt: number): number { + if (!Number.isFinite(attempt) || attempt <= 1) return BACKOFF_SCHEDULE_MS[0]; + const idx = Math.min(Math.floor(attempt) - 1, BACKOFF_SCHEDULE_MS.length - 1); + return BACKOFF_SCHEDULE_MS[idx]; +} + +/** Whether `attempts` reconnect emits have reached/exceeded the cap. Pure. */ +export function isExhausted(attempts: number): boolean { + return attempts >= MAX_RECONNECT_ATTEMPTS; +} + +/** + * Per-session reconnect bookkeeping held by the watcher. All time values are + * epoch ms. `inFlight` guards against stacking respawns when a tick fires while + * a previous reattach is still running. `exhaustedEmitted` ensures the + * `remoteReconnectExhausted` event fires at most once per session. + */ +export interface RemoteReconnectState { + /** Number of `remoteSessionDropped` emits so far (advances per emit). */ + attempts: number; + /** Earliest time (epoch ms) the next emit is eligible. 0 = eligible now. */ + nextEligibleAt: number; + /** A reattach triggered by a prior emit is currently running. */ + inFlight: boolean; + /** Cap reached — stop auto-retrying for this session. */ + exhausted: boolean; + /** The `remoteReconnectExhausted` SSE event has already been emitted. */ + exhaustedEmitted: boolean; +} + +/** A fresh reconnect state (no attempts, immediately eligible). Pure. */ +export function freshReconnectState(): RemoteReconnectState { + return { attempts: 0, nextEligibleAt: 0, inFlight: false, exhausted: false, exhaustedEmitted: false }; +} + +/** + * Advance the backoff after an emit at time `now`. Increments `attempts` and + * schedules `nextEligibleAt = now + delay`. Returns a NEW state object (does + * not mutate the input). Pure. + * + * NOTE: this does NOT set `exhausted`. Exhaustion is a decision the watcher + * makes on the FOLLOWING tick (via {@link decideReconnect} → `exhaust`), so the + * `remoteReconnectExhausted` event fires exactly once after the final attempt's + * backoff window elapses — not pre-emptively on the last emit. + */ +export function advanceBackoff(state: RemoteReconnectState, now: number): RemoteReconnectState { + const attempts = state.attempts + 1; + const delay = reconnectDelayForAttempt(attempts); + return { + ...state, + attempts, + nextEligibleAt: now + delay, + }; +} + +/** Reset after a successful reattach — back to a fresh, eligible state. Pure. */ +export function resetReconnectState(): RemoteReconnectState { + return freshReconnectState(); +} + +/** Minimal session view the decision needs (avoids importing MuxSession here). */ +export interface ReconnectSessionView { + sessionId: string; + /** Truthy when this is a remote (SSH-wrapped) session. */ + isRemote: boolean; + /** Result of `isPaneDead(muxName)` for this session. */ + paneDead: boolean; +} + +/** + * Decision outcomes for a single watcher tick on one session. + * - `emit` → emit `remoteSessionDropped { sessionId, attempt }`, then + * advance backoff (attempt = the returned `attempt`). + * - `exhaust` → cap reached this tick; emit `remoteReconnectExhausted` once. + * - `skip` → do nothing (not remote / pane alive / guarded / in-flight / + * not yet due / already exhausted). + */ +export type ReconnectAction = + | { kind: 'emit'; attempt: number } + | { kind: 'exhaust' } + | { kind: 'skip'; reason: ReconnectSkipReason }; + +export type ReconnectSkipReason = + | 'not-remote' + | 'pane-alive' + | 'guarded' + | 'in-flight' + | 'not-due' + | 'exhausted' + | 'disabled'; + +export interface DecideReconnectInput { + session: ReconnectSessionView; + state: RemoteReconnectState | undefined; + /** Session is in the intentional-teardown guard set (killed/detached/stopping). */ + guarded: boolean; + /** Kill-switch: `remoteAutoReconnect` setting. When false, never reconnect. */ + enabled: boolean; + now: number; +} + +/** + * PURE eligibility decision for one session on one tick. No clock, no I/O — all + * inputs are passed in. The watcher translates the result into emits + state + * transitions. + * + * Order of guards (most-decisive first): + * 1. kill-switch off → skip:disabled + * 2. not a remote session → skip:not-remote + * 3. pane is alive → skip:pane-alive + * 4. intentional teardown guard → skip:guarded (NEVER revive a killed tab) + * 5. a reattach already running → skip:in-flight (no stacked respawns) + * 6. already exhausted → skip:exhausted (one exhaust emit, then quiet) + * 7. cap reached this tick → exhaust + * 8. not yet due (backoff) → skip:not-due + * 9. otherwise → emit (attempt = attempts + 1) + */ +export function decideReconnect(input: DecideReconnectInput): ReconnectAction { + const { session, state, guarded, enabled, now } = input; + + if (!enabled) return { kind: 'skip', reason: 'disabled' }; + if (!session.isRemote) return { kind: 'skip', reason: 'not-remote' }; + if (!session.paneDead) return { kind: 'skip', reason: 'pane-alive' }; + // Intentional kill / detach must NEVER be auto-revived. + if (guarded) return { kind: 'skip', reason: 'guarded' }; + + const s = state ?? freshReconnectState(); + + // Only one reconnect in flight per session — don't stack respawns. + if (s.inFlight) return { kind: 'skip', reason: 'in-flight' }; + + if (s.exhausted) return { kind: 'skip', reason: 'exhausted' }; + + // Cap reached: surface exhaustion once, then go quiet. + if (isExhausted(s.attempts)) return { kind: 'exhaust' }; + + // Backoff gate — only emit when due. + if (now < s.nextEligibleAt) return { kind: 'skip', reason: 'not-due' }; + + return { kind: 'emit', attempt: s.attempts + 1 }; +} diff --git a/src/session-cli-builder.ts b/src/session-cli-builder.ts index 95f9f0da..1e970c45 100644 --- a/src/session-cli-builder.ts +++ b/src/session-cli-builder.ts @@ -21,6 +21,8 @@ function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): str switch (claudeMode) { case 'dangerously-skip-permissions': return ['--dangerously-skip-permissions']; + case 'auto': + return ['--permission-mode', 'auto']; case 'allowedTools': if (allowedTools) { return ['--allowedTools', allowedTools]; @@ -80,8 +82,16 @@ export function buildInteractiveArgs( * @param model - Optional model override * @returns Array of CLI arguments */ -export function buildPromptArgs(prompt: string, model?: string): string[] { - const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json']; +export function buildPromptArgs( + prompt: string, + model?: string, + claudeMode: ClaudeMode = 'dangerously-skip-permissions', + allowedTools?: string +): string[] { + // Respect the session's permission mode instead of always skipping, so a + // multi-user non-granted user's one-shot runs classifier-guarded (auto) rather + // than with full bypass. Defaults to skip-permissions (unchanged single-user). + const args = ['-p', '--verbose', ...buildPermissionArgs(claudeMode, allowedTools), '--output-format', 'stream-json']; if (model) { args.push('--model', model); } diff --git a/src/session.ts b/src/session.ts index a15ce76a..98e05940 100644 --- a/src/session.ts +++ b/src/session.ts @@ -50,7 +50,9 @@ import { type EffortLevel, type GeminiConfig, type SessionRemote, + type SessionDocker, } from './types.js'; +import { probeDockerCliVersion } from './docker-hosts.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; @@ -178,6 +180,8 @@ export function isAltScreenStripMode(mode: SessionMode): boolean { const DEFAULT_PTY_COLS = 120; const DEFAULT_PTY_ROWS = 40; const TMUX_DISPLAY_TIMEOUT_MS = 2000; +/** Delay before the in-container Claude CLI version probe (lets the container start). */ +const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000; /** * Ask tmux for the current window geometry of `muxName` so a re-attaching PTY @@ -212,8 +216,10 @@ export function queryTmuxWindowSize(muxName: string, socket: string): { cols: nu return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS }; } -export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote): string { - return remote ? '/tmp' : workingDir; +export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote, docker?: SessionDocker): string { + // Remote and docker sessions run the CLI elsewhere (ssh / docker exec); the LOCAL + // wrapper pane never needs the workspace as its cwd, so launch it in /tmp. + return remote || docker ? '/tmp' : workingDir; } /** @@ -407,6 +413,14 @@ export class Session extends EventEmitter { // Remote execution metadata, present when this session runs over SSH through local tmux. private readonly _remote?: SessionRemote; + // Docker execution metadata, present when this session runs inside a container via + // local tmux + `docker exec`. The container is per-CASE (shared by sibling sessions). + private readonly _docker?: SessionDocker; + + // Owning username in multi-user mode (undefined in single-user). Stamped at create + // from req.authUser and round-tripped through recovery like _remote/_docker. + private _owner?: string; + // Session color for visual differentiation private _color: import('./types.js').SessionColor = 'default'; @@ -480,6 +494,10 @@ export class Session extends EventEmitter { attachmentHistory?: SessionAttachmentHistoryItem[]; /** Remote execution metadata for sessions launched through SSH inside local tmux. */ remote?: SessionRemote; + /** Docker execution metadata for sessions launched inside a container via local tmux. */ + docker?: SessionDocker; + /** Owning username (multi-user mode); undefined in single-user. */ + owner?: string; } ) { super(); @@ -553,6 +571,8 @@ export class Session extends EventEmitter { } this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT; this._remote = config.remote; + this._docker = config.docker; + this._owner = config.owner; if (config.attachmentHistory && config.attachmentHistory.length > 0) { this.restoreAttachmentHistory(config.attachmentHistory); } @@ -654,6 +674,21 @@ export class Session extends EventEmitter { return this._claudeSessionId; } + /** Docker execution metadata when this session runs inside a container, else undefined. */ + get docker(): SessionDocker | undefined { + return this._docker; + } + + /** Owning username in multi-user mode, else undefined. */ + get owner(): string | undefined { + return this._owner; + } + + /** Set the owning username (used by recovery to restore ownership). */ + set owner(username: string | undefined) { + this._owner = username; + } + // Adopt a Claude conversation ID observed from an external source (e.g. hook // payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so // `_handleJsonMessage` never sees `session_id`; hooks are the only signal @@ -1033,6 +1068,8 @@ export class Session extends EventEmitter { status: this._status, workingDir: this.workingDir, remote: this._remote, + docker: this._docker, + owner: this._owner, currentTaskId: this._currentTaskId, createdAt: this.createdAt, lastActivityAt: this._lastActivityAt, @@ -1224,7 +1261,7 @@ export class Session extends EventEmitter { name: 'xterm-256color', cols: ptyCols, rows: ptyRows, - cwd: resolveMuxAttachCwd(this.workingDir, this._remote), + cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker), // COD-75: codex/gemini get COLORTERM=truecolor — mirrors buildEnvExports() // in tmux-manager.ts so the attach client and the tmux session agree. env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini'), @@ -1238,6 +1275,70 @@ export class Session extends EventEmitter { return { isRestored }; } + /** + * COD-108 — re-establish a dropped REMOTE session. Triggered by the + * `TmuxManager` remote-reconnect watcher (via `remoteSessionDropped`): the + * watcher detects a dead remote pane, the session owner reassembles the SAME + * `RespawnPaneOptions` used for Claude-idle respawns and calls + * `respawnPane()` directly. For a remote session that re-runs + * `buildRemoteSessionCommand` (owned → `new-session -A`, non-owned → + * `attach`), which idempotently REATTACHES the still-running durable remote + * tmux session — scrollback + agent intact (proven COD-104/105). + * + * Deliberately does NOT route through the Claude-idle respawn-controller — + * this is a transport re-establish, not a `/clear`/`/compact` cycle. + * + * @returns true if the pane was respawned (reattach issued), false otherwise. + */ + async reattachRemote(): Promise { + if (!this._remote) return false; // not a remote session + if (!this._useMux || !this._mux || !this._muxSession) return false; + const mux = this._mux; + + // If tmux lost the whole session (not just a dead pane), there is nothing to + // respawn into — a genuine death, leave it for normal recovery/reconcile. + if (!mux.muxSessionExists(this._muxSession.muxName)) { + console.log('[Session] reattachRemote: mux session gone, skipping:', this._muxSession.muxName); + return false; + } + + const newPid = await mux.respawnPane(this._buildRespawnPaneOptions()); + if (!newPid) { + console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName); + return false; + } + console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid); + return true; + } + + /** + * Assemble the {@link RespawnPaneOptions} for this session. Single source of + * truth shared by interactive start, shell start (via their inline copies), + * and {@link reattachRemote} so the remote reattach path can never drift from + * the spawn path. + */ + private _buildRespawnPaneOptions(): import('./mux-interface.js').RespawnPaneOptions { + return { + sessionId: this.id, + workingDir: this.workingDir, + mode: this.mode, + niceConfig: this._niceConfig, + model: this._model, + claudeMode: this._claudeMode, + allowedTools: this._allowedTools, + openCodeConfig: this._openCodeConfig, + codexConfig: this._codexConfig, + geminiConfig: this._geminiConfig, + resumeSessionId: this._resumeSessionId, + envOverrides: this._envOverrides, + effort: this._effort, + historyLimit: this._tmuxHistoryLimit, + remote: this._remote, + docker: this._docker, + owner: this._owner, + }; + } + private _handleTerminalOutput(data: string): void { // Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus // mouse-tracking enables that hijack the scroll wheel so the user can't reach @@ -1344,7 +1445,7 @@ export class Session extends EventEmitter { // repaint/alt-screen mode; issue #154). Remote sessions run claude on // another host, so a local probe wouldn't reflect their version — skip them // and let the banner scrape handle those. Cached process-wide, best-effort. - if (this.mode === 'claude' && !this._remote && !this._cliVersion) { + if (this.mode === 'claude' && !this._remote && !this._docker && !this._cliVersion) { const probedVersion = getClaudeCliVersion(); if (probedVersion) { this._cliVersion = probedVersion; @@ -1357,27 +1458,37 @@ export class Session extends EventEmitter { } } + // Docker sessions run claude INSIDE the container, so the local probe above + // reports the HOST claude (wrong version, and leaving cliVersion undefined + // silently disables wheel-forwarding, #154). Probe the IN-CONTAINER version + // instead — deferred so the container is up after the mux attach below. + if (this.mode === 'claude' && this._docker && !this._cliVersion) { + const dockerMeta = this._docker; + setTimeout(() => { + if (this._isStopped || this._cliVersion) return; + void probeDockerCliVersion(dockerMeta, this.mode) + .then((version) => { + if (!version || this._isStopped || this._cliVersion) return; + this._cliVersion = version; + this.emit('cliInfoUpdated', { + version: this._cliVersion, + model: this._cliModel, + accountType: this._cliAccountType, + latestVersion: this._cliLatestVersion, + }); + }) + .catch(() => { + /* best-effort */ + }); + }, DOCKER_CLI_VERSION_PROBE_DELAY_MS); + } + // If mux wrapping is enabled, create or attach to a mux session if (this._useMux && this._mux) { try { const { isRestored } = await this._setupOrAttachMuxSession({ - respawnPaneOptions: { - sessionId: this.id, - workingDir: this.workingDir, - mode: this.mode, - niceConfig: this._niceConfig, - model: this._model, - claudeMode: this._claudeMode, - allowedTools: this._allowedTools, - openCodeConfig: this._openCodeConfig, - codexConfig: this._codexConfig, - geminiConfig: this._geminiConfig, - resumeSessionId: this._resumeSessionId, - envOverrides: this._envOverrides, - effort: this._effort, - historyLimit: this._tmuxHistoryLimit, - remote: this._remote, - }, + // Single source of truth shared with reattachRemote() (COD-108). + respawnPaneOptions: this._buildRespawnPaneOptions(), createSessionOptions: { sessionId: this.id, workingDir: this.workingDir, @@ -1395,6 +1506,8 @@ export class Session extends EventEmitter { effort: this._effort, historyLimit: this._tmuxHistoryLimit, remote: this._remote, + docker: this._docker, + owner: this._owner, }, spawnErrLabel: 'mux attachment', }); @@ -1504,7 +1617,7 @@ export class Session extends EventEmitter { // === Auto-accept workspace trust dialog === // Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory. - // Codeman sessions always use --dangerously-skip-permissions, so auto-accept. + // Codeman sessions run permission-skipping or classifier-guarded (auto) modes, so auto-accept. if (!this._trustDialogAccepted && data.includes('trust this folder')) { this._trustDialogAccepted = true; console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`); @@ -1765,6 +1878,8 @@ export class Session extends EventEmitter { envOverrides: this._envOverrides, historyLimit: this._tmuxHistoryLimit, remote: this._remote, + docker: this._docker, + owner: this._owner, }, createSessionOptions: { sessionId: this.id, @@ -1775,6 +1890,8 @@ export class Session extends EventEmitter { envOverrides: this._envOverrides, historyLimit: this._tmuxHistoryLimit, remote: this._remote, + docker: this._docker, + owner: this._owner, }, spawnErrLabel: 'shell mux attachment', }); @@ -1902,7 +2019,7 @@ export class Session extends EventEmitter { model ? `(model: ${model})` : '' ); - const args = buildPromptArgs(prompt, model); + const args = buildPromptArgs(prompt, model, this._claudeMode, this._allowedTools); try { this.ptyProcess = pty.spawn('claude', args, { diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 0139d20d..ca29478e 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -29,7 +29,8 @@ const execAsync = promisify(exec); import { existsSync, readFileSync, mkdirSync } from 'node:fs'; import { writeFile, rename } from 'node:fs/promises'; import { dirname } from 'node:path'; -import { dataPath, DEFAULT_TMUX_SOCKET } from './config/instance.js'; +import { homedir } from 'node:os'; +import { dataPath, DEFAULT_TMUX_SOCKET, CODEMAN_INSTANCE } from './config/instance.js'; import { ProcessStats, PersistedRespawnConfig, @@ -43,9 +44,24 @@ import { type EffortLevel, type GeminiConfig, type SessionRemote, + type SessionDocker, + type DockerCommandMode, } from './types.js'; import { buildEffortCliArgs } from './session-cli-builder.js'; import { buildSshConnectionArgs, defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js'; +import { + buildDockerBaseArgs, + buildDockerCreateArgs, + containerApiUrl, + CONTAINER_HOME, + defaultDockerCommandForMode, + hostGatewayAlias, + resolveDockerClaudeArtifacts, + resolveDockerCredentialArtifacts, + type DockerCreateContext, + type DockerMount, + type DockerSeedCopy, +} from './docker-hosts.js'; import { wrapWithNice, SAFE_PATH_PATTERN, @@ -62,6 +78,13 @@ import type { RespawnPaneOptions, PaneCaptureOptions, } from './mux-interface.js'; +import { + decideReconnect, + advanceBackoff, + freshReconnectState, + resetReconnectState, + type RemoteReconnectState, +} from './remote-reconnect.js'; // ============================================================================ // Timing Constants @@ -94,6 +117,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100; /** Default stats collection interval (2 seconds) */ const DEFAULT_STATS_INTERVAL_MS = 2000; +/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */ +const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000; + /** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */ const TMUX_LAUNCH_CWD = '/tmp'; @@ -118,6 +144,20 @@ const IS_TEST_MODE = !!process.env.VITEST; /** Path to persisted mux session metadata */ const MUX_SESSIONS_FILE = dataPath('mux-sessions.json'); +/** + * COD-108 kill-switch: `remoteAutoReconnect` app setting (default ON). Read at + * call time (like headroom routing) so a settings change takes effect without a + * restart. Absent/non-boolean ⇒ true (feature on). + */ +function isRemoteAutoReconnectEnabled(): boolean { + try { + const s = JSON.parse(readFileSync(dataPath('settings.json'), 'utf8')) as Record; + return typeof s.remoteAutoReconnect === 'boolean' ? s.remoteAutoReconnect : true; + } catch { + return true; + } +} + /** Regex to validate tmux session names (only allow safe characters) */ const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/; @@ -547,6 +587,8 @@ function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: stri switch (mode) { case 'dangerously-skip-permissions': return ' --dangerously-skip-permissions'; + case 'auto': + return ' --permission-mode auto'; case 'allowedTools': if (allowedTools) { // Sanitize: allow tool names with patterns like Bash(git:*), space/comma-separated @@ -658,7 +700,7 @@ function buildEffortSettingsFlag(effort?: EffortLevel): string { return flag && value ? ` ${flag} '${value}'` : ''; } -function buildSpawnCommand(options: { +export function buildSpawnCommand(options: { mode: SessionMode; sessionId: string; model?: string; @@ -761,9 +803,22 @@ export function buildRemoteLaunchCommand(options: { mode: SessionMode; remote: SessionRemote; sessionId: string; + claudeMode?: ClaudeMode; + allowedTools?: string; }): string { - const { mode, remote, sessionId } = options; - const modeCommand = remote.commands?.[mode] || defaultRemoteCommandForMode(mode); + const { mode, remote, sessionId, claudeMode, allowedTools } = options; + // §6.3: honor the session's EFFECTIVE claude permission mode on remote instead of + // hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's + // downgraded 'auto' actually reaches the remote agent (the default command otherwise + // ignored claudeMode). A per-host `commands.claude` override stays authoritative + // (admin's explicit choice). For the DEFAULT single-user config (skip), the emitted + // command is byte-identical to before. Non-claude modes are unchanged. + const override = remote.commands?.[mode]; + const modeCommand = override + ? override + : mode === 'claude' + ? `exec claude${buildClaudePermissionFlags(claudeMode, allowedTools)}` + : defaultRemoteCommandForMode(mode); const remoteName = remoteTmuxSessionName(sessionId); // Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by @@ -781,6 +836,13 @@ export function buildRemoteLaunchCommand(options: { `set -t ${remoteName} mouse off`, `set -t ${remoteName} prefix C-q`, 'set -s escape-time 0', + // COD-106 — shared/collaborative sessions: tmux defaults to sizing a window + // to the SMALLEST attached client, so two Codemans at different viewports + // would fight (clamp to the smaller). `window-size latest` sizes to the + // most-recently-active client instead, so concurrent clients coexist. + // Per-session scoped (`set -t `, matching #145's hardening) so a shared + // remote tmux server's other sessions keep their own sizing behavior. + `set -t ${remoteName} window-size latest`, ].join(' \\; '); // ssh runs its trailing args through the remote login shell, so the entire @@ -812,6 +874,345 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].join(' '); } +// ========== Docker cases (COD-Docker) ========== +// +// The docker analog of the remote-SSH launch above. Instead of a local tmux pane +// running `ssh -t host 'tmux new-session …'`, it runs `docker exec -it +// sh -lc 'tmux new-session …'` into a DURABLE in-container tmux server. The +// container is per-CASE, so many sessions `docker exec` into the same one. See +// docs/docker-cases-plan.md. + +/** + * DEDICATED in-container tmux socket. A Codeman running INSIDE the container uses + * `-L codeman`; ours is `-L codeman-docker` with a `codeman-dkr-*` session name + * that deliberately FAILS SAFE_MUX_NAME_PATTERN, so an in-container Codeman never + * adopts/resizes/respawns our session (same defence as the remote socket). + */ +const DOCKER_TMUX_SOCKET = 'codeman-docker'; + +/** + * Deterministic, reattach-stable in-container tmux session name. Derived from the + * same stable field the local muxName uses (first 8 chars of the sessionId), so a + * reconnect re-issues the exact same `new-session -A` and lands back in the SAME + * in-container session. The `dkr` letters make it fail SAFE_MUX_NAME_PATTERN. + */ +export function dockerTmuxSessionName(sessionId: string): string { + return `codeman-dkr-${sessionId.slice(0, 8)}`; +} + +/** Resume ids are UUID-ish; reject anything with shell metacharacters (defensive). */ +const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/; + +/** + * Append the CLI-specific resume flag to a pane command (codex/gemini). Only fires + * when the in-container tmux is RE-CREATED (`new-session -A` makes the flag inert + * on a live reattach), i.e. exactly when the previous live agent was lost and we + * want to resume the conversation from the bind-mounted transcript. Claude mode + * uses claudeDockerPaneCommand instead. + */ +function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string { + if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand; + switch (mode) { + case 'gemini': + return `${modeCommand} --resume ${resumeId}`; + case 'codex': + return `${modeCommand} resume ${resumeId}`; + default: + return modeCommand; // shell / opencode: no resume + } +} + +/** + * Claude-mode pane command with a DETERMINISTIC conversation id (the docker analog + * of buildSpawnCommand's --resume/--session-id logic). A fresh launch passes + * `--session-id `, so the in-container conversation id is knowable + * host-side (resume-id capture + subagent/workflow correlation) WITHOUT relying on + * hook reachability. When the in-container tmux was re-created after a container + * stop/reboot, the same command re-runs against the surviving transcript: + * `--session-id` exits 1 ("already in use") and the `||` fallback RESUMES that + * conversation (verified CLI behavior). An explicit resumeId gets the local + * builder's shape — resume first, session-id fallback — so a stale id never + * dead-panes. The leading `exec ` is stripped: an exec'd first branch could never + * fall back. + */ +function claudeDockerPaneCommand(modeCommand: string, sessionId: string, resumeId?: string): string { + if (!RESUME_ID_SAFE.test(sessionId)) return modeCommand; // defensive — ids are server-minted uuids + const cmd = modeCommand.replace(/^exec\s+/, ''); + const rid = resumeId && RESUME_ID_SAFE.test(resumeId) ? resumeId : undefined; + if (rid && rid !== sessionId) { + return `${cmd} --resume ${rid} || ${cmd} --session-id ${sessionId}`; + } + const cid = rid ?? sessionId; + return `${cmd} --session-id ${cid} || ${cmd} --resume ${cid}`; +} + +/** Fully-resolved inputs for buildDockerLaunchCommand (pure). */ +export interface DockerLaunchOptions { + mode: SessionMode; + docker: SessionDocker; + sessionId: string; + resumeSessionId?: string; + createContext: DockerCreateContext; + /** exec-time inline env (non-secret): TERM, COLORTERM, CODEMAN_SESSION_ID, CODEMAN_MUX */ + execEnv: Record; + /** exec-time NAME-ONLY env forwarded from Codeman's process env (codex/gemini keys) */ + execEnvNames: string[]; + /** + * Files to copy from read-only seed mounts into the container's writable HOME once + * before launch (guarded so reconnects never clobber). Isolates Claude state: the + * merged `~/.claude.json`, plus `~/.claude/.credentials.json` + `settings.json`, + * are writable copies (not host mounts), so the container never re-auths and never + * writes its runtime state back into the host `~/.claude`. + */ + seedCopies?: DockerSeedCopy[]; +} + +/** + * Build the ONE `bash -c` launch string for a docker session: image-check -> + * ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable + * in-container tmux (resume-aware). PURE and unit-testable. The escaping survives + * four layers: outer `bash -c "…"` (JSON.stringify at respawn-pane) -> the joined + * command -> `docker exec … sh -lc ''` -> tmux `''`. + */ +export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { + const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies } = opts; + const base = buildDockerBaseArgs(docker).join(' '); + const createArgs = buildDockerCreateArgs(createContext).join(' '); + const name = shellescape(docker.containerName); + const workdir = shellescape(docker.containerWorkdir); + const image = shellescape(docker.image); + const dkrName = dockerTmuxSessionName(sessionId); + const sid = sessionId.slice(0, 8); + + let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode); + if (mode === 'claude') { + modeCommand = claudeDockerPaneCommand(modeCommand, sessionId, resumeSessionId); + } else if (resumeSessionId) { + modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId); + } + // Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the + // pane PID the agent itself. + const paneCommand = `cd ${workdir} && ${modeCommand}`; + + // `setenv -g` primes the session id so reattaches / newly-created panes inherit + // it. `new-session -A` = attach-or-create (idempotent + resume-aware). Options + // are scoped per-session (`set -t`) or server (`set -s`), never `-g`, so a shared + // in-container tmux server's other sessions keep their own prefix/mouse. + const tmuxInvocation = [ + `tmux -L ${DOCKER_TMUX_SOCKET} setenv -g CODEMAN_SESSION_ID ${shellescape(sid)}`, + 'setenv -g CODEMAN_MUX 1', + `new-session -A -s ${dkrName} -c ${workdir} ${shellescape(paneCommand)}`, + `set -t ${dkrName} status off`, + `set -t ${dkrName} mouse off`, + `set -t ${dkrName} prefix C-q`, + 'set -s escape-time 0', + ].join(' \\; '); + + const execEnvFlags: string[] = []; + for (const [k, v] of Object.entries(execEnv)) execEnvFlags.push('--env', shellescape(`${k}=${v}`)); + // NAME-ONLY forwards: docker reads the VALUE from Codeman's own process env, so + // the secret never appears in argv (no `ps` leak) and is not committed. + for (const n of execEnvNames) execEnvFlags.push('--env', n); + for (const extra of docker.extraExecArgs ?? []) execEnvFlags.push(shellescape(extra)); + + const imageMissingMsg = shellescape( + `Codeman: base image ${docker.image} not present (it is normally auto-built on first use)` + ); + const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`); + + const imageCheck = `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`; + // create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain. + const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`; + const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`; + // Seed writable credential config from read-only host mounts ONCE per container + // (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for + // whole-dir credential seeds). mkdir -p the parent so a file seed works even when + // no sibling share-mount pre-created the dir. Paths are fixed CONTAINER_HOME + // constants (no shell metachars), so the whole inner command is shell-quoted once. + const seedSteps = (seedCopies ?? []).map((s) => { + const cp = s.recursive ? 'cp -a' : 'cp'; + const parent = s.to.slice(0, s.to.lastIndexOf('/')); + return `mkdir -p ${parent} 2>/dev/null; [ -e ${s.to} ] || ${cp} ${s.from} ${s.to} 2>/dev/null || true`; + }); + const innerCmd = seedSteps.length ? `${seedSteps.join(' ; ')} ; ${tmuxInvocation}` : tmuxInvocation; + const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(innerCmd)}`; + + return [imageCheck, ensure, start, execCmd].join(' ; '); +} + +/** + * Kill ONLY this session's in-container tmux session. The container is shared by + * the case's other sessions, so this NEVER `docker stop`s it — stopping/removing + * the container is an explicit teardown (buildDockerStopCommand) or case-delete + * (buildDockerRemoveCommand). Fired best-effort on session kill. + */ +export function buildDockerKillCommand(options: { docker: SessionDocker; sessionId: string }): string { + const { docker, sessionId } = options; + const base = buildDockerBaseArgs(docker).join(' '); + const dkrName = dockerTmuxSessionName(sessionId); + return `${base} exec ${shellescape(docker.containerName)} tmux -L ${DOCKER_TMUX_SOCKET} kill-session -t ${shellescape(dkrName)}`; +} + +/** Explicit container stop (frees RAM/CPU; conversation resumes on next launch via --resume). */ +export function buildDockerStopCommand(docker: SessionDocker): string { + return `${buildDockerBaseArgs(docker).join(' ')} stop -t 10 ${shellescape(docker.containerName)}`; +} + +/** Explicit container removal (case-delete). Destroys in-image state; bind mounts survive. */ +export function buildDockerRemoveCommand(docker: SessionDocker): string { + return `${buildDockerBaseArgs(docker).join(' ')} rm -f ${shellescape(docker.containerName)}`; +} + +/** + * Resolve the environment-dependent bits of a docker launch (host uid, existing + * credential mounts, derived api url, hook-secret mount, Desktop detection) into + * the pure buildDockerLaunchCommand inputs. IO; only ever called from the real + * launch path (createSession/respawnPane no-op under VITEST). + */ +export function resolveDockerLaunchOptions( + mode: SessionMode, + docker: SessionDocker, + sessionId: string, + resumeSessionId?: string +): DockerLaunchOptions { + const home = homedir(); + const isDesktop = process.platform === 'darwin'; // Docker Desktop translates uids + native host.docker.internal + const uid = typeof process.getuid === 'function' ? process.getuid() : 1000; + const userArgs: string[] = + docker.engine === 'podman' + ? ['--userns=keep-id'] // rootless podman: map host uid to the image `agent` uid + : isDesktop + ? [] // Desktop: run as the image's baked uid (a mac uid wouldn't own /home/agent) + : ['--user', `${uid}:0`]; // Linux: host uid + GID 0 (OpenShift arbitrary-uid writable HOME) + const gatewayAlias = hostGatewayAlias(docker.engine); + + const credentialMounts: DockerMount[] = []; + const extraMounts: DockerMount[] = []; + // Isolated credential state (Claude + codex/gemini/gcloud/opencode): each store + // shares ONLY what a host feature / --resume needs (Claude projects/, codex + // sessions/+history) and seeds everything else (tokens, settings, configs) as + // writable copies, so the container is authed WITHOUT re-auth and WITHOUT writing + // its runtime state back into the host dirs. Only when credentials are mounted. + let seedCopies: DockerSeedCopy[] = []; + if (docker.mountCredentials) { + const claudeArtifacts = resolveDockerClaudeArtifacts(home, docker.containerName, docker.containerWorkdir); + const credArtifacts = resolveDockerCredentialArtifacts(home); + extraMounts.push(...claudeArtifacts.mounts, ...credArtifacts.mounts); + seedCopies = [...claudeArtifacts.seedCopies, ...credArtifacts.seedCopies]; + } + const envCreate: Record = { + HOME: CONTAINER_HOME, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + // Force a UTF-8 locale (the base image defaults to POSIX/C). Without this, tmux + // runs in non-UTF-8 mode and renders Claude's Unicode box-drawing (─│┌┐) as raw + // VT100 ACS glyphs (`qqqq…`). `C.UTF-8` is built into glibc (no locale-gen). + LANG: 'C.UTF-8', + LC_ALL: 'C.UTF-8', + // Give claude a temp dir it will own inside HOME. Its default `/tmp/claude-` + // is refused when that path pre-exists root-owned — which happens when the + // workspace bind-mount path traverses it (e.g. a workspace under /tmp/claude-). + // A nonexistent HOME subpath is created+owned by the running uid, so this is robust + // to any workspace location. Non-secret path, safe to be committed on export. + CLAUDE_CODE_TMPDIR: `${CONTAINER_HOME}/.cache/codeman-claude-tmp`, + }; + if (docker.hooksEnabled) { + // Derive a container-reachable API url (scheme + port preserved; host swapped + // for the engine gateway alias). Prod is HTTPS on 3000. + envCreate.CODEMAN_API_URL = containerApiUrl(process.env.CODEMAN_API_URL, docker.engine); + const hookSecretPath = dataPath('hook-secret'); + if (existsSync(hookSecretPath)) { + const dst = `${CONTAINER_HOME}/.codeman/hook-secret`; + extraMounts.push({ src: hookSecretPath, dst, readonly: true }); + envCreate.CODEMAN_HOOK_SECRET_FILE = dst; // a path is non-secret; the bytes ride the bind mount + } + } + + const createContext: DockerCreateContext = { + docker, + sessionId, + instance: CODEMAN_INSTANCE, + userArgs, + credentialMounts, + extraMounts, + envCreate, + addHostGateway: !isDesktop, + gatewayAlias, + }; + + const execEnv: Record = { + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + // UTF-8 at exec time too, so the tmux CLIENT this exec launches is UTF-8 and + // renders box-drawing correctly even when reattaching to a container created + // before this fix (client_utf8 is per-client, resolved from the exec's locale). + LANG: 'C.UTF-8', + LC_ALL: 'C.UTF-8', + CODEMAN_SESSION_ID: sessionId.slice(0, 8), + CODEMAN_MUX: '1', + }; + // NAME-ONLY exec env forwarded from Codeman's process env (the docker client + // inherits it), so API-key CLIs get their key without it appearing in argv. + const execEnvNames = + mode === 'codex' + ? ['OPENAI_API_KEY', 'CODEX_API_KEY'] + : mode === 'gemini' + ? ['GEMINI_API_KEY', 'GOOGLE_API_KEY'] + : []; + + return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies }; +} + +/** + * COD-105 — build the SSH command that ATTACHES to an EXISTING `codeman-*` tmux + * session on the remote host (one this Codeman didn't create — discovered via + * `listRemoteCodemanSessions`). Sibling of `buildRemoteLaunchCommand`. + * + * Emits: + * ssh -o BatchMode=yes -t [] user@host \ + * 'tmux -L codeman attach -t ' + * + * - `attach` (NOT `new-session -A`) so we only join an existing session; the + * remote session keeps running independent of us, which is exactly why the + * resulting Codeman session is NON-OWNED (see `SessionRemote.owned`): closing + * the local tab must detach, never `kill-session` the remote. + * - The remote session name is shell-escaped so a value with metachars stays a + * single token inside the quoted tmux invocation. + * - COD-107 — connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`, + * arbitrary `-o`) come from the shared `buildSshConnectionArgs`, so attach + * connects identically to launch / discovery / the prereq probe. `-t` sits + * right after `ssh -o BatchMode=yes` (a PTY is required for interactive tmux). + */ +export function buildRemoteAttachCommand(remote: SessionRemote, remoteSessionName: string): string { + const tmuxInvocation = `tmux -L codeman attach -t ${shellescape(remoteSessionName)}`; + const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote); + const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)]; + return sshParts.join(' '); +} + +/** + * COD-105 — choose the right remote ssh command for a session's ownership: + * - NON-owned (`remote.owned === false`): ATTACH to a discovered remote tmux + * session by its EXISTING name (`remote.remoteSessionName`, falling back to + * this session's deterministic name). We only join — never create. + * - owned (default): LAUNCH/attach-or-create via `buildRemoteLaunchCommand` + * (COD-104), which we then own and may explicitly kill. + */ +function buildRemoteSessionCommand(options: { + mode: SessionMode; + remote: SessionRemote; + sessionId: string; + claudeMode?: ClaudeMode; + allowedTools?: string; +}): string { + const { remote, sessionId } = options; + if (remote.owned === false) { + const target = remote.remoteSessionName || remoteTmuxSessionName(sessionId); + return buildRemoteAttachCommand(remote, target); + } + return buildRemoteLaunchCommand(options); +} + /** * Set sensitive environment variables on a tmux session via setenv. * These are inherited by panes but not visible in ps output or tmux history. @@ -965,6 +1366,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { /** Track last-known pane count per session to avoid unnecessary tmux set-option calls */ private lastPaneCount: Map = new Map(); + // ── COD-108 remote-reconnect watcher state ──────────────────────────────── + /** Periodic watcher that re-establishes dropped remote sessions. */ + private remoteReconnectInterval: NodeJS.Timeout | null = null; + /** Per-session backoff/attempt bookkeeping (sessionId → state). */ + private reconnectState: Map = new Map(); + /** + * Sessions excluded from auto-reconnect because they are being intentionally + * torn down (killed/detached/stopping). A guarded session is NEVER revived. + */ + private reconnectGuard: Set = new Set(); + private trueColorConfigured = false; constructor() { @@ -1209,6 +1621,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { effort, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, + docker, + owner, } = options; const muxName = `codeman-${sessionId.slice(0, 8)}`; @@ -1228,6 +1642,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { createdAt: Date.now(), workingDir, remote, + docker, + owner, mode, attached: false, name, @@ -1273,7 +1689,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { // Build the full command to run inside tmux const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`; - const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd; + const fullCmd = docker + ? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId)) + : remote + ? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools }) + : localFullCmd; // Create tmux session in three steps to handle cold-start (no server running) // and avoid the race where the command exits before remain-on-exit is set: @@ -1324,7 +1744,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Replace the shell with the actual command (no echo in terminal). Keep // pane launch in /tmp, then cd inside bash against the current mount table. - const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`; + const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`; execSync( `${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`, { @@ -1399,6 +1819,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { createdAt: Date.now(), workingDir, remote, + docker, + owner, mode, attached: false, name, @@ -1484,6 +1906,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { effort, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, + docker, } = options; const session = this.sessions.get(sessionId); if (!session) return null; @@ -1521,7 +1944,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const config = niceConfig || DEFAULT_NICE_CONFIG; const cmd = wrapWithNice(baseCmd, config); const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`; - const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd; + const fullCmd = docker + ? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId)) + : remote + ? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools }) + : localFullCmd; try { // For OpenCode: set sensitive env vars via tmux setenv before respawn @@ -1539,7 +1966,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { this.applyEnvOverrides(muxName, envOverrides); // -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state). - const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`; + const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`; await execAsync( `${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`, { @@ -1636,9 +2063,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return false; } + // COD-108: an intentional kill/detach must NEVER be auto-revived by the + // remote-reconnect watcher. Guard BEFORE any teardown so a tick that fires + // mid-kill (especially the non-owned DETACH early-return below, where the + // dead local pane would otherwise look reconnectable) sees the guard. + this.guardRemoteReconnect(sessionId); + // TEST MODE: Remove from memory only — NEVER touch real tmux sessions if (IS_TEST_MODE) { this.sessions.delete(sessionId); + this.clearRemoteReconnectState(sessionId); this.emit('sessionKilled', { sessionId }); return true; } @@ -1650,6 +2084,40 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return false; } + // COD-105 — DETACH-NOT-KILL for NON-owned remote sessions. + // + // When this session was created by ATTACHING a remote tmux session another + // Codeman owns (`remote.owned === false`), closing the tab must NOT propagate + // a remote `tmux kill-session` — that would nuke work the remote's own + // Codeman (or another instance) still relies on. We tear down ONLY the LOCAL + // pane that holds the ssh client: killing the local ssh sends SIGHUP to its + // remote `tmux attach`, which DETACHES (the durable remote session survives). + // + // This early return is the structural guarantee: no code below this point + // (now or in future for owned sessions) can ever issue a remote kill-session + // for a non-owned session. The only `kill-session` we run is on OUR LOCAL + // socket (`this.tmux()` = `tmux -L codeman` on THIS host), which kills the + // local pane — it does NOT reach the REMOTE socket. + if (session.remote && session.remote.owned === false) { + console.log(`[TmuxManager] DETACH (non-owned remote): tearing down local pane only for ${session.muxName}`); + if (isValidMuxName(session.muxName)) { + try { + // Local socket only — detaches the remote session by killing the local ssh pane. + execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, { + timeout: EXEC_TIMEOUT_MS, + }); + } catch { + // Local pane may already be gone. + } + } + this.lastPaneCount.delete(session.muxName); + this.sessions.delete(sessionId); + this.clearRemoteReconnectState(sessionId); + this.saveSessions(); + this.emit('sessionKilled', { sessionId }); + return true; + } + // Get current PID (may have changed) const currentPid = this.getPanePid(session.muxName) || session.pid; @@ -1725,6 +2193,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + // Strategy 3c: Docker sessions run a DURABLE in-container tmux session. Kill + // ONLY this session's in-container tmux session (best-effort). The container is + // PER-CASE and shared by the case's other sessions, so we deliberately do NOT + // `docker stop` it here — stopping/removing is an explicit teardown/case-delete. + if (session.docker && !IS_TEST_MODE) { + try { + exec(buildDockerKillCommand({ docker: session.docker, sessionId }), { timeout: EXEC_TIMEOUT_MS }, () => {}); + } catch { + // Best-effort — never affects the local kill result. + } + } + // Strategy 4: Direct kill by PID as final fallback if (this.isProcessAlive(currentPid)) { try { @@ -1742,6 +2222,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { this.lastPaneCount.delete(session.muxName); this.sessions.delete(sessionId); + this.clearRemoteReconnectState(sessionId); this.saveSessions(); this.emit('sessionKilled', { sessionId }); @@ -1808,6 +2289,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } else { dead.push(sessionId); this.sessions.delete(sessionId); + this.clearRemoteReconnectState(sessionId); this.emit('sessionDied', { sessionId }); } } @@ -2079,9 +2561,118 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { this.lastPaneCount.clear(); } + // ── COD-108 remote-session auto-reconnect watcher ───────────────────────── + + /** + * Start the remote-reconnect watcher (COD-108). Each tick, for every tracked + * session with `session.remote` whose local pane is DEAD, not intentionally + * guarded, and within its backoff budget, emit `remoteSessionDropped` so the + * session owner reattaches (re-running the idempotent remote command rejoins + * the durable remote tmux session). After the attempt cap, emit + * `remoteReconnectExhausted` once and go quiet. + * + * No-op tick body under `IS_TEST_MODE` (mirrors `startMouseModeSync`): tests + * drive the logic deterministically via {@link runRemoteReconnectTick}. + */ + startRemoteReconnectWatcher(intervalMs: number = DEFAULT_REMOTE_RECONNECT_INTERVAL_MS): void { + if (this.remoteReconnectInterval) { + clearInterval(this.remoteReconnectInterval); + } + this.remoteReconnectInterval = setInterval(() => { + if (IS_TEST_MODE) return; + try { + this.runRemoteReconnectTick(Date.now(), isRemoteAutoReconnectEnabled()); + } catch (err) { + console.error('[TmuxManager] Remote reconnect watcher error:', err); + } + }, intervalMs); + } + + stopRemoteReconnectWatcher(): void { + if (this.remoteReconnectInterval) { + clearInterval(this.remoteReconnectInterval); + this.remoteReconnectInterval = null; + } + } + + /** + * Run ONE watcher tick. Extracted (and given an injected `now`/`enabled`) so + * the reconnect logic is deterministically testable even though the live + * `setInterval` body no-ops under test mode. For each remote session it + * applies the pure {@link decideReconnect} decision and translates the result + * into events + backoff/state transitions. Public for tests + the watcher. + */ + runRemoteReconnectTick(now: number, enabled: boolean): void { + for (const session of this.sessions.values()) { + if (!session.remote) continue; + const sessionId = session.sessionId; + const state = this.reconnectState.get(sessionId); + const action = decideReconnect({ + session: { + sessionId, + isRemote: true, + paneDead: this.isPaneDead(session.muxName), + }, + state, + guarded: this.reconnectGuard.has(sessionId), + enabled, + now, + }); + + if (action.kind === 'emit') { + const base = state ?? freshReconnectState(); + // Mark in-flight + advance backoff BEFORE emitting so a re-entrant tick + // (or a synchronous listener) can never stack a second reconnect. + this.reconnectState.set(sessionId, { ...advanceBackoff(base, now), inFlight: true }); + this.emit('remoteSessionDropped', { sessionId, attempt: action.attempt }); + } else if (action.kind === 'exhaust') { + const base = state ?? freshReconnectState(); + if (!base.exhaustedEmitted) { + this.reconnectState.set(sessionId, { ...base, exhausted: true, exhaustedEmitted: true }); + this.emit('remoteReconnectExhausted', { sessionId }); + } + } + // 'skip' → nothing to do. + } + } + + /** + * Tell the watcher a reattach attempt for `sessionId` finished. On success, + * reset the backoff so the session is healthy again; on failure, just clear + * the in-flight flag so the next due tick can retry under the existing + * backoff schedule. Called by the session owner after `respawnPane`. + */ + noteRemoteReconnect(sessionId: string, success: boolean): void { + if (success) { + this.reconnectState.set(sessionId, resetReconnectState()); + return; + } + const state = this.reconnectState.get(sessionId); + if (state) this.reconnectState.set(sessionId, { ...state, inFlight: false }); + } + + /** + * Exclude a session from auto-reconnect (intentional teardown). Adds it to the + * guard set and drops any backoff state so a closed/killed tab — especially a + * non-owned remote DETACH — is never auto-revived. Idempotent. + */ + guardRemoteReconnect(sessionId: string): void { + this.reconnectGuard.add(sessionId); + this.reconnectState.delete(sessionId); + } + + /** Clear all per-session reconnect + guard state (e.g. when a session is removed). */ + clearRemoteReconnectState(sessionId: string): void { + this.reconnectState.delete(sessionId); + this.reconnectGuard.delete(sessionId); + } + destroy(): void { this.stopStatsCollection(); this.stopMouseModeSync(); + this.stopRemoteReconnectWatcher(); + this.reconnectState.clear(); + this.reconnectGuard.clear(); } registerSession(session: MuxSession): void { diff --git a/src/tunnel-manager.ts b/src/tunnel-manager.ts index 083b11d2..9e10764e 100644 --- a/src/tunnel-manager.ts +++ b/src/tunnel-manager.ts @@ -43,6 +43,8 @@ interface QrTokenRecord { shortCode: string; // 6 chars base62 (for URL path) createdAt: number; // Date.now() consumed: boolean; // single-use flag + /** Multi-user: the user this token logs in when redeemed (absent = rotating global token). */ + username?: string; } /** Rejection-sampled base62 short code — no modulo bias */ @@ -378,23 +380,64 @@ export class TunnelManager extends EventEmitter { * Map.get() is hash-based — no timing side-channel from string comparison. */ consumeToken(shortCode: string): boolean { + return this.consumeTokenWithIdentity(shortCode).ok; + } + + /** + * Like consumeToken, but also returns the bound username for multi-user tokens + * (undefined for the rotating global token). Only the identity-less rotating + * token triggers an immediate re-rotation (desktop gets a fresh QR); per-user + * tokens are on-demand and self-expire. + */ + consumeTokenWithIdentity(shortCode: string): { ok: boolean; username?: string } { // Global rate limit (across all IPs) - if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return false; + if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return { ok: false }; this.qrAttemptCount++; const record = this.qrTokensByCode.get(shortCode); - if (!record) return false; - if (record.consumed) return false; + if (!record) return { ok: false }; + if (record.consumed) return { ok: false }; const now = Date.now(); - if (now - record.createdAt > QR_TOKEN_GRACE_MS) return false; + if (now - record.createdAt > QR_TOKEN_GRACE_MS) return { ok: false }; // Atomic consume (single-threaded JS = no race) record.consumed = true; - // Immediately rotate so desktop gets a fresh QR - this.rotateToken(); - this.emit('qrTokenRegenerated'); - return true; + const username = record.username; + if (!username) { + // Rotating global token — immediately rotate so desktop gets a fresh QR. + this.rotateToken(); + this.emit('qrTokenRegenerated'); + } else { + this.qrTokensByCode.delete(shortCode); + } + return { ok: true, username }; + } + + /** + * Multi-user: mint a single-use token bound to a specific user (on-demand, no + * rotation). Evicts expired/consumed tokens first. Returns the short code. + */ + mintUserToken(username: string): string { + const now = Date.now(); + for (const [code, rec] of this.qrTokensByCode) { + if (now - rec.createdAt > QR_TOKEN_GRACE_MS || rec.consumed) this.qrTokensByCode.delete(code); + } + const record: QrTokenRecord = { + token: randomBytes(32).toString('hex'), + shortCode: generateShortCode(), + createdAt: Date.now(), + consumed: false, + username, + }; + this.qrTokensByCode.set(record.shortCode, record); + return record.shortCode; + } + + /** Render a QR SVG for an arbitrary short code (used by per-user minting). */ + async getQrSvgForCode(tunnelUrl: string, code: string): Promise { + const QRCode = await import('qrcode'); + return QRCode.toString(`${tunnelUrl}/q/${code}`, { type: 'svg', margin: 2, width: 256 }); } /** Force-regenerate (manual revocation via API) */ diff --git a/src/types/api.ts b/src/types/api.ts index b7201022..4795caa7 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -37,6 +37,16 @@ export enum ApiErrorCode { RATE_LIMITED = 'RATE_LIMITED', /** Operation could not be completed (well-formed but unprocessable) */ OPERATION_FAILED = 'OPERATION_FAILED', + /** Authenticated but not permitted (e.g. non-admin hitting an admin route) */ + FORBIDDEN = 'FORBIDDEN', + /** User must change their password before any other action (multi-user) */ + PASSWORD_CHANGE_REQUIRED = 'PASSWORD_CHANGE_REQUIRED', + /** A user with this name already exists (multi-user) */ + USER_EXISTS = 'USER_EXISTS', + /** No user with this name (multi-user) */ + USER_NOT_FOUND = 'USER_NOT_FOUND', + /** Refusing to demote/disable/delete the last enabled admin (multi-user) */ + LAST_ADMIN = 'LAST_ADMIN', /** Internal server error */ INTERNAL_ERROR = 'INTERNAL_ERROR', } @@ -53,6 +63,11 @@ const ErrorMessages: Record = { [ApiErrorCode.ALREADY_EXISTS]: 'Resource already exists', [ApiErrorCode.RATE_LIMITED]: 'Too many requests', [ApiErrorCode.OPERATION_FAILED]: 'The operation failed', + [ApiErrorCode.FORBIDDEN]: 'You do not have permission to perform this action', + [ApiErrorCode.PASSWORD_CHANGE_REQUIRED]: 'You must change your password before continuing', + [ApiErrorCode.USER_EXISTS]: 'A user with that name already exists', + [ApiErrorCode.USER_NOT_FOUND]: 'No such user', + [ApiErrorCode.LAST_ADMIN]: 'Cannot remove the last enabled admin', [ApiErrorCode.INTERNAL_ERROR]: 'An internal error occurred', }; @@ -69,6 +84,11 @@ const ErrorStatus: Record = { [ApiErrorCode.CONFLICT]: 409, [ApiErrorCode.ALREADY_EXISTS]: 409, [ApiErrorCode.OPERATION_FAILED]: 422, + [ApiErrorCode.FORBIDDEN]: 403, + [ApiErrorCode.PASSWORD_CHANGE_REQUIRED]: 403, + [ApiErrorCode.USER_EXISTS]: 409, + [ApiErrorCode.USER_NOT_FOUND]: 404, + [ApiErrorCode.LAST_ADMIN]: 409, [ApiErrorCode.RATE_LIMITED]: 429, [ApiErrorCode.INTERNAL_ERROR]: 500, }; @@ -124,7 +144,7 @@ export interface CaseInfo { /** Whether CLAUDE.md exists */ hasClaudeMd?: boolean; /** Case storage/execution location */ - location?: 'local' | 'linked-local' | 'remote'; + location?: 'local' | 'linked-local' | 'remote' | 'docker'; /** Whether this is a linked local folder */ linked?: boolean; /** Remote case metadata for display and session creation */ @@ -134,6 +154,14 @@ export interface CaseInfo { username: string; path: string; }; + /** Docker case metadata for display and session creation */ + docker?: { + hostId: string; + container: string; + image?: string; + path: string; + network?: string; + }; } // ========== Error Handling Utilities ========== diff --git a/src/types/cron.ts b/src/types/cron.ts index 05bfdf13..9c05b8c4 100644 --- a/src/types/cron.ts +++ b/src/types/cron.ts @@ -36,6 +36,8 @@ export type ConcurrencyPolicy = 'warn_only' | 'skip_if_same_agent_running'; export interface CronJob { id: string; name: string; + /** Owning username in multi-user mode; the job launches as this user. Undefined in single-user. */ + owner?: string; /** Reuses Codeman's existing session modes; 'shell' covers Terminal/custom. */ agentType: SessionMode; workingDir: string; diff --git a/src/types/index.ts b/src/types/index.ts index 5a5b0911..706f1fa6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -69,3 +69,4 @@ export * from './orchestrator.js'; export * from './update.js'; export * from './workflow-run.js'; export * from './search.js'; +export * from './user.js'; diff --git a/src/types/session.ts b/src/types/session.ts index 33facf72..8305759c 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -9,7 +9,7 @@ * - SessionOutput — captured stdout/stderr/exitCode * - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error' * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend) - * - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools') + * - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools') * - SessionColor — visual differentiation color * - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession) * - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId) @@ -35,10 +35,11 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error'; /** * Claude CLI startup permission mode. * - `'dangerously-skip-permissions'`: Bypass all permission prompts (default) + * - `'auto'`: Anthropic's classifier-guarded low-prompt mode (`--permission-mode auto`) * - `'normal'`: Standard mode with permission prompts * - `'allowedTools'`: Only allow specific tools (requires allowedTools list) */ -export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools'; +export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools'; /** Session mode: which CLI backend a session runs */ export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'; @@ -84,6 +85,8 @@ export interface RemoteHost extends RemoteSshOptions { export interface RemoteCase { name: string; type: 'remote'; + /** Owning username in multi-user mode; absent = legacy/unassigned (admin-only). */ + owner?: string; hostId: string; remotePath: string; } @@ -96,6 +99,163 @@ export interface SessionRemote extends RemoteSshOptions { port?: number; remotePath: string; commands?: Partial>; + /** + * COD-105 — whether THIS Codeman created the remote tmux session. + * + * - `true` (default for COD-104 launched sessions): we own the remote session; + * an explicit "kill" may propagate a remote `tmux kill-session`. + * - `false` (discovered + attached an existing remote session another Codeman + * created): closing the local tab must DETACH only — we must NEVER issue a + * remote `kill-session`, or we'd nuke work the remote's own Codeman (or + * another instance) still relies on. See `killSession()` gate. + * + * Absent is treated as owned (legacy/COD-104 sessions persisted before this + * field existed were all launched by us). + */ + owned?: boolean; + /** + * COD-105 — for a NON-owned (discovered + attached) session, the EXISTING + * remote tmux session name to `attach -t` (e.g. `codeman-disco1`). It differs + * from this Codeman's deterministic `codeman-` name because the remote + * session was created elsewhere. Only meaningful when `owned === false`. + */ + remoteSessionName?: string; +} + +/** + * COD-105 — a `codeman-*` tmux session discovered on a remote host's + * `tmux -L codeman` socket (may have been created by the remote's own Codeman, + * another instance, or this one). Returned by `listRemoteCodemanSessions`. + */ +export interface RemoteSessionInfo { + /** tmux session name (always starts `codeman-`). */ + name: string; + /** Whether at least one client is currently attached to the remote session. */ + attached: boolean; + /** COD-106 — number of clients attached (tmux `session_attached`); >1 = shared. */ + attachedClients: number; + /** tmux `session_created` epoch seconds. */ + created: number; + /** Number of windows in the remote session. */ + windows: number; +} + +// ========== Docker cases (COD-Docker) ========== +// +// Docker mode is a LOCATION OVERLAY on cases (never a 6th SessionMode), the exact +// analog of the remote-SSH feature above: instead of a local tmux pane running +// `ssh host` into a durable remote tmux server, a local tmux pane runs +// `docker exec -it` into a durable in-container tmux server. The container is +// scoped to the CASE (not the session), so multiple sessions can `docker exec` +// into the same long-lived container. See `docs/docker-cases-plan.md`. + +/** Which CLI backends a Docker case can run (same set as remote). */ +export type DockerCommandMode = Extract; + +/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */ +export type DockerEngine = 'docker' | 'podman'; + +/** + * Container network mode. `host` and any inbound `-p` publish are deliberately + * unrepresentable (never in this union, never emitted by the flag builder). + * - `bridge`: own netns, NAT egress, no inbound (default — every API CLI needs egress) + * - `none`: fully offline sandbox (breaks API CLIs; reserved for `shell`) + * - `custom`: a user-defined bridge `codeman-net-` (future egress-allowlist chokepoint) + */ +export type DockerNetworkMode = 'bridge' | 'none' | 'custom'; + +/** Per-container resource caps. Advisory under non-delegated rootless (see `capsEnforced`). */ +export interface DockerResourceLimits { + /** e.g. '4g' -> --memory 4g --memory-swap 4g (swap==memory: a real OOM cap) */ + memory?: string; + /** e.g. '2' -> --cpus 2 */ + cpus?: string; + /** e.g. 512 -> --pids-limit 512 (fork-bomb guard) */ + pidsLimit?: number; + /** e.g. '4096:8192' -> --ulimit nofile=4096:8192 */ + nofile?: string; + /** e.g. '256m' -> --shm-size (only when a tool needs /dev/shm) */ + shmSize?: string; +} + +/** A reusable Docker engine/image/network/resource profile (mirror of RemoteHost). */ +export interface DockerHost { + id: string; + label: string; + /** Engine; when absent the availability probe resolves it (docker, else podman). */ + engine?: DockerEngine; + /** Base image ref (built locally by scripts/build-agent-image.mjs, e.g. codeman/agent:base). */ + image: string; + /** Advanced: remote daemon (-H ssh://user@host or a DOCKER_HOST value). */ + daemonHost?: string; + /** Advanced: docker `--context` name. */ + context?: string; + /** Network mode (default 'bridge'). */ + network?: DockerNetworkMode; + /** Custom bridge name when network === 'custom'. */ + networkName?: string; + resources?: DockerResourceLimits; + /** GPU allocation, e.g. 'all' / '1' / 'device=0,1' -> `--gpus ` (needs the NVIDIA container toolkit). */ + gpus?: string; + /** true (default) = convenient: bind-mount host cred dirs RW. false = sealed (blocks full-image export). */ + mountCredentials?: boolean; + /** true (default) = wire in-container hooks (host-gateway callback + workspace scaffold). */ + hooksEnabled?: boolean; + /** true (default) = a relaunch resumes the last conversation from the bind-mounted transcript. */ + resumeOnStart?: boolean; + /** Per-mode command overrides (mirror RemoteHost.commands). */ + commands?: Partial>; + /** Escape hatch: extra `docker create` args (validated like extraSshOptions). */ + extraCreateArgs?: string[]; + /** Escape hatch: extra `docker exec` args. */ + extraExecArgs?: string[]; +} + +/** A case linked to a Docker container (mirror of RemoteCase). */ +export interface DockerCase { + name: string; + type: 'docker'; + /** Owning username in multi-user mode; absent = legacy/unassigned (admin-only). */ + owner?: string; + hostId: string; + /** Absolute HOST directory: the bind-mount source AND Session.workingDir (real host bytes). */ + hostWorkspacePath: string; + /** Container path (default = hostWorkspacePath: mirror -> transcript projHash correlates). */ + containerWorkdir?: string; + /** Container name (default codeman-case-). */ + container?: string; + /** Last captured Claude conversation id, replayed via --resume on a fresh launch. */ + lastClaudeSessionId?: string; +} + +/** + * Flattened Docker execution metadata carried on a live session (mirror of + * SessionRemote). Round-trips through MuxSession/SessionState/mux-sessions.json. + */ +export interface SessionDocker { + hostId: string; + label: string; + engine: DockerEngine; + image: string; + /** Per-CASE container name (shared by all sessions of the case). */ + containerName: string; + hostWorkspacePath: string; + containerWorkdir: string; + network: DockerNetworkMode; + networkName?: string; + resources?: DockerResourceLimits; + /** GPU allocation ('all' / '1' / 'device=0,1'). */ + gpus?: string; + mountCredentials: boolean; + hooksEnabled: boolean; + resumeOnStart: boolean; + daemonHost?: string; + context?: string; + commands?: Partial>; + extraCreateArgs?: string[]; + extraExecArgs?: string[]; + /** Stable hash of the drift-relevant create args (recreate-on-drift detection). */ + configHash?: string; } /** @@ -217,6 +377,10 @@ export interface SessionState { workingDir: string; /** Remote execution metadata, present when this session runs over SSH through local tmux */ remote?: SessionRemote; + /** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */ + docker?: SessionDocker; + /** Owning username in multi-user mode; undefined in single-user (ignored when the flag is off) */ + owner?: string; /** ID of currently assigned task, null if none */ currentTaskId: string | null; /** Timestamp when session was created */ diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 00000000..904cbb9f --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,64 @@ +/** + * @fileoverview Multi-user mode types (opt-in `--multiuser`). + * + * Users live in `~/.codeman/users.json` (via `dataPath`, mode 0600). Each record + * carries a scrypt password hash with its own parameters so hashing cost can be + * raised later and old records rehashed on next login. `AuthUser` is the + * request-scoped identity decorated onto Fastify requests; in SINGLE-user mode a + * synthetic `{ username: 'admin', role: 'admin' }` is used so downstream code has + * one code path. See `src/user-store.ts` and `docs/multi-user-plan.md`. + */ + +export type UserRole = 'admin' | 'user'; + +/** Per-record scrypt parameters + salt/hash (all hex). */ +export interface PasswordHash { + algo: 'scrypt'; + N: number; + r: number; + p: number; + salt: string; + hash: string; +} + +export interface UserRecord { + /** Canonical lowercase slug; also the user's folder name under USER_SPACES_DIR. */ + username: string; + role: UserRole; + password: PasswordHash; + /** Disabled accounts fail auth closed but keep their space on disk. */ + disabled?: boolean; + /** Set by an admin reset; gates all API access until the user changes it. */ + mustChangePassword?: boolean; + /** + * Permission-mode grant (section 6.3). When false (the default for new users), + * the user's Claude sessions are forced to `--permission-mode auto`, shell mode + * and cron `launchCommand` are refused, and other CLIs' bypass flags are dropped. + */ + canBypassPermissions?: boolean; + createdAt: number; + lastLoginAt?: number; +} + +/** On-disk shape of `users.json`. */ +export interface UsersFile { + version: 1; + users: UserRecord[]; +} + +/** Request-scoped identity (decorated as `req.authUser`). */ +export interface AuthUser { + username: string; + role: UserRole; +} + +/** Admin-facing projection of a user: never carries the password hash. */ +export interface PublicUser { + username: string; + role: UserRole; + disabled: boolean; + mustChangePassword: boolean; + canBypassPermissions: boolean; + createdAt: number; + lastLoginAt?: number; +} diff --git a/src/user-store.ts b/src/user-store.ts new file mode 100644 index 00000000..869a08c4 --- /dev/null +++ b/src/user-store.ts @@ -0,0 +1,488 @@ +/** + * @fileoverview Multi-user store: `~/.codeman/users.json` (via `dataPath`, 0600). + * + * Mirrors the storage-module pattern of `remote-hosts.ts` / `docker-hosts.ts`, but + * because it holds password hashes it writes atomically (tmp + rename) at mode + * 0600 and keeps only a SHORT in-process cache so the CLI (`codeman users …`) can + * edit the file while the server runs and have changes picked up within the TTL. + * + * Pure, IO-free helpers (`isValidUsername`, `hashPassword`, `verifyPasswordHash`, + * `needsRehash`, `resolveClaudeModeForUser`, the last-admin invariants) are split + * out so they are unit-testable without a server. Hashing is `scrypt` from + * `node:crypto` (no new deps), compared via `timingSafeEqual`; parameters are + * stored per record so cost can be raised later and old records rehashed on their + * next successful login. + * + * See `docs/multi-user-plan.md` sections 4.1, 5, 6.3. + */ + +import { existsSync, mkdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { isAbsolute, join, relative } from 'node:path'; +import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto'; +import { promisify } from 'node:util'; +import { dataPath, getDataDir } from './config/instance.js'; +import { getUserSpacesDir, isMultiUserMode, maxUsers } from './config/multiuser.js'; +import type { AuthUser, ClaudeMode, PasswordHash, PublicUser, UserRecord, UserRole, UsersFile } from './types.js'; + +const scrypt = promisify(scryptCb) as ( + password: string | Buffer, + salt: string | Buffer, + keylen: number, + options: { N: number; r: number; p: number; maxmem: number } +) => Promise; + +const USERS_FILE = 'users.json'; +const CACHE_TTL_MS = 1000; +const KEYLEN = 64; +const SALT_BYTES = 32; +/** Generous ceiling so raising N/r later does not trip scrypt's memory guard. */ +const SCRYPT_MAXMEM = 256 * 1024 * 1024; + +/** Current hashing parameters. Stored per record; raise these to increase cost. */ +export const DEFAULT_SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 } as const; + +/** Username: lowercase, first char alphanumeric, 2-32 chars total. Becomes a folder name. */ +const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{1,31}$/; + +/** Typed error whose `.code` maps to an API errorCode at the route layer. */ +export class UserStoreError extends Error { + constructor( + message: string, + public readonly code: 'USER_EXISTS' | 'USER_NOT_FOUND' | 'LAST_ADMIN' | 'INVALID_INPUT' + ) { + super(message); + this.name = 'UserStoreError'; + } +} + +// ─────────────────────────────── pure helpers ─────────────────────────────── + +export function normalizeUsername(name: string): string { + return String(name ?? '') + .trim() + .toLowerCase(); +} + +export function isValidUsername(name: string): boolean { + return USERNAME_RE.test(normalizeUsername(name)); +} + +/** Hash a password with the given (or current) scrypt params + a fresh random salt. */ +export async function hashPassword( + password: string, + params: { N: number; r: number; p: number } = DEFAULT_SCRYPT_PARAMS +): Promise { + const salt = randomBytes(SALT_BYTES); + const derived = await scrypt(password, salt, KEYLEN, { ...params, maxmem: SCRYPT_MAXMEM }); + return { + algo: 'scrypt', + N: params.N, + r: params.r, + p: params.p, + salt: salt.toString('hex'), + hash: derived.toString('hex'), + }; +} + +/** Constant-time verify of a password against a stored hash record. Never throws. */ +export async function verifyPasswordHash(password: string, record: PasswordHash): Promise { + if (!record || record.algo !== 'scrypt') return false; + let salt: Buffer; + let expected: Buffer; + try { + salt = Buffer.from(record.salt, 'hex'); + expected = Buffer.from(record.hash, 'hex'); + } catch { + return false; + } + if (expected.length === 0) return false; + let derived: Buffer; + try { + derived = await scrypt(password, salt, expected.length, { + N: record.N, + r: record.r, + p: record.p, + maxmem: SCRYPT_MAXMEM, + }); + } catch { + return false; + } + if (derived.length !== expected.length) return false; + return timingSafeEqual(derived, expected); +} + +/** True when a stored hash uses weaker params than current and should be rehashed. */ +export function needsRehash(record: PasswordHash, params = DEFAULT_SCRYPT_PARAMS): boolean { + return record.algo !== 'scrypt' || record.N !== params.N || record.r !== params.r || record.p !== params.p; +} + +/** URL-safe one-time password (16 chars) for admin create/reset flows. */ +export function generateOneTimePassword(): string { + return randomBytes(12).toString('base64url'); +} + +export function toPublicUser(u: UserRecord): PublicUser { + return { + username: u.username, + role: u.role, + disabled: !!u.disabled, + mustChangePassword: !!u.mustChangePassword, + canBypassPermissions: !!u.canBypassPermissions, + createdAt: u.createdAt, + lastLoginAt: u.lastLoginAt, + }; +} + +export function countEnabledAdmins(users: UserRecord[]): number { + return users.filter((u) => u.role === 'admin' && !u.disabled).length; +} + +/** + * Section 6.3: resolve the effective Claude permission mode for a user. Admins and + * granted users get the global mode as-is; a non-granted regular user whose mode + * would be `dangerously-skip-permissions` is silently downgraded to `auto` (all + * other modes are already <= auto and pass through). Pure. + */ +export function resolveClaudeModeForUser( + globalMode: ClaudeMode | undefined, + grant: { role: UserRole; canBypassPermissions?: boolean } +): ClaudeMode { + const mode: ClaudeMode = globalMode ?? 'dangerously-skip-permissions'; + if (grant.role === 'admin' || grant.canBypassPermissions) return mode; + return mode === 'dangerously-skip-permissions' ? 'auto' : mode; +} + +/** + * Section 6.3: whether a user may run arbitrary commands as the host account + * (shell-mode sessions, cron `launchCommand`, other CLIs' bypass flags). Same + * one-bit grant as bypass. Admins always may. + */ +export function canRunPrivilegedCommands(grant: { role: UserRole; canBypassPermissions?: boolean }): boolean { + return grant.role === 'admin' || !!grant.canBypassPermissions; +} + +// ─────────────────────────────── IO layer ─────────────────────────────── + +let cache: { users: UserRecord[]; ts: number } | null = null; + +/** Drop the in-process cache (called after every write; exported for tests). */ +export function invalidateUsersCache(): void { + cache = null; +} + +export async function readUsers(force = false): Promise { + const now = Date.now(); + if (!force && cache && now - cache.ts < CACHE_TTL_MS) return cache.users; + let raw: string; + try { + raw = await fs.readFile(dataPath(USERS_FILE), 'utf-8'); + } catch (err) { + // ENOENT is the ONLY legitimately-empty store (first boot). Any other read + // error (EIO/EACCES/EMFILE/EBUSY) is a transient/permission failure, NOT an + // empty store — do NOT cache [] and do NOT let it look empty, or a following + // createUser/bootstrap would overwrite users.json and destroy every account. + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + cache = { users: [], ts: now }; + return []; + } + throw err; + } + // A present-but-corrupt file (invalid JSON) must also fail loud rather than + // read as empty, so mutators/bootstrap abort instead of clobbering it. + const parsed = JSON.parse(raw) as Partial; + const users = Array.isArray(parsed.users) ? parsed.users : []; + cache = { users, ts: now }; + return users; +} + +async function writeUsers(users: UserRecord[]): Promise { + const dir = getDataDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const finalPath = dataPath(USERS_FILE); + // Unique per-writer tmp name (pid + random) so the CLI (`codeman users …`) and + // the live server — designed to write this file concurrently across processes — + // never share a single `users.json.tmp` inode and tear each other's payload. + // Matches the state-store.ts / self-update.ts convention. + const tmpPath = `${finalPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`; + const payload: UsersFile = { version: 1, users }; + try { + await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { mode: 0o600 }); + await fs.chmod(tmpPath, 0o600).catch(() => {}); + await fs.rename(tmpPath, finalPath); + } catch (err) { + await fs.unlink(tmpPath).catch(() => {}); + throw err; + } + cache = { users, ts: Date.now() }; +} + +/** + * Serialize every read-modify-write on users.json. Without this a fire-and-forget + * touchLastLogin (fired on each Basic auth) can interleave with a route's + * create/update and clobber records, since both do readUsers(true) → mutate → + * writeUsers against a single shared file + tmp path. + */ +let mutateChain: Promise = Promise.resolve(); +function withUsersLock(fn: () => Promise): Promise { + const run = mutateChain.then(fn, fn); + mutateChain = run.then( + () => undefined, + () => undefined + ); + return run; +} + +export async function hasUsers(): Promise { + return (await readUsers()).length > 0; +} + +// A precomputed dummy hash so an unknown/disabled user costs the same scrypt work +// as a real verify (defeats username-enumeration by timing). Created once, lazily. +let dummyHashPromise: Promise | null = null; +function getDummyHash(): Promise { + if (!dummyHashPromise) dummyHashPromise = hashPassword('codeman-timing-equalization-placeholder'); + return dummyHashPromise; +} + +/** + * Verify a username/password against the store. Returns the record (plus whether it + * should be rehashed) on success, or null for wrong password / unknown / disabled + * user. Runs a dummy scrypt on the miss path so timing does not reveal which users + * exist. Never writes (the caller decides when to persist lastLogin / rehash). + */ +export async function verifyPassword( + username: string, + password: string +): Promise<{ user: UserRecord; needsRehash: boolean } | null> { + const user = await findUser(username); + if (!user || user.disabled) { + await verifyPasswordHash(password, await getDummyHash()); + return null; + } + const ok = await verifyPasswordHash(password, user.password); + if (!ok) return null; + return { user, needsRehash: needsRehash(user.password) }; +} + +export async function findUser(username: string): Promise { + const norm = normalizeUsername(username); + if (!norm) return undefined; + const users = await readUsers(); + return users.find((u) => u.username === norm); +} + +export interface CreateUserOptions { + username: string; + role: UserRole; + password: string; + mustChangePassword?: boolean; + canBypassPermissions?: boolean; +} + +export async function createUser(opts: CreateUserOptions): Promise { + const username = normalizeUsername(opts.username); + if (!isValidUsername(username)) { + throw new UserStoreError( + 'Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])', + 'INVALID_INPUT' + ); + } + if (opts.role !== 'admin' && opts.role !== 'user') { + throw new UserStoreError('Role must be "admin" or "user"', 'INVALID_INPUT'); + } + if (!opts.password || opts.password.length < 8) { + throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT'); + } + return withUsersLock(async () => { + const users = await readUsers(true); + if (users.some((u) => u.username === username)) { + throw new UserStoreError(`User "${username}" already exists`, 'USER_EXISTS'); + } + if (users.length >= maxUsers()) { + throw new UserStoreError(`Maximum number of users (${maxUsers()}) reached`, 'INVALID_INPUT'); + } + const record: UserRecord = { + username, + role: opts.role, + password: await hashPassword(opts.password), + disabled: false, + mustChangePassword: !!opts.mustChangePassword, + canBypassPermissions: !!opts.canBypassPermissions, + createdAt: Date.now(), + }; + users.push(record); + await writeUsers(users); + return record; + }); +} + +/** Set a user's password. `mustChangePassword` is left unchanged unless specified. */ +export async function setPassword( + username: string, + password: string, + opts: { mustChangePassword?: boolean } = {} +): Promise { + if (!password || password.length < 8) { + throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT'); + } + const norm = normalizeUsername(username); + return withUsersLock(async () => { + const users = await readUsers(true); + const record = users.find((u) => u.username === norm); + if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); + record.password = await hashPassword(password); + if (opts.mustChangePassword !== undefined) record.mustChangePassword = opts.mustChangePassword; + await writeUsers(users); + return record; + }); +} + +export interface UpdateUserPatch { + role?: UserRole; + disabled?: boolean; + canBypassPermissions?: boolean; + mustChangePassword?: boolean; +} + +export async function updateUser(username: string, patch: UpdateUserPatch): Promise { + const norm = normalizeUsername(username); + return withUsersLock(async () => { + const users = await readUsers(true); + const record = users.find((u) => u.username === norm); + if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); + + // Guard the last-enabled-admin invariant against demote/disable. + const before = countEnabledAdmins(users); + const projected: UserRecord = { + ...record, + role: patch.role ?? record.role, + disabled: patch.disabled ?? record.disabled, + }; + const after = countEnabledAdmins(users.map((u) => (u.username === norm ? projected : u))); + if (before > 0 && after === 0) { + throw new UserStoreError('Cannot demote or disable the last enabled admin', 'LAST_ADMIN'); + } + + if (patch.role !== undefined) record.role = patch.role; + if (patch.disabled !== undefined) record.disabled = patch.disabled; + if (patch.canBypassPermissions !== undefined) record.canBypassPermissions = patch.canBypassPermissions; + if (patch.mustChangePassword !== undefined) record.mustChangePassword = patch.mustChangePassword; + await writeUsers(users); + return record; + }); +} + +/** + * Record a successful login timestamp. Best-effort + throttled: skips the write if + * the last login was within the last minute (Basic clients re-send credentials on + * every request, so this fires often — the throttle keeps disk churn bounded). + */ +export async function touchLastLogin(username: string): Promise { + const norm = normalizeUsername(username); + try { + await withUsersLock(async () => { + const users = await readUsers(true); + const record = users.find((u) => u.username === norm); + if (!record) return; + if (record.lastLoginAt && Date.now() - record.lastLoginAt < 60_000) return; + record.lastLoginAt = Date.now(); + await writeUsers(users); + }); + } catch { + /* best-effort */ + } +} + +export async function deleteUser(username: string): Promise { + const norm = normalizeUsername(username); + await withUsersLock(async () => { + const users = await readUsers(true); + const record = users.find((u) => u.username === norm); + if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); + const before = countEnabledAdmins(users); + const remaining = users.filter((u) => u.username !== norm); + const after = countEnabledAdmins(remaining); + if (before > 0 && after === 0) { + throw new UserStoreError('Cannot delete the last enabled admin', 'LAST_ADMIN'); + } + await writeUsers(remaining); + }); +} + +/** + * First-boot bootstrap: in multi-user mode with no users yet, create the initial + * admin from `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` if both are set. Returns a + * status the caller (server start / CLI) uses to decide whether to refuse boot. + */ +export async function bootstrapInitialAdmin(): Promise<{ + status: 'created' | 'exists' | 'missing-env'; + username?: string; +}> { + if (await hasUsers()) return { status: 'exists' }; + const username = process.env.CODEMAN_USERNAME; + const password = process.env.CODEMAN_PASSWORD; + if (!username || !password) return { status: 'missing-env' }; + const created = await createUser({ username, role: 'admin', password }); + return { status: 'created', username: created.username }; +} + +/** + * Delete a user's on-disk space (`/`) with the section 8 + * guard rails: the top-level dir must not be a symlink, and its realpath must + * resolve strictly inside USER_SPACES_DIR (so a symlinked or `..`-escaping target + * can never be used to rm an arbitrary tree). No-op if the space does not exist. + */ +export async function deleteUserSpace(username: string): Promise { + const norm = normalizeUsername(username); + if (!isValidUsername(norm)) throw new UserStoreError('Invalid username', 'INVALID_INPUT'); + const root = getUserSpacesDir(); + const target = join(root, norm); + let lst; + try { + lst = await fs.lstat(target); + } catch { + return; // nothing to delete + } + if (lst.isSymbolicLink()) { + throw new UserStoreError('Refusing to delete a symlinked user space', 'INVALID_INPUT'); + } + const realRoot = await fs.realpath(root).catch(() => root); + const realTarget = await fs.realpath(target); + const rel = relative(realRoot, realTarget); + if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) { + throw new UserStoreError('User space escapes USER_SPACES_DIR', 'INVALID_INPUT'); + } + await fs.rm(realTarget, { recursive: true, force: true }); +} + +/** The synthetic admin used in single-user mode so downstream has one code path. */ +export const SYNTHETIC_ADMIN: AuthUser = { username: 'admin', role: 'admin' }; + +/** + * Whether a username may run arbitrary commands (shell mode, cron launchCommand, + * other CLIs' bypass). Single-user or an unset owner: allowed. In multi-user a + * MISSING user (e.g. deleted) fails closed (non-privileged). Used at cron fire time. + */ +export async function canUsernameRunPrivilegedCommands(username: string | undefined): Promise { + if (!isMultiUserMode() || !username) return true; + const user = await findUser(username); + return canRunPrivilegedCommands(user ?? { role: 'user' }); +} + +/** + * Resolve the effective Claude mode for a username by looking up the grant. In + * single-user mode (or for an unknown owner) the global mode passes through. + */ +export async function resolveClaudeModeForUsername( + globalMode: ClaudeMode | undefined, + username: string | undefined +): Promise { + const fallback: ClaudeMode = globalMode ?? 'dangerously-skip-permissions'; + if (!isMultiUserMode() || !username) return fallback; + // Fail closed: an unknown/deleted owner in multi-user mode is treated as a + // non-granted regular user so a stale-owned spawn (e.g. an orphaned cron job) + // is downgraded to `auto` rather than inheriting the global bypass. + const user = await findUser(username); + return resolveClaudeModeForUser(globalMode, user ?? { role: 'user' }); +} diff --git a/src/web/admin-audit.ts b/src/web/admin-audit.ts new file mode 100644 index 00000000..857e4095 --- /dev/null +++ b/src/web/admin-audit.ts @@ -0,0 +1,28 @@ +/** + * @fileoverview Append-only admin audit log (~/.codeman/admin-audit.jsonl). + * + * Every user-management action (create/patch/reset/delete/logout/assign) writes one + * JSON line: timestamp, acting admin, action, target, request IP. Same idiom as + * session-lifecycle.jsonl. Best-effort: a write failure never blocks the action. + */ + +import fs from 'node:fs/promises'; +import { dataPath } from '../config/instance.js'; + +export interface AdminAuditEntry { + ts: number; + admin: string; + action: string; + target?: string; + ip?: string; + detail?: Record; +} + +export async function appendAdminAudit(entry: Omit): Promise { + try { + const line = JSON.stringify({ ts: Date.now(), ...entry }) + '\n'; + await fs.appendFile(dataPath('admin-audit.jsonl'), line, { mode: 0o600 }); + } catch { + /* best-effort audit; never block the action */ + } +} diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index 5387a280..41b9b160 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -8,7 +8,7 @@ * - CORS (localhost only) */ -import type { FastifyInstance, FastifyReply } from 'fastify'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { randomBytes, timingSafeEqual } from 'node:crypto'; import { StaleExpirationMap } from '../../utils/index.js'; import type { AuthSessionRecord } from '../ports/auth-port.js'; @@ -20,6 +20,17 @@ import { AUTH_FAILURE_WINDOW_MS, } from '../../config/auth-config.js'; import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js'; +import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js'; + +// Request-scoped identity (multi-user). Single-user leaves it undefined and the +// ownership helpers default to a synthetic admin (see route-helpers). +declare module 'fastify' { + interface FastifyRequest { + authUser?: AuthUser; + } +} // Auth session cookie name export const AUTH_COOKIE_NAME = 'codeman_session'; @@ -30,6 +41,83 @@ interface AuthState { authFailures: StaleExpirationMap | null; qrAuthFailures: StaleExpirationMap | null; hookSecretFailures: StaleExpirationMap | null; + /** Per-username Basic-auth failure bucket (multi-user only). */ + userFailures: StaleExpirationMap | null; +} + +/** Rate-limit response for a client that exceeded the failure cap. */ +function sendAuthRateLimit(reply: FastifyReply, failures: StaleExpirationMap, key: string): void { + const remainingMs = failures.getRemainingTtl(key) ?? AUTH_FAILURE_WINDOW_MS; + const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000)); + reply.header('Retry-After', String(retryAfterSeconds)); + reply.code(429).send('Too Many Requests — try again later'); +} + +/** Parse a `Basic base64(user:pass)` header into its parts, or null if malformed. */ +function parseBasicAuth(header?: string): { username: string; password: string } | null { + if (!header || !header.startsWith('Basic ')) return null; + try { + const decoded = Buffer.from(header.slice(6), 'base64').toString('utf-8'); + const idx = decoded.indexOf(':'); + if (idx < 0) return null; + return { username: decoded.slice(0, idx), password: decoded.slice(idx + 1) }; + } catch { + return null; + } +} + +/** + * The `/api/hook-event` + `/api/status-telemetry` localhost bypass, shared by the + * single-user and multi-user auth hooks so the security-critical logic has ONE + * source of truth. Returns: + * - 'bypass' : loopback + valid hook secret; the caller should allow the request + * - 'rejected' : a reply was already sent (wrong secret rate-limited / 401) + * - 'continue' : not a hook request (or non-loopback); fall through to normal auth + * + * COD-91: the shared hook secret is required UNCONDITIONALLY on the loopback bypass + * (a user's own loopback reverse proxy is indistinguishable from a real local hook). + */ +function checkHookSecretBypass( + req: FastifyRequest, + reply: FastifyReply, + hookSecretFailures: StaleExpirationMap +): 'bypass' | 'rejected' | 'continue' { + if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') { + const ip = req.ip; + const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; + if (isLoopback) { + const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? ''); + const expected = Buffer.from(getHookSecret()); + if (presented.length === expected.length && timingSafeEqual(presented, expected)) { + return 'bypass'; + } + const hookIp = req.ip; + const hookFailures = hookSecretFailures.get(hookIp) ?? 0; + if (hookFailures >= AUTH_FAILURE_MAX) { + sendAuthRateLimit(reply, hookSecretFailures, hookIp); + return 'rejected'; + } + hookSecretFailures.set(hookIp, hookFailures + 1); + reply.code(401).send('Unauthorized: hook secret required'); + return 'rejected'; + } + // Non-localhost hook requests fall through to normal auth + } + return 'continue'; +} + +/** + * Requests that a `mustChangePassword` user may still reach: the identity probe, + * the password-change endpoint, and any non-API path (static assets / index.html, + * so the browser can load the app and render the change-password modal). + */ +function isPasswordChangeExempt(req: FastifyRequest): boolean { + const url = (req.url ?? '').split('?')[0]; + if (url === '/api/me' || url === '/api/me/password') return true; + // Security: the WebSocket terminal (/ws/...) is a functional channel, not a static + // asset, so it must NOT be exempt, or a locked user keeps a working terminal. + if (url.startsWith('/ws/')) return false; + return !url.startsWith('/api/'); } /** @@ -47,13 +135,20 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au authFailures: null, qrAuthFailures: null, hookSecretFailures: null, + userFailures: null, }; - const authPassword = process.env.CODEMAN_PASSWORD; - if (!authPassword) return state; + // Always declare req.authUser so downstream reads are safe (single-user leaves it + // undefined; the ownership helpers then default to a synthetic admin). + if (!app.hasRequestDecorator('authUser')) app.decorateRequest('authUser', undefined); - const authUsername = process.env.CODEMAN_USERNAME || 'admin'; - const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64'); + const multiUser = isMultiUserMode(); + const authPassword = process.env.CODEMAN_PASSWORD; + + // No auth at all: single-user with no password (byte-identical to legacy). In + // multi-user mode auth is ALWAYS active (users authenticate individually), even + // without CODEMAN_PASSWORD. + if (!multiUser && !authPassword) return state; // Session token store — active sessions extend TTL on access state.authSessions = new StaleExpirationMap({ @@ -87,57 +182,28 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au const authFailures = state.authFailures; const hookSecretFailures = state.hookSecretFailures; - function sendAuthRateLimit( - reply: FastifyReply, - clientIp: string, - failures: StaleExpirationMap = authFailures - ): void { - const remainingMs = failures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS; - const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000)); - reply.header('Retry-After', String(retryAfterSeconds)); - reply.code(429).send('Too Many Requests — try again later'); + if (multiUser) { + // Per-username failure bucket: a botnet can't brute-force one account across + // many IPs, and one user behind a NAT can't lock out everyone else. + state.userFailures = new StaleExpirationMap({ + ttlMs: AUTH_FAILURE_WINDOW_MS, + refreshOnGet: false, + }); + registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures); + return state; } + // ── Single-user Basic Auth (unchanged behavior; CODEMAN_PASSWORD required) ── + const authUsername = process.env.CODEMAN_USERNAME || 'admin'; + const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64'); + app.addHook('onRequest', (req, reply, done) => { - // Hook events + statusline telemetry come from local Claude Code (curl from - // localhost) — no Basic-Auth credentials available. Validated downstream by - // HookEventSchema / StatusTelemetrySchema. Same loopback+hook-secret gate. - // - // COD-54: the bare localhost bypass is unsafe while a tunnel is running, because - // `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the - // loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and - // would pass. COD-91: require the shared hook secret on the loopback bypass - // UNCONDITIONALLY (not just while the managed tunnel is up). Codeman can't detect - // a user's own loopback reverse proxy (their own `cloudflared --url`, `tailscale - // serve`, nginx → 127.0.0.1), so tunnel-gating left that path with the unsafe plain - // bypass. Managed-session hooks always present the secret (X-Codeman-Hook-Secret, - // from $CODEMAN_HOOK_SECRET_FILE — generated for every instance), so requiring it - // always closes the gap without breaking the legitimate hook channel. - if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') { - const ip = req.ip; - const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; - if (isLoopback) { - // Always require the shared secret (constant-time compare). - const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? ''); - const expected = Buffer.from(getHookSecret()); - if (presented.length === expected.length && timingSafeEqual(presented, expected)) { - done(); - return; - } - // Wrong/absent secret — rate-limit per IP in the DEDICATED hook bucket - // (never authFailures, which would lock out the login path). - const hookIp = req.ip; - const hookFailures = hookSecretFailures.get(hookIp) ?? 0; - if (hookFailures >= AUTH_FAILURE_MAX) { - sendAuthRateLimit(reply, hookIp, hookSecretFailures); - return; - } - hookSecretFailures.set(hookIp, hookFailures + 1); - reply.code(401).send('Unauthorized: hook secret required'); - return; - } - // Non-localhost hook requests fall through to normal auth + const bypass = checkHookSecretBypass(req, reply, hookSecretFailures); + if (bypass === 'bypass') { + done(); + return; } + if (bypass === 'rejected') return; // QR auth path — handled by the route itself (token validation + rate limiting) if (req.url?.startsWith('/q/')) { @@ -153,10 +219,6 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au if (sessionToken && authSessions.get(sessionToken) !== undefined) { // Sliding cookie: re-issue on every authenticated request so the browser // cookie lifetime tracks the server-side sliding TTL (refreshOnGet above). - // Without this the cookie has a fixed lifetime from login; the browser - // drops it mid-use, the next request arrives cookie-less and falls through - // to Basic Auth — popping the native username/password dialog, which reads - // as a random logout while actively working. reply.setCookie(AUTH_COOKIE_NAME, sessionToken, { httpOnly: true, secure: https, @@ -206,7 +268,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au // Rate limit only requests that failed to authenticate on this attempt. const failures = authFailures.get(clientIp) ?? 0; if (failures >= AUTH_FAILURE_MAX) { - sendAuthRateLimit(reply, clientIp); + sendAuthRateLimit(reply, authFailures, clientIp); return; } @@ -220,6 +282,164 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au return state; } +/** + * Multi-user auth hook (async, because password verification runs scrypt). Verifies + * `username:password` against the user store, mints an identity-carrying cookie, + * decorates `req.authUser`, enforces the per-IP + per-username rate limits, and the + * `mustChangePassword` lockbox. The single-user hook above is left untouched. + */ +function registerMultiUserAuthHook( + app: FastifyInstance, + https: boolean, + authSessions: StaleExpirationMap, + authFailures: StaleExpirationMap, + hookSecretFailures: StaleExpirationMap, + userFailures: StaleExpirationMap +): void { + const setSessionCookie = (reply: FastifyReply, token: string) => + reply.setCookie(AUTH_COOKIE_NAME, token, { + httpOnly: true, + secure: https, + sameSite: 'lax', + maxAge: AUTH_SESSION_TTL_MS / 1000, + path: '/', + }); + + // Evict the oldest cookie session of the SAME user first (so one user logging in + // 100 times cannot flush everyone else's sessions), falling back to global-oldest. + const evictForCapacity = (username: string) => { + let userKey: string | undefined; + let userTs = Infinity; + let globalKey: string | undefined; + let globalTs = Infinity; + for (const [k, v] of authSessions) { + if (v.createdAt < globalTs) { + globalTs = v.createdAt; + globalKey = k; + } + if (v.username === username && v.createdAt < userTs) { + userTs = v.createdAt; + userKey = k; + } + } + const key = userKey ?? globalKey; + if (key !== undefined) authSessions.delete(key); + }; + + const enforcePasswordChange = (req: FastifyRequest, reply: FastifyReply, mustChange: boolean): boolean => { + if (mustChange && !isPasswordChangeExempt(req)) { + reply.code(403).send(createErrorResponse(ApiErrorCode.PASSWORD_CHANGE_REQUIRED)); + return true; + } + return false; + }; + + app.addHook('onRequest', async (req, reply) => { + const bypass = checkHookSecretBypass(req, reply, hookSecretFailures); + if (bypass === 'bypass' || bypass === 'rejected') return; + + // QR redemption path — handled by the route itself. + if (req.url?.startsWith('/q/')) return; + + const clientIp = req.ip; + + // 1. Cookie session (carries identity + mustChangePassword snapshot). + const sessionToken = req.cookies[AUTH_COOKIE_NAME]; + const record = sessionToken ? authSessions.get(sessionToken) : undefined; + if (record && record.username) { + // Security: re-validate the cookie identity against the store on every request so + // an out-of-band mutation the in-memory map can't see (the `codeman users` CLI, + // a separate process, deleting/disabling/demoting a user) takes effect promptly + // instead of riding the 24h cookie. findUser is cached ~1s, so this is cheap. + let live: Awaited>; + try { + live = await findUser(record.username); + } catch { + // The store is transiently unreadable/corrupt (readUsers throws on a non-ENOENT + // read, #23). Fall back to the cookie's snapshot for THIS request rather than + // 500-ing an already-authenticated client (pre-#24 behaviour); a persistently + // corrupt store still fails all WRITES loudly at the mutator/bootstrap layer. + req.authUser = { username: record.username, role: record.role ?? 'user' }; + setSessionCookie(reply, sessionToken!); + enforcePasswordChange(req, reply, !!record.mustChangePassword); + return; + } + if (!live || live.disabled) { + authSessions.delete(sessionToken!); + reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' }); + reply.code(401).send('Unauthorized'); + return; + } + // Trust the LIVE role/mustChangePassword, not the (possibly stale) cookie snapshot + // (also defends #9/#13: a CLI demotion is reflected without a revoke). + req.authUser = { username: live.username, role: live.role }; + setSessionCookie(reply, sessionToken!); // sliding re-issue + enforcePasswordChange(req, reply, !!live.mustChangePassword); + return; + } + + // 2. Basic Auth against the user store (scrypt verify). + // Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the + // per-username bucket here; see below). + const ipFail = authFailures.get(clientIp) ?? 0; + if (ipFail >= AUTH_FAILURE_MAX) { + sendAuthRateLimit(reply, authFailures, clientIp); + return; + } + const creds = parseBasicAuth(req.headers.authorization); + if (creds) { + const normUser = creds.username.trim().toLowerCase(); + // Security: VERIFY FIRST, then throttle only FAILED attempts. Consulting the + // per-username bucket before verifying let throwaway IPs lock out a known account + // (incl. admin) even with the correct password. A correct password must always + // win and self-heal both buckets, regardless of the username-failure count. + const result = await verifyPassword(creds.username, creds.password); + if (result) { + const { user, needsRehash: rehash } = result; + if (rehash) void setPassword(user.username, creds.password).catch(() => {}); + void touchLastLogin(user.username).catch(() => {}); + authFailures.delete(clientIp); + userFailures.delete(normUser); + + const token = randomBytes(32).toString('hex'); + if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username); + authSessions.set(token, { + ip: clientIp, + ua: req.headers['user-agent'] ?? '', + createdAt: Date.now(), + method: 'basic', + username: user.username, + role: user.role, + mustChangePassword: !!user.mustChangePassword, + }); + req.authUser = { username: user.username, role: user.role }; + setSessionCookie(reply, token); + enforcePasswordChange(req, reply, !!user.mustChangePassword); + return; + } + // Failed guess: count it against BOTH buckets. Once the per-username bucket + // reaches the cap, further FAILED attempts get 429 (throttles distributed + // brute-force), but this path is only reached on a wrong password, so it can + // never deny a correct one. + const uFail = (userFailures.get(normUser) ?? 0) + 1; + userFailures.set(normUser, uFail); + authFailures.set(clientIp, ipFail + 1); + if (uFail >= AUTH_FAILURE_MAX) { + sendAuthRateLimit(reply, userFailures, normUser); + return; + } + reply.header('WWW-Authenticate', 'Basic realm="Codeman"'); + reply.code(401).send('Unauthorized'); + return; + } + + // No credentials presented: count against the per-IP bucket and challenge. + authFailures.set(clientIp, ipFail + 1); + reply.header('WWW-Authenticate', 'Basic realm="Codeman"'); + reply.code(401).send('Unauthorized'); + }); +} + /** Methods that don't change server state and so skip the cross-site Origin check. */ const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); diff --git a/src/web/network-auth-policy.ts b/src/web/network-auth-policy.ts index a7e73423..3fb04974 100644 --- a/src/web/network-auth-policy.ts +++ b/src/web/network-auth-policy.ts @@ -39,6 +39,16 @@ export function isLoopbackBindHost(host: string): boolean { */ export const DEFAULT_TRUSTED_HOST_SUFFIXES = ['.ts.net', '.trycloudflare.com', '.cfargotunnel.com']; +/** + * Container-to-host gateway aliases (Docker / Podman). A hook `curl` from INSIDE a + * docker case carries `Host: host.docker.internal:` (the derived + * CODEMAN_API_URL), so the always-on host guard must allow it or every in-container + * hook is blocked 403. These names only resolve to the host from within a + * container's network namespace, so they are not a DNS-rebinding surface for a + * normal browser. Both engines' aliases are allowed so a mixed fleet keeps working. + */ +export const DOCKER_HOST_GATEWAY_ALIASES = ['host.docker.internal', 'host.containers.internal']; + /** Policy inputs for the anti-DNS-rebinding Host allowlist + cross-site Origin guard. */ export interface HostPolicy { /** The host the server is bound to (e.g. '127.0.0.1', '0.0.0.0', or a hostname). */ @@ -98,6 +108,8 @@ function matchesHost(hostname: string, policy: HostPolicy): boolean { const bind = parseAuthorityHostname(policy.bindHost); if (bind && hostname === bind) return true; if (policy.tunnelHost && hostname === policy.tunnelHost) return true; + // Docker/Podman container-to-host gateway aliases (for in-container hook curls). + if (DOCKER_HOST_GATEWAY_ALIASES.includes(hostname)) return true; for (const suffix of DEFAULT_TRUSTED_HOST_SUFFIXES) { if (hostname === suffix.slice(1) || hostname.endsWith(suffix)) return true; } diff --git a/src/web/ports/auth-port.ts b/src/web/ports/auth-port.ts index ef022230..80059111 100644 --- a/src/web/ports/auth-port.ts +++ b/src/web/ports/auth-port.ts @@ -11,6 +11,19 @@ export interface AuthSessionRecord { ua: string; createdAt: number; method: 'qr' | 'basic'; + /** + * Multi-user identity carried by the cookie (single-user leaves these unset). + * Snapshotted at mint time. Authorization-relevant admin changes (password reset, + * disable, delete, role change, bypass-grant change) revoke the user's sessions so + * a stale snapshot can't outlive the change; additionally the cookie fast-path + * re-reads role/disabled/mustChangePassword live from the store each request, so an + * out-of-band CLI mutation also takes effect promptly. See docs/multi-user-plan.md + * section 5. + */ + username?: string; + role?: 'admin' | 'user'; + /** Whether this user must change their password before other actions are allowed. */ + mustChangePassword?: boolean; } export interface AuthPort { diff --git a/src/web/ports/config-port.ts b/src/web/ports/config-port.ts index 9d249cc0..bff5e9331 100644 --- a/src/web/ports/config-port.ts +++ b/src/web/ports/config-port.ts @@ -18,7 +18,7 @@ export interface ConfigPort { getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>; getTerminalHistoryConfig(): Promise; getDefaultClaudeMdPath(): Promise; - getLightState(): unknown; + getLightState(identity?: { username: string; role: 'admin' | 'user' }): unknown; getLightSessionsState(): unknown[]; startTranscriptWatcher(sessionId: string, transcriptPath: string): void; stopTranscriptWatcher(sessionId: string): void; diff --git a/src/web/ports/infra-port.ts b/src/web/ports/infra-port.ts index 322cdf37..21e5beca 100644 --- a/src/web/ports/infra-port.ts +++ b/src/web/ports/infra-port.ts @@ -23,6 +23,9 @@ export interface ScheduledRun { completedTasks: number; totalCost: number; logs: string[]; + /** Multi-user owner (username) — undefined in single-user mode. Used to scope + * list/delete and to downgrade the spawned Session's permission mode. */ + owner?: string; } export interface InfraPort { @@ -33,6 +36,6 @@ export interface InfraPort { readonly teamWatcher: TeamWatcher; readonly tunnelManager: TunnelManager; readonly pushStore: PushSubscriptionStore; - startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise; + startScheduledRun(prompt: string, workingDir: string, durationMinutes: number, owner?: string): Promise; stopScheduledRun(id: string): Promise; } diff --git a/src/web/public/admin-ui.js b/src/web/public/admin-ui.js new file mode 100644 index 00000000..aaadc98a --- /dev/null +++ b/src/web/public/admin-ui.js @@ -0,0 +1,260 @@ +/** + * @fileoverview Multi-user frontend: identity boot, admin Users panel, and the + * change-password flow. Self-contained (builds its own DOM) so it needs no + * index.html surgery beyond the script tag and integrates with the existing App + * Settings modal by injecting a "Users" tab (admins in multi-user mode only). + * + * @dependency app.js (window.app), settings-ui.js (App Settings modal + tab switch) + * @loadorder after settings-ui.js / ultracode-panel.js, before session-ui.js + * + * In single-user mode GET /api/me returns a synthetic admin with multiUser:false, + * so none of the admin UI is shown and behavior is unchanged. + */ +(function () { + 'use strict'; + + const unwrap = (body) => (body && typeof body === 'object' && 'data' in body ? body.data : body); + + async function apiGet(path) { + const res = await window.fetch(path, { headers: { Accept: 'application/json' } }); + return unwrap(await res.json()); + } + async function apiSend(method, path, body) { + const res = await window.fetch(path, { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + let json = null; + try { + json = await res.json(); + } catch { + /* empty body */ + } + return { ok: res.ok, status: res.status, body: json, data: unwrap(json) }; + } + + // ── Change-password modal ───────────────────────────────────────────────── + let cpModal = null; + function buildChangePasswordModal() { + if (cpModal) return cpModal; + const el = document.createElement('div'); + el.className = 'modal'; + el.id = 'changePasswordModal'; + el.style.zIndex = '3100'; + el.innerHTML = ` + `; + document.body.appendChild(el); + el.querySelector('#cpCancel').onclick = () => (el.style.display = 'none'); + el.querySelector('#cpSubmit').onclick = async () => { + const current = el.querySelector('#cpCurrent').value; + const nw = el.querySelector('#cpNew').value; + const confirm = el.querySelector('#cpConfirm').value; + const err = el.querySelector('#cpError'); + err.textContent = ''; + if (nw.length < 8) return (err.textContent = 'New password must be at least 8 characters.'); + if (nw !== confirm) return (err.textContent = 'Passwords do not match.'); + const r = await apiSend('POST', '/api/me/password', { currentPassword: current, newPassword: nw }); + if (!r.ok) return (err.textContent = (r.body && r.body.error) || 'Change failed.'); + el.style.display = 'none'; + if (window.app && window.app.showToast) window.app.showToast('Password changed'); + }; + cpModal = el; + return el; + } + function openChangePassword(forced) { + const el = buildChangePasswordModal(); + el.querySelector('#cpMustNote').style.display = forced ? '' : 'none'; + el.querySelector('#cpCancel').style.display = forced ? 'none' : ''; + el.querySelector('#cpError').textContent = ''; + el.style.display = 'flex'; + } + + // ── Fetch interceptor: surface PASSWORD_CHANGE_REQUIRED ─────────────────── + function installInterceptor() { + const orig = window.fetch; + window.fetch = async function (...args) { + const res = await orig.apply(this, args); + if (res.status === 403) { + try { + const clone = res.clone(); + const j = await clone.json(); + if (j && j.errorCode === 'PASSWORD_CHANGE_REQUIRED') openChangePassword(true); + } catch { + /* not JSON */ + } + } + return res; + }; + } + + // ── Admin Users panel (injected into the App Settings modal) ────────────── + function injectUsersTab() { + const modal = document.getElementById('appSettingsModal'); + if (!modal || modal.querySelector('[data-tab="settings-users"]')) return; + const tabs = modal.querySelector('.modal-tabs'); + const body = modal.querySelector('.modal-body'); + if (!tabs || !body) return; + const btn = document.createElement('button'); + btn.className = 'modal-tab-btn'; + btn.dataset.tab = 'settings-users'; + btn.textContent = 'Users'; + tabs.appendChild(btn); + const content = document.createElement('div'); + content.className = 'modal-tab-content hidden'; + content.id = 'settings-users'; + content.innerHTML = ` +
+ Users + +
+

Users share the host account; this separates workspaces, it does not sandbox + users from each other. Pair with Docker cases for isolation.

+
+

`; + body.appendChild(content); + // Render whenever the tab is shown (the shared switchSettingsTab toggles it). + btn.addEventListener('click', renderUsers); + content.querySelector('#adminAddUser').onclick = addUserFlow; + } + + function esc(s) { + return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); + } + + async function renderUsers() { + const table = document.getElementById('adminUsersTable'); + if (!table) return; + table.innerHTML = 'Loading…'; + let users; + try { + users = await apiGet('/api/admin/users'); + } catch { + table.innerHTML = 'Failed to load users.'; + return; + } + const rows = users + .map((u) => { + const flags = [ + u.role === 'admin' ? 'admin' : 'user', + u.disabled ? 'disabled' : 'enabled', + u.canBypassPermissions ? 'can-bypass' : '', + u.mustChangePassword ? 'must-change-pw' : '', + ] + .filter(Boolean) + .join(', '); + const st = u.stats || {}; + return ` + ${esc(u.username)} + ${esc(flags)} + ${st.liveSessions ?? 0} live · ${st.caseCount ?? 0} cases + + + + + + + `; + }) + .join(''); + table.innerHTML = ` + + ${rows}
UserFlagsUsage
`; + table.querySelectorAll('button[data-act]').forEach((b) => { + b.onclick = () => + userAction( + b.closest('tr').dataset.u, + b.dataset.act, + users.find((x) => x.username === b.closest('tr').dataset.u) + ); + }); + } + + function setMsg(t) { + const m = document.getElementById('adminUsersMsg'); + if (m) m.textContent = t || ''; + } + + async function userAction(username, act, u) { + if (act === 'role') { + const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, { + role: u.role === 'admin' ? 'user' : 'admin', + }); + setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.'); + } else if (act === 'disabled') { + const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, { disabled: !u.disabled }); + setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.'); + } else if (act === 'bypass') { + const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, { + canBypassPermissions: !u.canBypassPermissions, + }); + setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.'); + } else if (act === 'reset') { + if (!window.confirm(`Reset ${username}'s password? They must set a new one on next login.`)) return; + const r = await apiSend('POST', `/api/admin/users/${encodeURIComponent(username)}/reset-password`); + if (r.ok && r.data && r.data.oneTimePassword) { + window.prompt(`One-time password for ${username} (copy it now — shown once):`, r.data.oneTimePassword); + } else setMsg((r.body && r.body.error) || 'Reset failed.'); + } else if (act === 'delete') { + const typed = window.prompt(`Type "${username}" to delete this user. Add " +space" to also delete their files.`); + if (typed !== username && typed !== `${username} +space`) return setMsg('Delete cancelled.'); + const deleteSpace = typed.endsWith(' +space'); + const r = await apiSend('DELETE', `/api/admin/users/${encodeURIComponent(username)}`, { deleteSpace }); + setMsg(r.ok ? `Deleted ${username}.` : (r.body && r.body.error) || 'Delete failed.'); + } + renderUsers(); + } + + async function addUserFlow() { + const username = window.prompt('New username (lowercase, 2-32 chars, [a-z0-9_-]):'); + if (!username) return; + const admin = window.confirm('Make this user an admin? (OK = admin, Cancel = regular user)'); + const r = await apiSend('POST', '/api/admin/users', { username: username.trim(), role: admin ? 'admin' : 'user' }); + if (r.ok && r.data && r.data.oneTimePassword) { + window.prompt(`Created ${username}. One-time password (copy it now — shown once):`, r.data.oneTimePassword); + } else setMsg((r.body && r.body.error) || 'Create failed.'); + renderUsers(); + } + + // ── Boot ────────────────────────────────────────────────────────────────── + async function boot() { + installInterceptor(); + let me = null; + try { + me = await apiGet('/api/me'); + } catch { + /* server may be pre-auth */ + } + window.__codemanUser = me || { username: 'admin', role: 'admin', multiUser: false }; + document.dispatchEvent(new CustomEvent('codeman:me', { detail: window.__codemanUser })); + if (window.__codemanUser.mustChangePassword) openChangePassword(true); + if (window.__codemanUser.multiUser && window.__codemanUser.role === 'admin') { + injectUsersTab(); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } + + window.codemanAdmin = { openChangePassword, renderUsers }; +})(); diff --git a/src/web/public/app.js b/src/web/public/app.js index 0bec0273..286844d3 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -216,6 +216,10 @@ const _SSE_HANDLER_MAP = [ [SSE_EVENTS.MUX_DIED, '_onMuxDied'], [SSE_EVENTS.MUX_STATS_UPDATED, '_onMuxStatsUpdated'], + // Remote auto-reconnect (COD-108) + [SSE_EVENTS.REMOTE_SESSION_RECONNECTED, '_onRemoteSessionReconnected'], + [SSE_EVENTS.REMOTE_RECONNECT_EXHAUSTED, '_onRemoteReconnectExhausted'], + // Ralph [SSE_EVENTS.SESSION_RALPH_LOOP_UPDATE, '_onRalphLoopUpdate'], [SSE_EVENTS.SESSION_RALPH_TODO_UPDATE, '_onRalphTodoUpdate'], @@ -1437,6 +1441,69 @@ class CodemanApp { addListener(event, () => this._onSessionListMaybeChanged()); } + // Docker export/import: toast + refresh the Manage-tab exports list on completion. + addListener(SSE_EVENTS.DOCKER_EXPORT_COMPLETE, (e) => { + try { + const d = e.data ? JSON.parse(e.data) : {}; + this.showToast(`Docker export ready: ${d.bundle} (${Math.round((d.sizeBytes || 0) / 1e6)} MB)`, 'success'); + this.refreshDockerExports?.(); + } catch (err) { + console.error('[SSE] docker export complete:', err); + } + }); + addListener(SSE_EVENTS.DOCKER_EXPORT_FAILED, (e) => { + try { + const d = e.data ? JSON.parse(e.data) : {}; + this.showToast(`Docker export failed: ${d.error || 'unknown error'}`, 'error'); + } catch (err) { + console.error('[SSE] docker export failed:', err); + } + }); + // Import + drift-recreate completions: refresh case lists in EVERY open tab + // (the initiating tab already refreshes via its own fetch response). + addListener(SSE_EVENTS.DOCKER_IMPORT_COMPLETE, (e) => { + try { + const d = e.data ? JSON.parse(e.data) : {}; + this.showToast(`Docker bundle imported as case "${d.name}"`, 'success'); + this.loadQuickStartCases?.(); + this.refreshDockerExports?.(); + } catch (err) { + console.error('[SSE] docker import complete:', err); + } + }); + addListener(SSE_EVENTS.DOCKER_CONTAINER_RECREATED, (e) => { + try { + const d = e.data ? JSON.parse(e.data) : {}; + this.showToast(`Container for "${d.name}" removed — next launch recreates it with the new config`, 'info'); + } catch (err) { + console.error('[SSE] docker container recreated:', err); + } + }); + // Base image auto-build on first Docker case (build-on-first-use). A single + // multi-minute event; surface start/finish so the Run spinner is explained. + addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => { + this.showToast('Building the Codeman agent image (first Docker case, a few minutes)...', 'info', { + duration: 8000, + }); + }); + addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_COMPLETE, (e) => { + try { + const d = e.data ? JSON.parse(e.data) : {}; + if (d.error) this.showToast(`Agent image build failed: ${d.error}`, 'error'); + else this.showToast('Agent image ready. Starting the container...', 'success'); + } catch (err) { + console.error('[SSE] docker image build complete:', err); + } + }); + addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_FAILED, (e) => { + try { + const d = e.data ? JSON.parse(e.data) : {}; + this.showToast(`Agent image build failed: ${d.error || 'unknown error'}`, 'error'); + } catch (err) { + console.error('[SSE] docker image build failed:', err); + } + }); + // COD-139: a session:pinned event updates the local live-session pin flag (so // a subsequent render is consistent) and re-sorts the open session manager / // welcome list so pinned sessions float to the top. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index eef371d0..4e8dbdff 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -380,6 +380,11 @@ const SSE_EVENTS = { MUX_DIED: 'mux:died', MUX_STATS_UPDATED: 'mux:statsUpdated', + // Remote auto-reconnect (COD-108) + REMOTE_SESSION_DROPPED: 'remote:sessionDropped', + REMOTE_SESSION_RECONNECTED: 'remote:sessionReconnected', + REMOTE_RECONNECT_EXHAUSTED: 'remote:reconnectExhausted', + // Ralph SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate', SESSION_RALPH_TODO_UPDATE: 'session:ralphTodoUpdate', @@ -475,6 +480,17 @@ const SSE_EVENTS = { CASE_LINKED: 'case:linked', CASE_DELETED: 'case:deleted', CASE_ORDER_CHANGED: 'case:order-changed', + DOCKER_EXPORT_COMPLETE: 'docker:exportComplete', + DOCKER_EXPORT_FAILED: 'docker:exportFailed', + DOCKER_IMPORT_COMPLETE: 'docker:importComplete', + DOCKER_IMAGE_BUILD_STARTED: 'docker:imageBuildStarted', + DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress', + DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete', + DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed', + // Multi-user (admin-only / targeted) + ADMIN_USERS_CHANGED: 'admin:usersChanged', + AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired', + DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated', // Session order (global tab order sync) SESSION_ORDER_CHANGED: 'session:orderChanged', diff --git a/src/web/public/index.html b/src/web/public/index.html index 362e9a07..f867ff1b 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -118,12 +118,13 @@ - - + + +
—
@@ -557,7 +558,7 @@
- + v0.0.0
@@ -1268,6 +1269,13 @@ +
+ File Viewer + +
Attachments Button
+
+ Session Manager Button + +
+
+ Away Digest Button + +
+
+ Cron Button + +
Redraw Terminal Button
+
+ + + Automatically re-establish remote (SSH) sessions when the connection drops, reattaching to the durable remote tmux session (on by default; bounded backoff) +
+
+ + Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed. +
+
+ Container settings (optional, sensible defaults) +
+
+ + + Disk is elastic: storage grows automatically as data flows in (no fixed cap). +
+
+ + +
+
+ + +
+
+ + + Needs the NVIDIA container toolkit on the host. +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ Discover existing sessions +
+ Find codeman-* tmux sessions already running on this host (started by the remote's own Codeman or another instance) and attach to one. Attaching shares the session; closing the tab detaches it — it is never killed. +
+ +
+
+
+
+ + + Use arrows to reorder. Changes are saved automatically. +
+
+ Docker exports + +
+
No exports yet. Export a docker case from its tab.
+
@@ -2357,6 +2514,7 @@ + diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 39ab745b..db2b4961 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -459,16 +459,18 @@ html.mobile-init .file-browser-panel { height: 12px; } - /* Hide header settings gear, lifecycle log, away digest, and session manager on - mobile - settings moved to toolbar; away digest and the session manager are - secondary controls that don't belong on the cramped phone header (the session - manager stays reachable via the Ctrl+K palette's "Browse all sessions" item). + /* Hide header settings gear, lifecycle log, away digest, session manager, and + file viewer on mobile - settings moved to toolbar; the others are secondary / + desktop-oriented controls that don't belong on the cramped phone header (the + session manager stays reachable via the Ctrl+K palette's "Browse all sessions" + item; the file viewer button is opt-in but its panel is desktop-sized). (The attachments button is opt-in / default-hidden everywhere via its own --hidden marker, so it needs no mobile-specific rule here.) */ .btn-icon-header.btn-settings, .btn-icon-header.btn-lifecycle-log, .btn-icon-header.btn-away-digest, - .btn-icon-header.btn-session-manager { + .btn-icon-header.btn-session-manager, + .btn-icon-header.btn-file-viewer { display: none !important; } diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 3926d3fd..7c520853 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -82,6 +82,33 @@ Object.assign(CodemanApp.prototype, { } }, + // Remote auto-reconnect (COD-108) + _onRemoteSessionReconnected(data) { + const id = this.getShortId(data.sessionId); + this.showToast(`Remote session ${id} reconnected`, 'success'); + }, + + _onRemoteReconnectExhausted(data) { + const sessionId = data.sessionId; + const id = this.getShortId(sessionId); + // Auto-reconnect gave up after the bounded backoff. Surface a manual + // "Reconnect" affordance that re-triggers the attach path (force-reload the + // session, which re-runs the create/attach flow against the durable remote). + this.showToast(`Remote session ${id} dropped — auto-reconnect gave up`, 'error', { + duration: 15000, + action: { + label: 'Reconnect', + onClick: () => { + if (this.sessions && this.sessions.has(sessionId)) { + this.selectSession(sessionId, { forceReload: true }); + } else { + this.showToast('Session no longer available', 'warning'); + } + }, + }, + }); + }, + // Bash tools _onBashToolStart(data) { @@ -3098,6 +3125,32 @@ Object.assign(CodemanApp.prototype, { } }, + // Header "File Viewer" button (opt-in via App Settings → Header Displays → + // File Viewer). Toggles the file browser panel open/closed without a trip + // through settings. Persists via the same `showFileBrowser` flag the Panels + // section + the panel's own close (X) use, so the three stay in sync. + toggleFileBrowserButton() { + const panel = this.$('fileBrowserPanel'); + const isOpen = panel?.classList.contains('visible'); + const btn = document.querySelector('.btn-file-viewer'); + if (isOpen) { + this.closeFileBrowserPanel(); + if (btn) btn.setAttribute('aria-expanded', 'false'); + return; + } + if (!this.activeSessionId) { + this.showToast('Open a session to browse its files', 'info'); + return; + } + const settings = this.loadAppSettingsFromStorage(); + settings.showFileBrowser = true; + this.saveAppSettingsToStorage(settings); + const checkbox = document.getElementById('appSettingsShowFileBrowser'); + if (checkbox) checkbox.checked = true; + this.applyMonitorVisibility(); + if (btn) btn.setAttribute('aria-expanded', 'true'); + }, + closeFileBrowserPanel() { const panel = this.$('fileBrowserPanel'); if (panel) { @@ -3130,6 +3183,10 @@ Object.assign(CodemanApp.prototype, { const settings = this.loadAppSettingsFromStorage(); settings.showFileBrowser = false; this.saveAppSettingsToStorage(settings); + const checkbox = document.getElementById('appSettingsShowFileBrowser'); + if (checkbox) checkbox.checked = false; + const headerBtn = document.querySelector('.btn-file-viewer'); + if (headerBtn) headerBtn.setAttribute('aria-expanded', 'false'); }, async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) { diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 6ccee3cd..fa6af8ee 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -45,7 +45,18 @@ Object.assign(CodemanApp.prototype, { // ═══════════════════════════════════════════════════════════════ formatCasePickerLabel(c) { - return c?.location === 'remote' && c.remote?.hostId ? `${c.name} @ ${c.remote.hostId}` : c?.name || ''; + if (c?.location === 'remote' && c.remote?.hostId) return `${c.name} @ ${c.remote.hostId}`; + if (c?.location === 'docker') return `${c.name} (${this.dockerCaseTag(c.docker?.hostId)})`; + return c?.name || ''; + }, + + // Short parenthetical tag for a dockerized case: '(docker)' for the default / + // auto-provisioned host (one-click "Run in Docker", the Docker-tab 'local' + // default, or a per-case 'q-' resource-override host), otherwise the custom + // docker host id the user named (e.g. '(gpu-box)'). Keeps the case name short. + dockerCaseTag(hostId) { + if (!hostId || hostId === 'default' || hostId === 'local' || /^q-/.test(hostId)) return 'docker'; + return hostId; }, buildCasePickerOptions(cases = []) { @@ -70,7 +81,10 @@ Object.assign(CodemanApp.prototype, { c.location, c.remote?.hostId, c.remote?.label, - c.remote?.path + c.remote?.path, + c.docker?.container, + c.docker?.image, + c.docker?.path ].filter(Boolean).join(' ').toLowerCase(); return { name: c.name, label, case: c, searchText }; }) @@ -480,6 +494,22 @@ Object.assign(CodemanApp.prototype, { input.value = Math.max(1, current - 1); }, + // Next free index for a case's session tabs (e.g. w1-, + // w2- for agents, s1- for shells), shared by the local and + // remote/docker launch paths so all tabs follow the same naming convention. + _nextCaseSessionStartNumber(caseName, prefix = 'w') { + const re = new RegExp(`^${prefix}(\\d+)-([a-zA-Z0-9_-]+)`); + let startNumber = 1; + for (const [, session] of this.sessions || []) { + const match = session.name && session.name.match(re); + if (match && match[2] === caseName) { + const num = parseInt(match[1]); + if (num >= startNumber) startNumber = num + 1; + } + } + return startNumber; + }, + async runClaude() { const caseName = document.getElementById('quickStartCase').value || 'testcase'; const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1)); @@ -517,15 +547,54 @@ Object.assign(CodemanApp.prototype, { // Remote cases run over ssh — POST /api/sessions stat-validates workingDir on // the LOCAL fs (a remote user@host:/path never exists locally), so route them // through /api/quick-start, which resolves the remote case + launches via ssh. - if (caseData.location === 'remote') { + if (caseData.location === 'remote' || caseData.location === 'docker') { + // Name remote/docker tabs with the same w- convention as local + // sessions (quick-start would otherwise auto-generate codeman-). + const startNumber = this._nextCaseSessionStartNumber(caseName); + // Docker (NOT remote): the App Settings Claude Model choice applies — the + // workspace is a real host dir, so quick-start writes it to the case's + // .claude/settings.local.json and the in-container claude reads it. + // Remote quick-starts REJECT modelOverride (the file would land on the + // wrong machine), so never send it there. + let dockerModelOverride; + if (caseData.location === 'docker') { + const dockerGlobalSettings = this.loadAppSettingsFromStorage(); + const dockerCaseSettings = this.getCaseSettings(caseName); + const dockerUseOpus1m = dockerCaseSettings.opusContext1m || dockerGlobalSettings.opusContext1mEnabled; + dockerModelOverride = dockerGlobalSettings.claudeModel || (dockerUseOpus1m ? 'opus[1m]' : ''); + } const remoteIds = []; + let driftHandled = false; for (let i = 0; i < tabCount; i++) { - const res = await fetch('/api/quick-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caseName, mode: 'claude' }) + const quickStartBody = JSON.stringify({ + caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`, + ...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {}) }); - const data = await res.json(); + const doQuickStart = async () => { + const res = await fetch('/api/quick-start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: quickStartBody + }); + return res.json(); + }; + let data = await doQuickStart(); + // Docker config drift: the host config changed since the container was + // created (CONFLICT from quick-start). Confirm once, recreate, retry. + if (!data.success && data.errorCode === 'CONFLICT' && caseData.location === 'docker' && !driftHandled) { + driftHandled = true; + const recreate = confirm( + `Container config for "${caseName}" changed since its container was created.\n\n` + + 'Recreate the container to apply the new config? Workspace files and the ' + + 'conversation survive (the conversation resumes on launch).' + ); + if (recreate) { + const recRes = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/recreate`, { method: 'POST' }); + const recData = await recRes.json(); + if (!recData.success) throw new Error(recData.error || 'Failed to recreate container'); + data = await doQuickStart(); + } + } if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session'); remoteIds.push(data.data.sessionId); } @@ -541,16 +610,7 @@ Object.assign(CodemanApp.prototype, { let firstSessionId = null; // Find the highest existing w-number for THIS case to avoid duplicates - let startNumber = 1; - for (const [, session] of this.sessions) { - const match = session.name && session.name.match(/^w(\d+)-([a-zA-Z0-9_-]+)/); - if (match && match[2] === caseName) { - const num = parseInt(match[1]); - if (num >= startNumber) { - startNumber = num + 1; - } - } - } + const startNumber = this._nextCaseSessionStartNumber(caseName); // Get global Ralph tracker setting const ralphEnabled = this.isRalphTrackerEnabledByDefault(); @@ -694,18 +754,23 @@ Object.assign(CodemanApp.prototype, { } const selectedCase = (this.cases || []).find(c => c.name === caseName); - const isRemoteCase = caseData.location === 'remote' || selectedCase?.location === 'remote'; + const isRemoteCase = + caseData.location === 'remote' || + caseData.location === 'docker' || + selectedCase?.location === 'remote' || + selectedCase?.location === 'docker'; const workingDir = caseData.path; if (!workingDir) throw new Error('Case path not found'); // Remote cases run over ssh — route through /api/quick-start (see runClaude). - if (caseData.location === 'remote') { + if (caseData.location === 'remote' || caseData.location === 'docker') { + const startNumber = this._nextCaseSessionStartNumber(caseName, 's'); const remoteIds = []; for (let i = 0; i < shellCount; i++) { const res = await fetch('/api/quick-start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caseName, mode: 'shell' }) + body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}` }) }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to start remote shell session'); @@ -721,16 +786,7 @@ Object.assign(CodemanApp.prototype, { } // Find the highest existing s-number for THIS case to avoid duplicates - let startNumber = 1; - for (const [, session] of this.sessions) { - const match = session.name && session.name.match(/^s(\d+)-([a-zA-Z0-9_-]+)/); - if (match && match[2] === caseName) { - const num = parseInt(match[1]); - if (num >= startNumber) { - startNumber = num + 1; - } - } - } + const startNumber = this._nextCaseSessionStartNumber(caseName, 's'); // Create all shell sessions in parallel const sessionNames = []; @@ -789,7 +845,8 @@ Object.assign(CodemanApp.prototype, { const caseName = document.getElementById('quickStartCase').value || 'testcase'; // Remote cases run the CLI on the REMOTE host — the local /api/opencode/status // probe and the local-only config/env below don't apply (quick-start rejects them). - const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote'; + const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; + const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; this.terminal.clear(); this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`); @@ -818,6 +875,7 @@ Object.assign(CodemanApp.prototype, { body: JSON.stringify({ caseName, mode: 'opencode', + sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, ...(isRemote ? {} : { openCodeConfig: { autoAllowTools: true }, ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), @@ -843,7 +901,8 @@ Object.assign(CodemanApp.prototype, { const caseName = document.getElementById('quickStartCase').value || 'testcase'; // Remote cases run Codex on the REMOTE host — skip the local status probe and the // local-only config/env below (quick-start rejects them for remote cases). - const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote'; + const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; + const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; this.terminal.clear(); this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`); @@ -869,6 +928,7 @@ Object.assign(CodemanApp.prototype, { body: JSON.stringify({ caseName, mode: 'codex', + sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, ...(isRemote ? {} : { codexConfig: { dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false, @@ -897,7 +957,8 @@ Object.assign(CodemanApp.prototype, { const caseName = document.getElementById('quickStartCase').value || 'testcase'; // Remote cases run Gemini on the REMOTE host — skip the local status probe and the // local-only config/env below (quick-start rejects them for remote cases). - const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote'; + const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; + const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; this.terminal.clear(); this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`); @@ -922,6 +983,7 @@ Object.assign(CodemanApp.prototype, { body: JSON.stringify({ caseName, mode: 'gemini', + sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, ...(isRemote ? {} : { geminiConfig: { approvalMode: 'yolo' }, ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), @@ -1567,10 +1629,17 @@ Object.assign(CodemanApp.prototype, { if (tabName === 'case-manage') { submitBtn.style.display = 'none'; this.renderCaseManageList(); + this.refreshDockerExports(); } else { submitBtn.style.display = ''; submitBtn.textContent = - tabName === 'case-create' ? 'Create' : tabName === 'case-remote' ? 'Link Remote' : 'Link'; + tabName === 'case-create' + ? 'Create' + : tabName === 'case-remote' + ? 'Link Remote' + : tabName === 'case-docker' + ? 'Link Docker' + : 'Link'; } // Focus appropriate input if (tabName === 'case-create') { @@ -1579,6 +1648,8 @@ Object.assign(CodemanApp.prototype, { document.getElementById('linkCaseName').focus(); } else if (tabName === 'case-remote') { document.getElementById('remoteCaseName').focus(); + } else if (tabName === 'case-docker') { + document.getElementById('dockerCaseName').focus(); } }, @@ -1596,6 +1667,8 @@ Object.assign(CodemanApp.prototype, { await this.createCase(); } else if (this.caseModalTab === 'case-remote') { await this.linkRemoteCase(); + } else if (this.caseModalTab === 'case-docker') { + await this.linkDockerCase(); } else { await this.linkCase(); } @@ -1619,21 +1692,36 @@ Object.assign(CodemanApp.prototype, { return; } + // One-click "Run in Docker": create the case folder AND a container, then start + // a session inside it. Optional expandable settings override the defaults. + const inDocker = document.getElementById('newCaseDocker')?.checked; + const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases'; + const payload = inDocker + ? { name, description, ...this._collectDockerQuickSettings() } + : { name, description }; + try { - const res = await fetch('/api/cases', { + const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, description }) + body: JSON.stringify(payload) }); const data = await res.json(); if (data.success) { this.closeCreateCaseModal(); - this.showToast(`Case "${name}" created`, 'success'); // Reload cases and select the new one await this.loadQuickStartCases(name); // Save as last used case await this.saveLastUsedCase(name); + if (inDocker) { + const caps = data.data?.capsEnforced === false ? ' (resource caps advisory on this engine)' : ''; + this.showToast(`Docker case "${name}" created${caps} — starting session…`, 'success'); + // Start a session INSIDE the container (routes through quick-start). + await this.runClaude(); + } else { + this.showToast(`Case "${name}" created`, 'success'); + } } else { this.showToast(data.error || 'Failed to create case', 'error'); } @@ -1643,6 +1731,47 @@ Object.assign(CodemanApp.prototype, { } }, + // Fill the memory/cpu/gpu fields from a resource template. `medium` clears them so + // the server uses its defaults (no per-case host); `custom` leaves them editable. + applyDockerTemplate() { + const t = document.getElementById('quickDockerTemplate')?.value; + const presets = { + small: { m: '2g', c: '1', g: '' }, + medium: { m: '', c: '', g: '' }, + large: { m: '8g', c: '4', g: '' }, + gpu: { m: '8g', c: '4', g: 'all' }, + }; + const p = presets[t]; + if (!p) return; // 'custom' — leave fields as-is + const set = (id, v) => { + const el = document.getElementById(id); + if (el) el.value = v; + }; + set('quickDockerMemory', p.m); + set('quickDockerCpus', p.c); + set('quickDockerGpus', p.g); + }, + + // Collect only the non-default docker overrides (empty fields fall back to defaults + // server-side; sent as undefined, never null, per the Zod .optional() gotcha). + _collectDockerQuickSettings() { + const val = (id) => (document.getElementById(id)?.value || '').trim(); + const o = {}; + const mem = val('quickDockerMemory'); + if (mem) o.memory = mem; + const cpus = val('quickDockerCpus'); + if (cpus) o.cpus = cpus; + const gpus = val('quickDockerGpus'); + if (gpus && gpus.toLowerCase() !== 'none') o.gpus = gpus; + const net = document.getElementById('quickDockerNetwork')?.value; + if (net && net !== 'bridge') o.network = net; + const img = val('quickDockerImage'); + if (img) o.image = img; + const mc = document.getElementById('quickDockerMountCreds'); + if (mc && !mc.checked) o.mountCredentials = false; + return o; + }, + async linkCase() { const name = document.getElementById('linkCaseName').value.trim(); const path = document.getElementById('linkCasePath').value.trim(); @@ -1767,6 +1896,328 @@ Object.assign(CodemanApp.prototype, { } }, + async linkDockerCase() { + const name = document.getElementById('dockerCaseName').value.trim(); + const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim(); + const hostId = document.getElementById('dockerHostId').value.trim() || 'local'; + const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base'; + const network = document.getElementById('dockerNetwork').value; + const memory = document.getElementById('dockerMemory').value.trim(); + const cpus = document.getElementById('dockerCpus').value.trim(); + const mountCredentials = document.getElementById('dockerMountCredentials').checked; + const resumeOnStart = document.getElementById('dockerResumeOnStart').checked; + const statusEl = document.getElementById('dockerLinkStatus'); + + if (!name || !hostWorkspacePath) { + this.showToast('Please enter a case name and workspace path', 'error'); + return; + } + if (!/^[a-zA-Z0-9_-]+$/.test(name) || !/^[a-zA-Z0-9_-]+$/.test(hostId)) { + this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error'); + return; + } + if (!hostWorkspacePath.startsWith('/')) { + this.showToast('Workspace path must be absolute', 'error'); + return; + } + + try { + if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...'; + // omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null) + const resources = {}; + if (memory) resources.memory = memory; + if (cpus) resources.cpus = cpus; + const hostPayload = { + id: hostId, + label: hostId, + image, + network, + mountCredentials, + resumeOnStart, + ...(Object.keys(resources).length ? { resources } : {}), + }; + // PUT (update-or-create) so re-linking with the same host id refreshes its settings. + let hostRes = await fetch('/api/docker-hosts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(hostPayload), + }); + let hostData = await hostRes.json(); + if (!hostData.success && hostData.errorCode === 'ALREADY_EXISTS') { + hostRes = await fetch(`/api/docker-hosts/${encodeURIComponent(hostId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(hostPayload), + }); + hostData = await hostRes.json(); + } + if (!hostData.success) throw new Error(hostData.error || 'Failed to save docker host'); + + const caseRes = await fetch('/api/cases/docker-link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, hostId, hostWorkspacePath }), + }); + const caseData = await caseRes.json(); + if (caseData.success) { + this.closeCreateCaseModal(); + const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : ''; + this.showToast(`Docker case "${name}" linked${caps}`, 'success'); + await this.loadQuickStartCases(name); + await this.saveLastUsedCase(name); + } else { + if (statusEl) statusEl.textContent = caseData.error || 'Failed to link docker case'; + this.showToast(caseData.error || 'Failed to link docker case', 'error'); + } + } catch (err) { + console.error('Failed to link docker case:', err); + if (statusEl) statusEl.textContent = err.message; + this.showToast('Failed to link docker case: ' + err.message, 'error'); + } + }, + + // ═══════════════════════════════════════════════════════════════ + // Docker export / import UI + // ═══════════════════════════════════════════════════════════════ + + async refreshDockerExports() { + const listEl = document.getElementById('dockerExportsList'); + if (!listEl) return; + try { + const res = await fetch('/api/docker-exports'); + const data = await res.json(); + const exports = data?.data?.exports || []; + if (exports.length === 0) { + listEl.innerHTML = 'No exports yet. Export a docker case from its tab.'; + return; + } + listEl.innerHTML = exports + .map(e => { + const mb = (e.sizeBytes / 1e6).toFixed(1); + // escapeHtml is the free function from constants.js (never a method on `this`) + const nm = escapeHtml(e.name); + return `
+ ${nm} (${mb} MB) + + Download + + + +
`; + }) + .join(''); + } catch (err) { + listEl.innerHTML = `Failed to load exports: ${err.message}`; + } + }, + + async exportDockerCaseBundle(caseName, mode = 'full') { + try { + const res = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/export`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode }), + }); + const data = await res.json(); + if (data.success) { + this.showToast(`Exporting "${caseName}" (${mode})... you'll be notified when the bundle is ready`, 'info'); + } else { + this.showToast(data.error || 'Export failed', 'error'); + } + } catch (err) { + this.showToast('Export failed: ' + err.message, 'error'); + } + }, + + async importDockerBundle(bundle) { + const newCaseName = prompt('New case name for the imported bundle:', bundle.split('-')[0] + '-imported'); + if (!newCaseName) return; + const destWorkspacePath = prompt('Absolute host directory to restore the workspace into:', ''); + if (!destWorkspacePath) return; + try { + const res = await fetch('/api/docker-cases/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bundle, newCaseName, destWorkspacePath }), + }); + const data = await res.json(); + if (data.success) { + this.showToast(`Imported as "${newCaseName}"`, 'success'); + await this.loadQuickStartCases(newCaseName); + } else { + this.showToast(data.error || 'Import failed', 'error'); + } + } catch (err) { + this.showToast('Import failed: ' + err.message, 'error'); + } + }, + + async deleteDockerExport(filename) { + if (!confirm(`Delete export bundle "${filename}"?`)) return; + try { + const res = await fetch(`/api/docker-exports/${encodeURIComponent(filename)}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + this.showToast('Export deleted', 'success'); + this.refreshDockerExports(); + } else { + this.showToast(data.error || 'Delete failed', 'error'); + } + } catch (err) { + this.showToast('Delete failed: ' + err.message, 'error'); + } + }, + + // ═══════════════════════════════════════════════════════════════ + // COD-105 — Discover + attach existing remote tmux sessions + // ═══════════════════════════════════════════════════════════════ + + /** Read the remote-host fields from the remote-case form into a host payload. */ + _readRemoteHostFromForm() { + const hostId = document.getElementById('remoteHostId').value.trim(); + const host = document.getElementById('remoteHostAddress').value.trim(); + const username = document.getElementById('remoteHostUsername').value.trim(); + const portRaw = document.getElementById('remoteHostPort').value.trim(); + const identityFile = document.getElementById('remoteHostIdentityFile').value.trim(); + const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim(); + const jumpHost = document.getElementById('remoteHostJumpHost').value.trim(); + const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim(); + const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + let port; + if (portRaw) { + const n = Number(portRaw); + if (Number.isInteger(n) && n >= 1 && n <= 65535) port = n; + } + return { + id: hostId, + label: hostId, + host, + username, + ...(port ? { port } : {}), + ...(identityFile ? { identityFile } : {}), + ...(socksProxy ? { socksProxy } : {}), + ...(jumpHost ? { jumpHost } : {}), + ...(extraSshOptions.length ? { extraSshOptions } : {}), + ...(codexCommand ? { commands: { codex: codexCommand } } : {}), + }; + }, + + /** + * Explicit Discover action (Decision A — never auto-runs on host select). + * Saves the host config (idempotent), then queries the host for `codeman-*` + * tmux sessions it didn't create and renders an Attach action per session. + */ + async discoverRemoteSessions() { + const results = document.getElementById('remoteDiscoverResults'); + const btn = document.getElementById('remoteDiscoverBtn'); + const hostPayload = this._readRemoteHostFromForm(); + if (!hostPayload.id || !hostPayload.host || !hostPayload.username) { + this.showToast('Fill in Host ID, address, and username first', 'error'); + return; + } + if (!/^[a-zA-Z0-9_-]+$/.test(hostPayload.id)) { + this.showToast('Invalid Host ID. Use letters, numbers, hyphens, underscores.', 'error'); + return; + } + if (btn) btn.disabled = true; + if (results) results.innerHTML = '
Discovering…
'; + try { + // Persist the host so the discovery endpoint can resolve it by id (idempotent). + const hostRes = await fetch('/api/remote-hosts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(hostPayload) + }); + const hostData = await hostRes.json(); + if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') { + throw new Error(hostData.error || 'Failed to save remote host'); + } + const res = await fetch(`/api/remote-hosts/${encodeURIComponent(hostPayload.id)}/sessions`); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Discovery failed'); + this._renderDiscoveredSessions(hostPayload.id, data.data.sessions || []); + } catch (err) { + console.error('Discover remote sessions failed:', err); + if (results) results.innerHTML = `
${escapeHtml(err.message)}
`; + } finally { + if (btn) btn.disabled = false; + } + }, + + /** Render the discovered remote sessions with an Attach action each. */ + _renderDiscoveredSessions(hostId, sessions) { + const results = document.getElementById('remoteDiscoverResults'); + if (!results) return; + if (!sessions.length) { + results.innerHTML = '
No codeman-* sessions running on this host (or it is unreachable).
'; + return; + } + const now = Math.floor(Date.now() / 1000); + const rows = sessions.map(s => { + const ageSecs = Math.max(0, now - (s.created || 0)); + const age = ageSecs < 3600 ? `${Math.floor(ageSecs / 60)}m` : ageSecs < 86400 ? `${Math.floor(ageSecs / 3600)}h` : `${Math.floor(ageSecs / 86400)}d`; + // COD-106 — show "shared · N clients" when more than one client is attached + // (genuinely collaborative), else a plain "attached" badge for a single client. + const clients = s.attachedClients != null ? s.attachedClients : s.attached ? 1 : 0; + const attachedBadge = + clients > 1 + ? `shared · ${clients} clients` + : clients === 1 + ? 'attached' + : ''; + return ` +
+
+ ${escapeHtml(s.name)} ${attachedBadge} + age ${age} · ${s.windows || 1} window(s) +
+ +
`; + }).join(''); + results.innerHTML = rows; + }, + + /** + * Create a NON-owned session that attaches to a discovered remote tmux session. + * Closing this tab detaches — it never kills the remote session. + */ + async attachDiscoveredSession(hostId, remoteSessionName) { + try { + const createRes = await fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mode: 'shell', + name: remoteSessionName, + attachRemoteSession: { hostId, remoteSessionName }, + }) + }); + const createData = await createRes.json(); + if (!createData.success) throw new Error(createData.error || 'Failed to create session'); + const id = createData.data.session.id; + await fetch(`/api/sessions/${id}/shell`, { method: 'POST' }); + const dims = this.getTerminalDimensions(); + if (dims) { + await fetch(`/api/sessions/${id}/resize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(dims) + }); + } + this.closeCreateCaseModal(); + this.showToast(`Attached to ${remoteSessionName} (detach on close)`, 'success'); + this.activeSessionId = id; + await this.selectSession(id); + if (this.terminal && typeof this.terminal.focus === 'function') this.terminal.focus(); + } catch (err) { + console.error('Attach discovered session failed:', err); + this.showToast('Failed to attach: ' + err.message, 'error'); + } + }, + // ═══════════════════════════════════════════════════════════════ // Case Management (reorder + delete) // ═══════════════════════════════════════════════════════════════ @@ -1791,6 +2242,12 @@ Object.assign(CodemanApp.prototype, { ${escapeHtml(pathDisplay)}
+ ${ + c.location === 'docker' + ? `` + : '' + }