Compare commits

..
Author SHA1 Message Date
arkonandClaude Opus 4.8 a8e0e2a343 chore: release 0.9.0 — security hardening + warn-don't-block network policy
Release 0.9.0 covering the merged security/reliability PRs (#106 deps/
supply-chain, #107 auth/network, #108 test stability, #110 tmux cwd) plus:

- Network policy: a non-loopback bind without CODEMAN_PASSWORD now STARTS
  with a loud warning (3 ways to secure) instead of refusing to start.
  Loopback stays the safe default. --allow-unauthenticated-network just
  acknowledges (terser note). (src/web/server.ts start())
- Post-install security note explaining the loopback default + safe exposure.
- New docs/security-architecture.md documenting the full model (binding,
  auth pipeline, tunnel req.ip caveat, file-serving, supply-chain, isolation,
  recommended setups). CLAUDE.md Security section + gotcha updated.
- Updated auth-security test: asserts warn-and-start (not throw).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:29:47 +02:00
Ark0N 4d0586a2aa Merge pull request #110 from aakhter/cod-31-tmux-session-reliability
fix: harden tmux launch cwd
2026-06-08 19:02:37 +02:00
arkonandClaude Opus 4.8 67a15b5949 docs: update CLAUDE.md for COD-29 network bind + CI/tooling drift
- Document the loopback-default bind and fail-closed non-loopback behavior
  (COD-29) as a Common Gotcha, plus expanded Auth + new Network bind rows
  in the Security table
- Add --host/CODEMAN_HOST bind and `npm run check:public-assets` to the
  Additional Commands table
- Note the CI server boot smoke test step

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:00:04 +02:00
Ark0N 6bf69a82c8 Merge pull request #106 from aakhter/cod-28-security-public-assets
chore: COD-28 harden dependencies and public assets
2026-06-08 18:12:42 +02:00
arkonandClaude Opus 4.8 d2efaa255b chore: scope new public-asset prettier check to maintained files
The PR adds an extended format:check / check-public-assets prettier pass
over src/web/public, but the hand-written public JS modules (and the
ported gesture bundle) have never been prettier-enforced and would turn
the new check red on master. Rather than reformat the entire frontend
(~2k lines of churn) inside a dependency-hardening PR, add those legacy
files + src/web/public/gesture/ to .prettierignore — matching the
author's existing pattern (app.js, styles.css, mobile.css, index.html).

The security-relevant checks are unaffected: check-public-assets.mjs
still validates NUL bytes and runs `node --check` on EVERY public .js
file regardless of .prettierignore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:11:14 +02:00
arkon a721af4552 Merge remote-tracking branch 'origin/master' into cod-28-security-public-assets 2026-06-08 18:05:10 +02:00
Ark0N e6b18fd126 Merge pull request #107 from aakhter/cod-29-network-auth-downloads
fix: COD-29 harden network auth and downloads
2026-06-08 18:03:01 +02:00
arkonandClaude Opus 4.8 6ee88be549 test: fix title tests for new host constructor arg + async renderIndexHtml
The WebServer constructor now takes `host` as the 4th positional arg
(titleHostname shifted to 5th), and renderIndexHtml became async (it
reads settings.json for the gesture bundle) and cache-busts asset URLs.
Update the two title tests accordingly:
- pass '127.0.0.1' as the bind host so the title value lands in the
  5th titleHostname slot (server-index-title + push-payload-host-title)
- await renderIndexHtml and make the cases async
- strip ?v=<mtime> cache-bust params before the byte-identical assertion

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:01:20 +02:00
Ark0N 1316725fdc Merge pull request #108 from aakhter/cod-30-test-ci-stability
test: COD-30 stabilize focused and perf browser tests
2026-06-08 17:54:33 +02:00
Aamer Akhter 187ce653ae fix: COD-31 harden tmux launch cwd 2026-06-08 11:18:14 -04:00
Aamer Akhter da00fa6038 fix: COD-29 relax auth lockout recovery 2026-06-08 11:01:34 -04:00
Aamer Akhter a36543c1b9 fix: COD-29 harden downloads and extract auth policy 2026-06-08 11:01:34 -04:00
Aamer Akhter dea015dc91 COD-2 scope downloads to session workspace 2026-06-08 11:01:34 -04:00
Aamer Akhter 333dc047c3 fix: COD-29 fail closed for unauthenticated network binds 2026-06-08 11:01:34 -04:00
Aamer Akhter d897c9a1cf test: COD-30 stabilize perf browser timing 2026-06-08 10:27:18 -04:00
Aamer Akhter 880b63d2a0 test: COD-30 stabilize focused test suites 2026-06-08 10:19:38 -04:00
Aamer Akhter eb874339dd chore: COD-28 harden dependencies and public assets 2026-06-08 09:56:06 -04:00
31 changed files with 6468 additions and 3879 deletions
+19
View File
@@ -2,8 +2,27 @@ dist/
coverage/
node_modules/
src/web/public/vendor/
src/web/public/gesture/
src/web/public/app.js
src/web/public/styles.css
src/web/public/mobile.css
src/web/public/index.html
# Hand-formatted public JS modules (never prettier-enforced; the new
# check-public-assets.mjs still validates NUL bytes + JS syntax on these).
src/web/public/constants.js
src/web/public/image-input.js
src/web/public/input-cjk.js
src/web/public/keyboard-accessory.js
src/web/public/notification-manager.js
src/web/public/orchestrator-panel.js
src/web/public/panels-ui.js
src/web/public/ralph-panel.js
src/web/public/ralph-wizard.js
src/web/public/respawn-ui.js
src/web/public/session-ui.js
src/web/public/settings-ui.js
src/web/public/sw.js
src/web/public/terminal-ui.js
src/web/public/voice-input.js
src/web/public/upload.html
scripts/remotion/
+31
View File
@@ -1,5 +1,36 @@
# aicodeman
## 0.9.0
### Minor Changes
- Security hardening release: network-bind policy, auth lockout recovery, download/SVG hardening, dependency & supply-chain fixes, tmux launch reliability, and a full security-architecture doc.
**Network binding (COD-29, #107):**
- The web server now defaults to binding `127.0.0.1` (loopback) instead of `0.0.0.0`, so a fresh install is reachable only from the same machine and needs no password. New `--host` / `-H` / `CODEMAN_HOST` flag to choose the bind host.
- Binding a non-loopback host **without** `CODEMAN_PASSWORD` no longer refuses to start — it **starts and prints a loud warning** with the three ways to secure it (set `CODEMAN_PASSWORD`, bind loopback + an authenticated tunnel / `tailscale serve`, or acknowledge with `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`). This keeps Codeman "just working" for new users while making remote exposure a guided, explicit choice. Host classification lives in the new `src/web/network-auth-policy.ts` (handles `127.0.0.0/8`, `::1`, `::ffff:127.*`, bracketed IPv6).
- A post-install security note now explains the loopback default and how to expose safely.
**Authentication (COD-29, #107):**
- Auth lockout now recovers gracefully: the per-IP rate-limit (`429`) check runs **after** the cookie/credential checks, so a valid session cookie or correct password is never locked out by a prior attacker's failures from the same IP (important behind a shared-IP tunnel). Wrong credentials are still counted and still hit the limit, and a `Retry-After` header is returned.
**Downloads & content-type hardening (COD-29, #107):**
- New session-scoped `POST /api/download` route: realpath-bounded to the session working dir, a sensitive-path blocklist (`/etc/shadow`, `~/.ssh/`, `.env`, `*credentials*`, …), `isFile()` + 50 MB cap, forced `attachment`.
- Workspace `.svg` files are served as `application/octet-stream` + `attachment` + `nosniff` (closes a stored-XSS-via-SVG vector); `nosniff` now applies to all `file-raw` responses.
**Dependencies & supply chain (COD-28, #106):**
- Bumped security-sensitive deps to patched versions (`@fastify/static` 9, `fastify` 5.8, `uuid` 14, `vitest` 4.1, …) and added `overrides` for patched transitives (`picomatch`, `basic-ftp`, `fast-uri`, `flatted`); `npm audit` goes from 7 advisories to 0.
- New `npm run check:public-assets` (`scripts/check-public-assets.mjs`): scans `src/web/public/**` for literal NUL bytes and runs `node --check` on every `.js` file, plus a Prettier pass on maintained files. Removed literal NUL placeholders from `app.js`. Added `test/dependency-security.test.ts` and `test/frontend-public-tooling.test.ts`.
**tmux launch reliability (COD-31, #110):**
- New tmux sessions and respawns launch from a stable `/tmp` and `cd` into the workspace inside the pane, avoiding `new-session` crashes when a FUSE/rclone-mounted workspace has a transient mount blip at launch. The `cd "<dir>" && <cmd>` form is fail-safe (the CLI never runs in `/tmp`) and the path is validated + double-quoted.
**Test stability (COD-30, #108):**
- Cleared leaked auth env in the Vitest setup, corrected stale route status-code / SSE-lifecycle expectations to match shipped behavior, updated the mobile keyboard accessory expectations, and measured DOMContentLoaded via browser navigation timing. Also fixed the `WebServer` title tests for the new `host` constructor arg + async `renderIndexHtml`.
**Docs:**
- New `docs/security-architecture.md` documenting the full model (network binding, auth pipeline, the tunnel `req.ip` caveat, file-serving hardening, supply-chain, multi-instance isolation, security headers, and recommended secure setups). CLAUDE.md updated accordingly.
## 0.8.2
### Patch Changes
+9 -3
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.8.2 (must match `package.json`)
**Version**: 0.9.0 (must match `package.json`)
## Project Overview
@@ -78,13 +78,15 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|------|---------|
| Dev with TLS | `npx tsx src/index.ts web --https` |
| Override window title hostname | `npx tsx src/index.ts web --title-hostname <name>` (default: `os.hostname()` — `codeman:<name>` is used for tab title, title-flash, and OS desktop notification prefix) |
| Bind a non-loopback host | `npx tsx src/index.ts web --host 0.0.0.0` (or `-H`; env `CODEMAN_HOST`; default `127.0.0.1`). Without `CODEMAN_PASSWORD` it **starts but warns loudly** — see Common Gotchas + `docs/security-architecture.md` |
| Continuous typecheck | `tsc --noEmit --watch` |
| Test coverage | `npm run test:coverage` |
| Dead-code sweep | `npm run knip` (config in `knip.json`) |
| Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) |
| Production start | `npm run start` |
| Production logs | `journalctl --user -u codeman-web -f` |
**CI**: `.github/workflows/ci.yml` runs `check:lockfile`, `typecheck`, `lint`, `format:check` on push to master/main and on PRs (Node 22). Tests excluded (they spawn tmux).
**CI**: `.github/workflows/ci.yml` runs `check:lockfile`, `typecheck`, `lint`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s) on push to master/main and on PRs (Node 22). The unit test suite is excluded (it spawns tmux).
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
@@ -99,6 +101,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
- **`xterm-zerolag-input` is duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
- **Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Full model: `docs/security-architecture.md`.**
- **Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`.
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
@@ -172,9 +175,12 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### Security
**Full model: [`docs/security-architecture.md`](docs/security-architecture.md)** — network binding, auth pipeline, the tunnel caveat, file-serving hardening, supply-chain, instance isolation, and recommended secure setups.
| Layer | Details |
|-------|---------|
| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` env vars |
| **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` |
| **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 |
+319
View File
@@ -0,0 +1,319 @@
# Security Architecture
This document describes Codeman's security model: how it decides who may reach
the web UI, how requests are authenticated, how the file-serving and tmux layers
are hardened, and the recommended ways to expose an instance safely.
Codeman spawns and drives Claude/OpenCode CLIs with
`--dangerously-skip-permissions`. **Anyone who can reach an unauthenticated
instance can run arbitrary commands as your user.** The defaults below are chosen
so that a fresh install is safe on the machine it runs on, while remote access is
an explicit, guided opt‑in.
> TL;DR — Codeman binds **loopback only (`127.0.0.1`) by default**, so out of the
> box it is reachable only from the same machine and needs no password. To reach
> it from elsewhere, either put it behind an **authenticated tunnel**
> (`tailscale serve` / `cloudflared`) **or** bind a wider host **and set
> `CODEMAN_PASSWORD`**. If you bind a non‑loopback host with no password, Codeman
> still starts but prints a **loud warning** telling you how to secure it.
---
## 1. Network binding model
| Setting | Default | Source |
|---------|---------|--------|
| Bind host | `127.0.0.1` (loopback) | `--host` / `CODEMAN_HOST` → `WebServer` ctor |
| Port | `3000` | `--port` / `CODEMAN_PORT` |
| TLS | off (`--https` to enable) | `--https` |
### Bind host classification
`isLoopbackBindHost()` (`src/web/network-auth-policy.ts`) decides whether a bind
host is loopback-only. It returns `true` for:
- `localhost`
- any IPv4 in `127.0.0.0/8` (e.g. `127.0.0.1`, `127.42.0.9`)
- IPv6 loopback `::1` (bracketed `[::1]` and the long form `0:0:0:0:0:0:0:1`)
- IPv4‑mapped loopback `::ffff:127.*`
It returns `false` for `0.0.0.0`, `::` (all interfaces), LAN IPs, and hostnames.
The classification is **fail‑safe in the dangerous direction**: any host that is
not provably loopback is treated as non‑loopback (it never mistakes `0.0.0.0`
for loopback). Shorthand forms like `127.1` or integer/octal IPs classify as
non‑loopback (you'll get a warning, not a silent wide‑open bind) — use
`127.0.0.1` for an unambiguous loopback bind.
### Startup policy (the "warn, don't block" rule)
At `WebServer.start()`:
| Bind host | `CODEMAN_PASSWORD` | Behavior |
|-----------|--------------------|----------|
| loopback (default) | unset | **Start.** Safe — reachable only from this machine. |
| loopback | set | **Start.** Auth required even locally. |
| non‑loopback | set | **Start.** Auth protects the open bind. |
| non‑loopback | unset | **Start + LOUD warning** listing how to secure it. |
| non‑loopback | unset, `--allow-unauthenticated-network` | **Start + terse acknowledged note.** |
> History: an earlier iteration (unreleased COD‑29) *refused to start* on a
> non‑loopback bind without a password. That surprised setups that "just worked"
> before, so **0.9.0 changed it to start‑and‑warn**. Loopback is still the safe
> default; the warning (with three concrete fixes) replaces the hard failure.
The warning points at three ways to secure the instance:
1. `CODEMAN_PASSWORD=<password>` — turns on HTTP Basic auth (see §2).
2. `--host 127.0.0.1` + an authenticated tunnel (`cloudflared` / `tailscale serve`).
3. `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`
— explicitly accept the risk (downgrades the warning to a one‑line note). This
flag is **only** an acknowledgement; it does not change reachability.
`CODEMAN_API_URL` (used by hooks/child processes) is always derived as a loopback
address (`0.0.0.0`/`localhost`/`::1` → `127.0.0.1`) so in‑process hooks reach the
server over loopback regardless of the public bind.
---
## 2. Authentication
Auth is **optional** and controlled by env vars captured at startup:
- `CODEMAN_USERNAME` (default `admin` when only a password is set)
- `CODEMAN_PASSWORD`
When `CODEMAN_PASSWORD` is unset, no auth is enforced — which is why the default
loopback bind matters. The auth pipeline (`src/web/middleware/auth.ts`,
`onRequest` hook) runs in this order:
1. **Localhost‑only exemptions** (always first): `POST /api/hook-event` and the QR
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3).
2. **Session cookie** check — a valid `codeman_session` cookie short‑circuits to
allow.
3. **HTTP Basic** check — correct credentials short‑circuit to allow and clear
that IP's failure counter.
4. **Rate‑limit gate** — if neither cookie nor credentials passed and the IP is
locked out, return `429` with a `Retry-After` header.
5. Otherwise return `401`, incrementing the IP's failure counter.
### Session cookies
On successful Basic auth the server issues `codeman_session`, an opaque
server‑side token (`randomBytes(32)`), valid 24h with auto‑extend and device
context for the audit log. Tokens are **not** client‑signed — they're validated
by presence in a server‑side map, so they cannot be forged offline.
### Rate limiting / lockout recovery
Failed auth is tracked **per IP**: 10 failures → `429`, with a 15‑minute decay.
The QR path has its own separate limiter.
The lockout check sits **after** the cookie/credential checks (step 4, not first).
This is deliberate: a user with a **valid cookie or correct password recovers
immediately** even while an attacker is hammering the same IP — important because
all traffic through a tunnel shares one source IP (loopback). Wrong credentials
are still counted and still hit the `429` at the threshold, so brute‑force
protection is unchanged.
---
## 3. Request‑origin trust & the tunnel caveat
`req.ip` is derived from the **TCP socket only** — Fastify runs with
`trustProxy: false`, so `X-Forwarded-For` / `X-Real-IP` / `Forwarded` are
**ignored**. A remote client cannot forge `req.ip` to `127.0.0.1`.
**However**, a reverse tunnel that connects to the server over loopback (e.g.
`cloudflared --url http://localhost:3000`) makes **every tunneled request arrive
with `req.ip = 127.0.0.1`**. The localhost‑only exemptions then treat those
requests as local:
- `POST /api/hook-event` — auth‑exempt for loopback. Bounded impact: it is
`HookEventSchema`‑validated and requires a valid in‑memory `sessionId`; it can
drive respawn signals, SSE broadcasts, push notifications, and transcript
watching — **not** arbitrary terminal input or file reads. It is a
session‑disruption / notification‑spoofing surface, not RCE.
- QR `/q/` — still protected by its own short‑code brute‑force limiter
(10 failures / 60s against a 62⁶ space).
**Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is
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.
---
## 4. Recommended remote‑access setups
Ordered most‑to‑least recommended:
### A. Tailscale serve (recommended)
Bind loopback, let Tailscale front it on your tailnet with a real cert:
```bash
codeman web --https # binds 127.0.0.1:3000
tailscale serve --bg https / http://127.0.0.1:3000
```
Only devices on your tailnet can reach it; Tailscale handles identity. No app
password and no `0.0.0.0` bind required. (This is the maintainer's production
setup.)
### B. Authenticated cloudflared tunnel + password
```bash
export CODEMAN_PASSWORD=<password>
codeman web --https
cloudflared tunnel --url https://localhost:3000
```
Always set `CODEMAN_PASSWORD` here — the tunnel connects over loopback, so the
hook‑event exemption (§3) would otherwise be reachable from the public URL.
### C. Direct LAN bind + password
```bash
export CODEMAN_PASSWORD=<password>
codeman web --https --host 0.0.0.0
```
Exposes the port on all interfaces; the password is the only thing protecting it.
### Avoid
`--host 0.0.0.0` **without** a password. Codeman will start (and warn), but
anyone on the network can control your Claude sessions. Never re‑expose `0.0.0.0`
without a password.
---
## 5. File‑serving hardening
Three routes serve workspace files; all require a valid `sessionId` and run the
shared path validator `validateSessionFilePath()` (`src/web/route-helpers.ts`):
it `realpath`s the target **before** the boundary check and rejects anything that
escapes the session working directory (`..`, absolute paths, and symlinks that
resolve outside). The realpath‑before‑check ordering closes the validation‑time
TOCTOU window.
| Route | Cap | Notes |
|-------|-----|-------|
| `file-content` | 10 MB | text preview |
| `file-raw` | 50 MB | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses** |
| `POST /api/download` | 50 MB | forced `attachment`; sensitive‑path blocklist |
### SVG / content‑type XSS
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.
### Download sensitive‑path blocklist
`/api/download` additionally refuses a blocklist of sensitive paths
(`/etc/shadow`, `~/.ssh/`, `.env`, `*credentials*`, `.aws/credentials`, …). This
is **defense‑in‑depth, not the primary boundary** — the realpath containment is
the control.
### Known limitation — `workingDir` scope
The file‑route boundary is the session's `workingDir`, and `POST /api/sessions`
currently accepts an arbitrary absolute `workingDir` (validated as "exists + is a
directory"). A session created with `workingDir=/` can therefore read files
across the filesystem within that boundary. This is **pre‑existing** across all
file routes and not widened by the recent changes. Recommended follow‑up:
constrain `workingDir` to an allowlist (e.g. under the cases dir / `$HOME`).
---
## 6. tmux launch hardening (COD‑31)
New sessions and respawns launch the tmux server/pane from a stable `/tmp`
(`TMUX_LAUNCH_CWD`) and then `cd` into the real workspace **inside** the pane,
against the live mount table:
```
respawn-pane -k -c /tmp -t <session> bash -c "cd <workingDir> && <cmd>"
```
This avoids a class of failures on FUSE/rclone‑mounted workspaces where a
transient mount blip at launch poisons tmux's long‑lived cwd and crashes
`new-session`. Safety properties:
- **Fail‑safe cwd:** the command is `cd "<dir>" && <cmd>` — if `cd` fails the CLI
does **not** run in `/tmp`; the pane dies with a visible error instead.
- **No injection:** `workingDir` passes `isValidWorkingDir` (absolute, rejects
`;&|$\`(){}<>'"` and newlines and `..`) and `isValidPath`, and is double‑quoted
in the pane command. Paths with spaces work; metacharacters are rejected before
reaching the shell.
- It does not change which tmux socket is targeted, so instance isolation (§8) is
preserved.
---
## 7. Supply‑chain & build‑asset hardening (COD‑28)
- **Dependency advisories:** security‑sensitive ranges are bumped to patched
versions, and `overrides` force patched transitive deps (`picomatch`,
`basic-ftp`, `fast-uri`, `flatted`). `test/dependency-security.test.ts` asserts
these stay patched in the lockfile.
- **Lockfile integrity:** `npm run check:lockfile` (CI on every push/PR) fails on
drift between `package.json` and `package-lock.json`. All lockfile entries
resolve to `registry.npmjs.org` with `sha512` integrity hashes.
- **Public‑asset checker:** `npm run check:public-assets`
(`scripts/check-public-assets.mjs`) scans `src/web/public/**` for literal NUL
bytes and runs `node --check` on every `.js` file (syntax validation), plus a
Prettier pass on maintained files. It uses `execFileSync` with argv arrays (no
shell), so filenames/content cannot inject commands; `node --check` only parses,
never executes. Large hand‑formatted/generated assets (`app.js`, the gesture
bundle, vendored libs) are `.prettierignore`d for the style pass, but the NUL +
syntax checks still cover them.
---
## 8. Multi‑instance isolation
The tmux socket (`tmux -L codeman[-<instance>]`) and data dir
(`~/.codeman[-<instance>]`) are **process‑wide and shared by every Codeman on the
machine**, derived from `CODEMAN_INSTANCE` (`src/config/instance.ts`). A second
instance on the **same** socket discovers and attaches PTYs to the first
instance's live sessions. To run instances side by side, give each a distinct
`CODEMAN_INSTANCE` (scopes both dir + socket), or set `CODEMAN_TMUX_SOCKET` +
`CODEMAN_DATA_DIR` individually. `CODEMAN_INSTANCE` defaults to empty = the
production layout (`~/.codeman`, `-L codeman`, port 3000).
---
## 9. Transport security headers
`registerSecurityHeaders` 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
---
## 10. Quick reference
| Env / flag | Effect |
|------------|--------|
| `CODEMAN_PASSWORD` (+ `CODEMAN_USERNAME`) | Enable HTTP Basic auth |
| `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) |
| `--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 |
| `CODEMAN_GESTURE=1` | Make the gesture overlay available (widens CSP) |
**Audit log:** session lifecycle and server start are recorded in
`~/.codeman/session-lifecycle.jsonl`.
+3867 -2696
View File
File diff suppressed because it is too large Load Diff
+26 -11
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.8.2",
"version": "0.9.0",
"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",
@@ -21,8 +21,9 @@
"typecheck": "tsc --noEmit",
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
"format": "prettier --write 'src/**/*.ts'",
"format:check": "prettier --check 'src/**/*.ts'",
"format": "prettier --write 'src/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
"format:check": "prettier --check 'src/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
"check:public-assets": "node scripts/check-public-assets.mjs",
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
"changeset": "changeset",
"version-packages": "changeset version && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
@@ -53,7 +54,7 @@
"@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^8.0.0",
"@fastify/static": "^9.1.3",
"@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
@@ -62,18 +63,18 @@
"chalk": "^5.3.0",
"chokidar": "^3.6.0",
"commander": "^12.1.0",
"fastify": "^5.1.0",
"fastify": "^5.8.5",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"uuid": "^10.0.0",
"uuid": "^14.0.0",
"web-push": "^3.6.7",
"zod": "^4.3.6"
},
"devDependencies": {
"@changesets/cli": "^2.29.8",
"@eslint/js": "^9.0.0",
"@remotion/cli": "4.0.429",
"@remotion/transitions": "4.0.429",
"@remotion/cli": "4.0.473",
"@remotion/transitions": "4.0.473",
"@types/node": "^20.19.33",
"@types/pngjs": "^6.0.5",
"@types/qrcode": "^1.5.6",
@@ -81,7 +82,7 @@
"@types/uuid": "^10.0.0",
"@types/web-push": "^3.6.4",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/coverage-v8": "^4.1.8",
"agent-browser": "^0.6.0",
"esbuild": "^0.27.3",
"eslint": "^9.0.0",
@@ -90,16 +91,30 @@
"pngjs": "^7.0.0",
"prettier": "^3.4.0",
"puppeteer": "^24.36.0",
"remotion": "4.0.429",
"remotion": "4.0.473",
"tsx": "^4.15.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.0.0",
"vitest": "^4.0.18"
"vitest": "^4.1.8"
},
"optionalDependencies": {
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
"@rspack/binding-linux-x64-gnu": "^1.7.7"
},
"overrides": {
"basic-ftp": "^5.3.1",
"fast-uri": "^3.1.2",
"flatted": "^3.4.2",
"anymatch": {
"picomatch": "^2.3.2"
},
"micromatch": {
"picomatch": "^2.3.2"
},
"readdirp": {
"picomatch": "^2.3.2"
}
},
"engines": {
"node": ">=18.0.0"
},
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -45,6 +45,6 @@
"jsdom": "^24.1.3",
"tsup": "^8.5.1",
"typescript": "^5.5.0",
"vitest": "^2.1.9"
"vitest": "^4.1.8"
}
}
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const publicRoot = resolve(repoRoot, 'src/web/public');
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
function collectTextAssets(dir) {
const files = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...collectTextAssets(fullPath));
continue;
}
if (checkedExtensions.has(extname(entry.name))) {
files.push(fullPath);
}
}
return files;
}
function findNullByte(buffer) {
for (let i = 0; i < buffer.length; i += 1) {
if (buffer[i] === 0) return i;
}
return -1;
}
const files = collectTextAssets(publicRoot);
const failures = [];
for (const file of files) {
const rel = relative(repoRoot, file);
const data = readFileSync(file);
const nullByteIndex = findNullByte(data);
if (nullByteIndex !== -1) {
failures.push(`${rel}: contains literal NUL byte at offset ${nullByteIndex}`);
}
if (extname(file) === '.js') {
try {
execFileSync(process.execPath, ['--check', file], { cwd: repoRoot, stdio: 'pipe' });
} catch (err) {
failures.push(`${rel}: JavaScript syntax check failed\n${String(err.stderr || err.message).trim()}`);
}
}
}
try {
execFileSync(prettierBin, ['--check', ...files], { cwd: repoRoot, stdio: 'pipe' });
} catch (err) {
failures.push(`Prettier public asset check failed\n${String(err.stdout || err.stderr || err.message).trim()}`);
}
if (failures.length > 0) {
console.error(failures.join('\n\n'));
process.exit(1);
}
console.log(`Public asset checks passed (${files.length} files).`);
+13
View File
@@ -460,3 +460,16 @@ if (process.env.CI || process.env.CODEMAN_NO_AUTOSTART) {
}
}
}
// ----------------------------------------------------------------------------
// Security note — printed on every install path
// ----------------------------------------------------------------------------
console.log(colors.bold('Security:'));
console.log(colors.dim(' Codeman binds ') + colors.cyan('127.0.0.1') + colors.dim(' (this machine only) — no password needed by default.'));
console.log(colors.dim(' To reach it from another device, do ONE of:'));
console.log(colors.dim(' • ') + colors.cyan('tailscale serve') + colors.dim(' / ') + colors.cyan('cloudflared tunnel') + colors.dim(' (recommended), or'));
console.log(colors.dim(' • ') + colors.cyan('codeman web --host 0.0.0.0') + colors.dim(' AND set ') + colors.cyan('CODEMAN_PASSWORD'));
console.log(colors.dim(' A non-loopback bind without a password still starts, but warns loudly.'));
console.log(colors.dim(' Details: docs/security-architecture.md'));
console.log('');
+11 -3
View File
@@ -483,21 +483,29 @@ program
program
.command('web')
.description('Start the web interface')
.option('-H, --host <host>', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1')
.option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000')
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
.option(
'--allow-unauthenticated-network',
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
)
.action(async (options) => {
const { startWebServer } = await import('./web/server.js');
const host = options.host;
const port = parseInt(options.port, 10);
const https = !!options.https;
const titleHostname = options.titleHostname;
const allowUnauthenticatedNetwork = !!options.allowUnauthenticatedNetwork;
const protocol = https ? 'https' : 'http';
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`));
console.log(chalk.cyan(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
try {
const server = await startWebServer(port, https, false, titleHostname);
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://localhost:${port}`));
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork);
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://${displayHost}:${port}`));
if (https) {
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
}
+24 -10
View File
@@ -73,6 +73,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000;
/** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */
const TMUX_LAUNCH_CWD = '/tmp';
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
@@ -679,8 +682,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// (Production uses systemd which has a clean env, but dev/test may be nested.)
const cleanEnv = { ...process.env };
delete cleanEnv.TMUX;
execSync(`${this.tmux()} new-session -ds "${muxName}" -c "${workingDir}"`, {
cwd: workingDir,
// Start the tmux server from a stable local cwd so FUSE/rclone workspace
// blips do not poison tmux's long-lived getcwd state.
execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, {
cwd: TMUX_LAUNCH_CWD,
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
env: cleanEnv,
@@ -706,11 +711,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// so secret values stay off the bash command line. Must run before respawn-pane.
this.applyEnvOverrides(muxName, envOverrides);
// Replace the shell with the actual command (no echo in terminal)
execSync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
// Replace the shell with the actual command (no echo in terminal). Keep
// pane launch in /tmp, then cd inside bash against the current mount table.
const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
execSync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
{
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
}
);
// Wait for tmux session to be queryable
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
@@ -890,9 +900,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides);
await execAsync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS,
});
const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
await execAsync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
{
timeout: EXEC_TIMEOUT_MS,
}
);
// Wait for the respawned process to start
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
const pid = this.getPanePid(muxName);
+15 -8
View File
@@ -8,7 +8,7 @@
* - CORS (localhost only)
*/
import { FastifyInstance } from 'fastify';
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';
@@ -69,6 +69,13 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
const authSessions = state.authSessions;
const authFailures = state.authFailures;
function sendAuthRateLimit(reply: FastifyReply, clientIp: string): void {
const remainingMs = authFailures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
reply.header('Retry-After', String(retryAfterSeconds));
reply.code(429).send('Too Many Requests — try again later');
}
app.addHook('onRequest', (req, reply, done) => {
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
// Safe: validated by HookEventSchema, only triggers broadcasts.
@@ -90,13 +97,6 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
const clientIp = req.ip;
// Rate limit: reject if too many failed attempts from this IP
const failures = authFailures.get(clientIp) ?? 0;
if (failures >= AUTH_FAILURE_MAX) {
reply.code(429).send('Too Many Requests — try again later');
return;
}
// Check session cookie first (avoids re-sending credentials on every request)
// Use get() instead of has() so refreshOnGet extends the TTL on active sessions
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
@@ -140,6 +140,13 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
return;
}
// Rate limit only requests that failed to authenticate on this attempt.
const failures = authFailures.get(clientIp) ?? 0;
if (failures >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, clientIp);
return;
}
// Auth failed — track failure count
authFailures.set(clientIp, failures + 1);
+21
View File
@@ -0,0 +1,21 @@
import { isIP } from 'node:net';
const EXPLICIT_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
export function isExplicitlyEnabled(value: string | undefined): boolean {
return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase());
}
export function isLoopbackBindHost(host: string): boolean {
const normalized = host
.trim()
.toLowerCase()
.replace(/^\[(.*)\]$/, '$1');
if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') {
return true;
}
if (isIP(normalized) === 4 && normalized.startsWith('127.')) {
return true;
}
return normalized.startsWith('::ffff:127.');
}
+3 -3
View File
@@ -1366,7 +1366,7 @@ class CodemanApp {
const placeholders = [];
const masked = text.replace(fenceRe, (m) => {
placeholders.push(m);
return `FENCE${placeholders.length - 1}`;
return `__CODEMAN_FENCE_${placeholders.length - 1}__`;
});
// Split on blank-line paragraph boundaries; wrap any paragraph containing
@@ -1376,13 +1376,13 @@ class CodemanApp {
.map((chunk) => {
if (/^\n{2,}$/.test(chunk)) return chunk; // keep separators
if (!chunk.trim()) return chunk;
if (chunk.includes('FENCE')) return chunk;
if (chunk.includes('__CODEMAN_FENCE_')) return chunk;
if (BOX_PATTERN.test(chunk)) return '\n```\n' + chunk + '\n```\n';
return chunk;
})
.join('');
return processed.replace(/FENCE(\d+)/g, (_m, i) => placeholders[Number(i)]);
return processed.replace(/__CODEMAN_FENCE_(\d+)__/g, (_m, i) => placeholders[Number(i)]);
}
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
+113 -7
View File
@@ -4,7 +4,8 @@
*/
import { FastifyInstance } from 'fastify';
import { join } from 'node:path';
import { basename as pathBasename, join } from 'node:path';
import { homedir } from 'node:os';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js';
@@ -278,7 +279,6 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
ico: 'image/x-icon',
bmp: 'image/bmp',
mp4: 'video/mp4',
@@ -292,19 +292,21 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
};
const content = await fs.readFile(resolvedPath);
if (download === 'true') {
const rawBasename = filePath!.split('/').pop() || 'download';
// Sanitize filename for Content-Disposition header (prevent header injection)
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
const rawBasename = filePath!.split('/').pop() || 'download';
// Sanitize filename for Content-Disposition header (prevent header injection)
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
if (download === 'true' || ext === 'svg') {
reply.raw.writeHead(200, {
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
'Content-Type': ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${basename}"`,
'Content-Length': content.length,
'X-Content-Type-Options': 'nosniff',
});
reply.raw.end(content);
return;
}
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
reply.header('X-Content-Type-Options', 'nosniff');
reply.send(content);
} catch (err) {
reply
@@ -380,4 +382,108 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
const closed = fileStreamManager.closeStream(streamId);
return { success: closed };
});
// Session-scoped file download.
// Uses the same realpath-based workspace boundary as file preview/raw routes;
// the sensitive-path blocklist remains defense-in-depth, not the primary boundary.
const SENSITIVE_PATTERNS: RegExp[] = [
/^\/etc\/shadow$/,
/^\/etc\/gshadow$/,
/^\/etc\/master\.passwd$/,
new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
/\/\.env$/,
/\/\.env\./,
/\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
/\/\.aws\/credentials$/,
/\/\.gcloud\/credentials\.db$/,
/\/\.docker\/config\.json$/,
];
function isSensitivePath(absPath: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath));
}
app.get('/api/download', async (req, reply) => {
const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string };
if (!filePath) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
return;
}
if (!sessionId) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing sessionId parameter'));
return;
}
const session = findSessionOrFail(ctx, sessionId);
const validated = validateSessionFilePath(session.workingDir, filePath);
if (!validated) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
const { resolvedPath } = validated;
// Check sensitive path blocklist
if (isSensitivePath(resolvedPath)) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
return;
}
try {
const stat = await fs.stat(resolvedPath);
if (!stat.isFile()) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is not a file'));
return;
}
// 50MB size limit
const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
if (stat.size > MAX_DOWNLOAD_SIZE) {
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > 50MB limit)`
)
);
return;
}
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const mimeTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
pdf: 'application/pdf',
json: 'application/json',
txt: 'text/plain',
md: 'text/markdown',
csv: 'text/csv',
xml: 'application/xml',
zip: 'application/zip',
gz: 'application/gzip',
tar: 'application/x-tar',
};
const filename = pathBasename(resolvedPath);
const content = await fs.readFile(resolvedPath);
// Bypass Fastify compression — write directly to raw response
reply.raw.writeHead(200, {
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': content.length,
});
reply.raw.end(content);
return;
} catch (err) {
reply
.code(500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
});
}
+49 -14
View File
@@ -102,6 +102,7 @@ import { SseEvent } from './sse-events.js';
import type { ScheduledRun } from './ports/index.js';
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import { isExplicitlyEnabled, isLoopbackBindHost } from './network-auth-policy.js';
import {
registerPushRoutes,
registerTeamRoutes,
@@ -191,6 +192,7 @@ export class WebServer extends EventEmitter {
private sse: SseStreamManager;
private store = getStore();
private port: number;
private host: string;
private https: boolean;
private testMode: boolean;
private mux: TerminalMultiplexer;
@@ -232,6 +234,10 @@ export class WebServer extends EventEmitter {
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
private readonly titleHostname: string;
private readonly windowTitle: string;
private readonly indexHtmlTemplate: string;
private readonly allowUnauthenticatedNetwork: boolean;
private _pasteImageGcStop: (() => void) | null = null;
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
private teamWatcherHandlers: {
@@ -240,15 +246,22 @@ export class WebServer extends EventEmitter {
teamRemoved: (config: unknown) => void;
taskUpdated: (data: unknown) => void;
} | null = null;
private readonly titleHostname: string;
private readonly windowTitle: string;
private readonly indexHtmlTemplate: string;
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false, titleHostname?: string) {
constructor(
port: number = 3000,
https: boolean = false,
testMode: boolean = false,
host: string = '127.0.0.1',
titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false
) {
super();
this.setMaxListeners(0);
this.host = host;
this.port = port;
this.https = https;
this.testMode = testMode;
this.allowUnauthenticatedNetwork =
allowUnauthenticatedNetwork || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
this.titleHostname = titleHostname || getHostname();
this.windowTitle = `codeman:${this.titleHostname}`;
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
@@ -1672,19 +1685,39 @@ export class WebServer extends EventEmitter {
this._eventLoopMonitor = startEventLoopMonitor();
}
await this.app.listen({ port: this.port, host: '0.0.0.0' });
await this.app.listen({ port: this.port, host: this.host });
const protocol = this.https ? 'https' : 'http';
console.log(`Codeman web interface running at ${protocol}://localhost:${this.port}`);
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`);
// Security warning: server binds to 0.0.0.0 (all interfaces) — warn if no auth configured
if (!process.env.CODEMAN_PASSWORD) {
console.warn('\n⚠ WARNING: No CODEMAN_PASSWORD set — server is accessible without authentication.');
console.warn(' Anyone on your network can access and control Claude sessions.');
console.warn(' Set CODEMAN_PASSWORD environment variable to enable auth.\n');
// 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
// "just worked" before. Instead we start and warn loudly, pointing at the ways
// to secure it. --allow-unauthenticated-network just acknowledges the risk (a
// terser note). See docs/security-architecture.md.
if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD) {
if (this.allowUnauthenticatedNetwork) {
console.warn(
`\n⚠ Codeman is reachable WITHOUT a password on ${displayHost}:${this.port} ` +
'(explicitly allowed). Anyone who can reach it can control your Claude sessions.\n'
);
} else {
console.warn(`\n⚠ WARNING: Codeman is bound to a non-loopback host (${this.host}) with NO password.`);
console.warn(` Anyone who can reach ${displayHost}:${this.port} can control your Claude sessions.`);
console.warn(' Secure it with ONE of:');
console.warn(' • set CODEMAN_PASSWORD=<password> (HTTP Basic auth), or');
console.warn(' • bind loopback only: --host 127.0.0.1, then front it with an');
console.warn(' authenticated tunnel (cloudflared) or `tailscale serve`, or');
console.warn(' • keep this bind and accept the risk: --allow-unauthenticated-network');
console.warn(' See docs/security-architecture.md for details.\n');
}
}
// Set API URL for child processes (MCP server, spawned sessions)
process.env.CODEMAN_API_URL = `${protocol}://localhost:${this.port}`;
const apiHost =
this.host === '0.0.0.0' || this.host === 'localhost' || this.host === '::1' ? '127.0.0.1' : this.host;
process.env.CODEMAN_API_URL = `${protocol}://${apiHost}:${this.port}`;
// Start scheduled runs cleanup timer
this.cleanup.setInterval(
@@ -2176,9 +2209,11 @@ export async function startWebServer(
port: number = 3000,
https: boolean = false,
testMode: boolean = false,
titleHostname?: string
host: string = '127.0.0.1',
titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false
): Promise<WebServer> {
const server = new WebServer(port, https, testMode, titleHostname);
const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork);
await server.start();
return server;
}
+147 -38
View File
@@ -3,26 +3,61 @@
* 1. Timing-safe password comparison (timingSafeEqual)
* 2. Hook event endpoint restricted to localhost
* 3. Session cookie TTL refresh on access
* 4. Startup warning when no password configured
* 4. Startup fails closed when network-bound without auth
* 5. SSE client limit enforcement
* 6. Logout endpoint invalidates session
* 7. Settings schema rejects unknown fields
*
* Port: 3160 (auth tests), 3161 (no-auth tests)
* Port: 3160 (auth tests), 3161 (loopback no-auth tests), 3162 (network override tests)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const AUTH_PORT = 3160;
const NOAUTH_PORT = 3161;
const NETWORK_OVERRIDE_PORT = 3162;
const AUTH_RATE_LIMIT_PORT = 3220;
const NOAUTH_NETWORK_PORT = 3221;
const TEST_USER = 'admin';
const TEST_PASS = 'test-password-12345';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
function basicAuthHeader(user: string, pass: string): string {
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
}
async function startAuthServer(port: number): Promise<{ server: WebServer; baseUrl: string }> {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
const server = new WebServer(port, false, true);
await server.start();
return { server, baseUrl: `http://localhost:${port}` };
}
async function getSessionCookie(baseUrl: string): Promise<string> {
const res = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
});
expect(res.status).toBe(200);
const setCookie = res.headers.get('set-cookie');
expect(setCookie).toBeTruthy();
const cookieMatch = setCookie!.match(/codeman_session=([^;]+)/);
expect(cookieMatch).toBeTruthy();
return `codeman_session=${cookieMatch![1]}`;
}
async function exhaustAuthFailures(baseUrl: string, prefix: string): Promise<void> {
for (let i = 0; i < 10; i++) {
const res = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, `${prefix}-${i}`) },
});
expect(res.status).toBe(401);
}
}
describe('Auth Security', () => {
let server: WebServer;
let baseUrl: string;
@@ -154,29 +189,63 @@ describe('Auth Security', () => {
});
describe('Rate Limiting', () => {
it('should block after too many failed attempts', async () => {
// Send 10 failed attempts
for (let i = 0; i < 10; i++) {
await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-' + i) },
});
}
let rateServer: WebServer;
let rateBaseUrl: string;
// 11th attempt should be rate-limited
const res = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-again') },
});
expect(res.status).toBe(429);
beforeEach(async () => {
({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer(AUTH_RATE_LIMIT_PORT));
});
it('should rate-limit even with correct credentials after lockout', async () => {
// After being rate-limited, even correct credentials should fail
const res = await fetch(`${baseUrl}/api/status`, {
afterEach(async () => {
await rateServer.stop();
});
it('should rate-limit wrong credentials after too many failed attempts', async () => {
await exhaustAuthFailures(rateBaseUrl, 'cod21-wrong');
const res = await fetch(`${rateBaseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-again') },
});
expect(res.status).toBe(429);
expect(res.headers.get('retry-after')).toMatch(/^\d+$/);
});
it('should allow an existing valid session cookie during auth failure lockout', async () => {
const cookie = await getSessionCookie(rateBaseUrl);
await exhaustAuthFailures(rateBaseUrl, 'cod21-cookie');
const res = await fetch(`${rateBaseUrl}/api/status`, {
headers: { Cookie: cookie },
});
expect(res.status).toBe(200);
});
it('should allow correct credentials to recover from auth failure lockout', async () => {
await exhaustAuthFailures(rateBaseUrl, 'cod21-recover');
const res = await fetch(`${rateBaseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
});
// Rate limit is per-IP and the previous test used the same IP
// This test verifies rate limiting isn't bypassed by correct creds
expect(res.status).toBe(429);
expect(res.status).toBe(200);
expect(res.headers.get('set-cookie')).toContain('codeman_session=');
});
it('should clear failed attempt count after correct credentials recover access', async () => {
await exhaustAuthFailures(rateBaseUrl, 'cod21-clear');
const recoveryRes = await fetch(`${rateBaseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
});
expect(recoveryRes.status).toBe(200);
const wrongAfterRecovery = await fetch(`${rateBaseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-after-recovery') },
});
expect(wrongAfterRecovery.status).toBe(401);
});
});
@@ -220,7 +289,7 @@ describe('Settings Schema Security', () => {
it('should enforce tunnelEnabled as boolean', () => {
const result = SettingsUpdateSchema.safeParse({
tunnelEnabled: 'yes', // truthy string — should be rejected
tunnelEnabled: 'yes', // truthy string — should be rejected
});
expect(result.success).toBe(false);
});
@@ -264,40 +333,80 @@ describe('Settings Schema Security', () => {
expect(validResult.success).toBe(true);
const invalidResult = SettingsUpdateSchema.safeParse({
nice: { enabled: true, niceValue: 100 }, // Out of range
nice: { enabled: true, niceValue: 100 }, // Out of range
});
expect(invalidResult.success).toBe(false);
});
});
describe('No-Auth Server Warning', () => {
describe('No-Auth Server Startup Policy', () => {
let server: WebServer;
let consoleWarnSpy: string[] = [];
const originalWarn = console.warn;
beforeAll(async () => {
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
consoleWarnSpy = [];
console.warn = (...args: unknown[]) => {
consoleWarnSpy.push(args.map(String).join(' '));
};
server = new WebServer(NOAUTH_PORT, false, true);
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
server = new WebServer(NOAUTH_PORT, false, true, '127.0.0.1');
await server.start();
});
afterAll(async () => {
console.warn = originalWarn;
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
await server.stop();
});
it('should warn when no CODEMAN_PASSWORD is set', () => {
const hasWarning = consoleWarnSpy.some(msg => msg.includes('No CODEMAN_PASSWORD set'));
expect(hasWarning).toBe(true);
});
it('should allow requests without auth when no password configured', async () => {
it('allows loopback requests without auth when no password is configured', async () => {
const res = await fetch(`http://localhost:${NOAUTH_PORT}/api/status`);
expect(res.status).toBe(200);
});
it('starts with a loud warning (not a hard failure) on a non-loopback bind without a password', async () => {
// Policy (0.9.0): loopback is the safe default, but opting into a non-loopback
// bind without a password no longer refuses to start — it starts and warns,
// pointing at how to secure it. See docs/security-architecture.md.
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const networkServer = new WebServer(NOAUTH_NETWORK_PORT, false, true, '0.0.0.0');
await expect(networkServer.start()).resolves.toBeUndefined();
const res = await fetch(`http://localhost:${NOAUTH_NETWORK_PORT}/api/status`);
expect(res.status).toBe(200);
const warned = warnSpy.mock.calls.flat().join('\n');
expect(warned).toMatch(/non-loopback host|NO password/i);
expect(warned).toMatch(/CODEMAN_PASSWORD/);
warnSpy.mockRestore();
await networkServer.stop();
});
it('allows non-loopback startup when CODEMAN_PASSWORD is configured', async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
const networkServer = new WebServer(0, false, true, '0.0.0.0');
await networkServer.start();
await networkServer.stop();
delete process.env.CODEMAN_PASSWORD;
});
it('allows non-loopback startup with the explicit unauthenticated-network override', async () => {
const networkServer = new WebServer(NETWORK_OVERRIDE_PORT, false, true, '0.0.0.0', undefined, true);
await networkServer.start();
const res = await fetch(`http://localhost:${NETWORK_OVERRIDE_PORT}/api/status`);
expect(res.status).toBe(200);
await networkServer.stop();
});
it('allows non-loopback startup with the explicit unauthenticated-network env override', async () => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = 'true';
const networkServer = new WebServer(0, false, true, '0.0.0.0');
await networkServer.start();
await networkServer.stop();
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
});
});
+34 -16
View File
@@ -5,6 +5,7 @@
*/
import { describe, it, expect } from 'vitest';
import { program } from '../src/cli.js';
describe('CLI Command Parsing', () => {
describe('Command Structure', () => {
@@ -72,11 +73,11 @@ describe('CLI Command Parsing', () => {
];
const findCommand = (name: string): Command | undefined => {
return commands.find(c => c.name === name || c.aliases.includes(name));
return commands.find((c) => c.name === name || c.aliases.includes(name));
};
const findSubcommand = (parent: Command, name: string): Command | undefined => {
return parent.subcommands?.find(c => c.name === name || c.aliases.includes(name));
return parent.subcommands?.find((c) => c.name === name || c.aliases.includes(name));
};
it('should find commands by name', () => {
@@ -103,7 +104,7 @@ describe('CLI Command Parsing', () => {
});
it('should have descriptions for all commands', () => {
commands.forEach(cmd => {
commands.forEach((cmd) => {
expect(cmd.description).toBeTruthy();
});
});
@@ -267,13 +268,13 @@ describe('CLI Command Parsing', () => {
});
it('should have defaults for web flags', () => {
webFlags.forEach(flag => {
webFlags.forEach((flag) => {
expect(flag.default).toBeDefined();
});
});
it('should have defaults for tui flags', () => {
tuiFlags.forEach(flag => {
tuiFlags.forEach((flag) => {
expect(flag.default).toBeDefined();
});
});
@@ -322,7 +323,7 @@ describe('CLI Command Parsing', () => {
help += `${description}\n`;
if (options.length > 0) {
help += '\nOptions:\n';
options.forEach(opt => {
options.forEach((opt) => {
help += ` ${opt}\n`;
});
}
@@ -345,6 +346,15 @@ describe('CLI Command Parsing', () => {
expect(help).toContain('--host');
});
it('documents the unauthenticated network override in real web command help', () => {
const webCommand = program.commands.find((command) => command.name() === 'web');
expect(webCommand).toBeDefined();
const help = webCommand!.helpInformation();
expect(help).toContain('--allow-unauthenticated-network');
expect(help).toMatch(/without\s+CODEMAN_PASSWORD/);
});
it('should format properly', () => {
const help = generateHelp('test', 'Test command', ['--flag']);
const lines = help.split('\n');
@@ -474,22 +484,27 @@ describe('CLI Output Formatting', () => {
}
const formatRow = (values: string[], columns: Column[]): string => {
return values.map((val, i) => {
const width = columns[i]?.width || 10;
return val.padEnd(width).substring(0, width);
}).join(' ');
return values
.map((val, i) => {
const width = columns[i]?.width || 10;
return val.padEnd(width).substring(0, width);
})
.join(' ');
};
const formatTable = (headers: string[], rows: string[][], widths: number[]): string => {
const columns = headers.map((h, i) => ({ header: h, width: widths[i] }));
const headerRow = formatRow(headers, columns);
const separator = columns.map(c => '-'.repeat(c.width)).join(' ');
const dataRows = rows.map(row => formatRow(row, columns));
const separator = columns.map((c) => '-'.repeat(c.width)).join(' ');
const dataRows = rows.map((row) => formatRow(row, columns));
return [headerRow, separator, ...dataRows].join('\n');
};
it('should format single row', () => {
const columns = [{ header: 'ID', width: 10 }, { header: 'Status', width: 8 }];
const columns = [
{ header: 'ID', width: 10 },
{ header: 'Status', width: 8 },
];
const row = formatRow(['123', 'active'], columns);
expect(row).toBe('123 active ');
});
@@ -503,7 +518,10 @@ describe('CLI Output Formatting', () => {
it('should format complete table', () => {
const table = formatTable(
['ID', 'Status'],
[['1', 'active'], ['2', 'idle']],
[
['1', 'active'],
['2', 'idle'],
],
[5, 8]
);
expect(table).toContain('ID');
@@ -587,7 +605,7 @@ describe('CLI Output Formatting', () => {
});
it('should format normal costs with 2 decimals', () => {
expect(formatCost(1.50)).toBe('$1.50');
expect(formatCost(1.5)).toBe('$1.50');
expect(formatCost(0.05)).toBe('$0.05');
});
@@ -633,7 +651,7 @@ describe('CLI Output Formatting', () => {
describe('List Formatting', () => {
const formatList = (items: string[], bullet: string = '-'): string => {
return items.map(item => `${bullet} ${item}`).join('\n');
return items.map((item) => `${bullet} ${item}`).join('\n');
};
const formatNumberedList = (items: string[]): string => {
+154
View File
@@ -0,0 +1,154 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const root = resolve(import.meta.dirname, '..');
type PackageLockPackage = {
version?: string;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
type PackageLock = {
packages: Record<string, PackageLockPackage>;
};
function readJson<T>(relativePath: string): T {
return JSON.parse(readFileSync(resolve(root, relativePath), 'utf8')) as T;
}
function compareVersions(actual: string, expected: string): number {
const actualParts = actual.split('.').map((part) => Number(part.replace(/\D.*/, '')) || 0);
const expectedParts = expected.split('.').map((part) => Number(part.replace(/\D.*/, '')) || 0);
for (let i = 0; i < Math.max(actualParts.length, expectedParts.length); i++) {
const left = actualParts[i] ?? 0;
const right = expectedParts[i] ?? 0;
if (left > right) return 1;
if (left < right) return -1;
}
return 0;
}
function packageNameFromLockPath(lockPath: string): string | null {
const parts = lockPath.split('node_modules/');
if (parts.length < 2) return null;
return parts[parts.length - 1] ?? null;
}
function lockedVersions(lock: PackageLock, packageName: string): string[] {
const versions = new Set<string>();
for (const [lockPath, pkg] of Object.entries(lock.packages)) {
if (packageNameFromLockPath(lockPath) === packageName && pkg.version) {
versions.add(pkg.version);
}
}
return [...versions].sort();
}
function expectEveryLockedVersionAtLeast(lock: PackageLock, packageName: string, minimum: string): void {
const versions = lockedVersions(lock, packageName);
expect(versions, `${packageName} should be present in package-lock.json`).not.toHaveLength(0);
for (const version of versions) {
expect(
compareVersions(version, minimum),
`${packageName}@${version} should be >= ${minimum}`
).toBeGreaterThanOrEqual(0);
}
}
function expectNoVulnerableVite(lock: PackageLock): void {
const versions = lockedVersions(lock, 'vite');
expect(versions, 'vite should be present in package-lock.json').not.toHaveLength(0);
for (const version of versions) {
const major = Number(version.split('.')[0]);
if (major === 6) {
expect(compareVersions(version, '6.4.2'), `vite@${version} should be >= 6.4.2`).toBeGreaterThanOrEqual(0);
} else if (major === 7) {
expect(compareVersions(version, '7.3.2'), `vite@${version} should be >= 7.3.2`).toBeGreaterThanOrEqual(0);
} else {
expect(major, `vite@${version} should be on a supported patched major`).toBeGreaterThanOrEqual(8);
}
}
}
function expectNoVulnerablePicomatch(lock: PackageLock): void {
const versions = lockedVersions(lock, 'picomatch');
expect(versions, 'picomatch should be present in package-lock.json').not.toHaveLength(0);
for (const version of versions) {
const major = Number(version.split('.')[0]);
if (major === 2) {
expect(compareVersions(version, '2.3.2'), `picomatch@${version} should be >= 2.3.2`).toBeGreaterThanOrEqual(0);
} else if (major === 4) {
expect(compareVersions(version, '4.0.4'), `picomatch@${version} should be >= 4.0.4`).toBeGreaterThanOrEqual(0);
}
}
}
function expectNoVulnerableBraceExpansion(lock: PackageLock): void {
const versions = lockedVersions(lock, 'brace-expansion');
expect(versions, 'brace-expansion should be present in package-lock.json').not.toHaveLength(0);
for (const version of versions) {
const major = Number(version.split('.')[0]);
if (major === 1) {
expect(
compareVersions(version, '1.1.13'),
`brace-expansion@${version} should be >= 1.1.13`
).toBeGreaterThanOrEqual(0);
} else if (major === 4) {
expect(
compareVersions(version, '5.0.5'),
`brace-expansion@${version} should not remain on vulnerable 4.x`
).toBeGreaterThanOrEqual(0);
} else if (major === 5) {
expect(compareVersions(version, '5.0.6'), `brace-expansion@${version} should be >= 5.0.6`).toBeGreaterThanOrEqual(
0
);
}
}
}
describe('dependency security policy', () => {
it('keeps direct security-sensitive dependency ranges on patched versions', () => {
const rootPackage = readJson<PackageLockPackage>('package.json');
const xtermPackage = readJson<PackageLockPackage>('packages/xterm-zerolag-input/package.json');
expect(rootPackage.dependencies?.['@fastify/static']).toBe('^9.1.3');
expect(rootPackage.dependencies?.fastify).toBe('^5.8.5');
expect(rootPackage.dependencies?.uuid).toBe('^14.0.0');
expect(rootPackage.devDependencies?.['@remotion/cli']).toBe('4.0.473');
expect(rootPackage.devDependencies?.remotion).toBe('4.0.473');
expect(rootPackage.devDependencies?.['@remotion/transitions']).toBe('4.0.473');
expect(rootPackage.devDependencies?.vitest).toBe('^4.1.8');
expect(rootPackage.devDependencies?.['@vitest/coverage-v8']).toBe('^4.1.8');
expect(xtermPackage.devDependencies?.vitest).toBe('^4.1.8');
});
it('keeps critical and high audit findings resolved in the lockfile', () => {
const lock = readJson<PackageLock>('package-lock.json');
expectEveryLockedVersionAtLeast(lock, 'vitest', '4.1.0');
expectEveryLockedVersionAtLeast(lock, '@vitest/coverage-v8', '4.1.0');
expectEveryLockedVersionAtLeast(lock, 'fastify', '5.8.5');
expectEveryLockedVersionAtLeast(lock, '@fastify/static', '9.1.3');
expectEveryLockedVersionAtLeast(lock, 'ip-address', '10.2.0');
expectEveryLockedVersionAtLeast(lock, 'uuid', '14.0.0');
expectEveryLockedVersionAtLeast(lock, 'ws', '8.20.1');
expectEveryLockedVersionAtLeast(lock, 'fast-uri', '3.1.2');
expectEveryLockedVersionAtLeast(lock, 'basic-ftp', '5.3.1');
expectEveryLockedVersionAtLeast(lock, 'flatted', '3.4.2');
expectNoVulnerableBraceExpansion(lock);
expectNoVulnerableVite(lock);
expectNoVulnerablePicomatch(lock);
});
it('keeps standalone workspace lockfiles on patched test tooling', () => {
const lock = readJson<PackageLock>('packages/xterm-zerolag-input/package-lock.json');
expect(lock.packages['']?.devDependencies?.vitest).toBe('^4.1.8');
expectEveryLockedVersionAtLeast(lock, 'vitest', '4.1.0');
expectEveryLockedVersionAtLeast(lock, 'ws', '8.20.1');
expectNoVulnerableVite(lock);
expectNoVulnerablePicomatch(lock);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const repoRoot = resolve(import.meta.dirname, '..');
describe('frontend public asset tooling', () => {
it('exposes a public asset check script', () => {
const pkg = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8')) as {
scripts?: Record<string, string>;
};
expect(pkg.scripts?.['check:public-assets']).toContain('scripts/check-public-assets.mjs');
});
it('keeps app.js free of literal NUL bytes', () => {
const appJs = readFileSync(resolve(repoRoot, 'src/web/public/app.js'));
expect(appJs.includes(0)).toBe(false);
});
it('runs the public asset check script', () => {
expect(() => {
execFileSync('npm', ['run', 'check:public-assets', '--silent'], {
cwd: repoRoot,
stdio: 'pipe',
});
}).not.toThrow();
});
});
+19 -35
View File
@@ -5,16 +5,22 @@ import { PORTS, KEYBOARD, SELECTORS, BODY_CLASSES, WAIT } from './helpers/consta
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, getBrowser, closeAllBrowsers } from './helpers/browser.js';
import {
showKeyboard, hideKeyboard,
showKeyboardViaCDP, hideKeyboardViaCDP,
showKeyboardViaMock, hideKeyboardViaMock,
showKeyboardViaDOM, hideKeyboardViaDOM,
showKeyboard,
hideKeyboard,
showKeyboardViaCDP,
hideKeyboardViaCDP,
showKeyboardViaMock,
hideKeyboardViaMock,
showKeyboardViaDOM,
hideKeyboardViaDOM,
setupViewportMock,
} from './helpers/keyboard-sim.js';
import { getCDP, setVisualViewportHeight } from './helpers/cdp.js';
import {
assertHasClass, assertNotHasClass,
assertVisible, assertHidden,
assertHasClass,
assertNotHasClass,
assertVisible,
assertHidden,
getCSSProperty,
} from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
@@ -167,9 +173,7 @@ describe('Virtual Keyboard', () => {
const success = await showKeyboardViaMock(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
expect(success).toBe(true);
const hasClass = await page.evaluate(() =>
document.body.classList.contains('keyboard-visible'),
);
const hasClass = await page.evaluate(() => document.body.classList.contains('keyboard-visible'));
expect(hasClass).toBe(true);
} finally {
await context.close();
@@ -280,12 +284,13 @@ describe('Virtual Keyboard', () => {
expect(mainPadding).toBe('');
});
it('accessory bar has 7 action buttons', async () => {
const count = await page.evaluate(() => {
const buttons = document.querySelectorAll('.keyboard-accessory-bar [data-action]');
return buttons.length;
it('accessory bar has the simple-mode action buttons', async () => {
const actions = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
(button) => (button as HTMLElement).dataset.action
);
});
expect(count).toBe(7);
expect(actions).toEqual(['scroll-up', 'scroll-down', 'init', 'clear', 'paste', 'dismiss']);
});
it('double-tap confirm on /clear button', async () => {
@@ -321,27 +326,6 @@ describe('Virtual Keyboard', () => {
expect(text).toBe('Tap again');
});
it('double-tap confirm on /compact button', async () => {
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
await page.evaluate(`
if (typeof app !== 'undefined') app.activeSessionId = 'test-session';
`);
await page.evaluate(() => {
const btn = document.querySelector('[data-action="compact"]') as HTMLElement;
btn?.click();
});
await page.waitForTimeout(100);
const confirming = await page.evaluate(() => {
const btn = document.querySelector('[data-action="compact"]');
return btn?.classList.contains('confirming') ?? false;
});
expect(confirming).toBe(true);
});
it('double-tap expires after 2s', async () => {
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { isExplicitlyEnabled, isLoopbackBindHost } from '../src/web/network-auth-policy.js';
describe('network auth policy', () => {
it.each(['localhost', '127.0.0.1', '127.42.0.9', '::1', '[::1]', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1'])(
'treats %s as loopback',
(host) => {
expect(isLoopbackBindHost(host)).toBe(true);
}
);
it.each(['0.0.0.0', '192.168.1.10', '10.0.0.1', 'example.com', '::', '[::]', '::ffff:192.168.1.10'])(
'treats %s as non-loopback',
(host) => {
expect(isLoopbackBindHost(host)).toBe(false);
}
);
it.each(['1', 'true', 'TRUE', ' yes ', 'on'])('treats %s as an explicit opt-in', (value) => {
expect(isExplicitlyEnabled(value)).toBe(true);
});
it.each([undefined, '', '0', 'false', 'no', 'off', 'enabled'])('does not treat %s as an explicit opt-in', (value) => {
expect(isExplicitlyEnabled(value)).toBe(false);
});
});
+13 -7
View File
@@ -1256,7 +1256,7 @@ describe('Operation Lightspeed', () => {
await Promise.all(ids.map((id) => deleteSession(baseUrl, id)));
});
it('should correctly filter SSE under concurrent session lifecycle', async () => {
it('should broadcast lifecycle events while filtering concurrent session terminal streams', async () => {
// Create 2 sessions
const target = await createSession(baseUrl);
const other = await createSession(baseUrl);
@@ -1309,15 +1309,21 @@ describe('Operation Lightspeed', () => {
const events = parseSSEEvents(receivedData);
// Should see target's rename but not other's events
const targetUpdated = events.find((e) => e.event === 'session:updated' && (e.data as any).id === target);
// session:updated is a lifecycle event broadcast to all clients; the
// subscription filter applies only to high-volume terminal streams.
const updatedEvents = events.filter((e) => e.event === 'session:updated');
const targetUpdated = updatedEvents.find((e) => (e.data as any).id === target);
expect(targetUpdated).toBeDefined();
// Should NOT see other's events
const otherEvents = events.filter(
(e) => ((e.data as any)?.id === other || (e.data as any)?.sessionId === other) && e.event !== 'init'
const otherLifecycleEvents = events.filter(
(e) => e.event !== 'init' && e.event !== 'session:terminal' && (e.data as any)?.id === other
);
expect(otherEvents.length).toBe(0);
expect(otherLifecycleEvents.length).toBeGreaterThan(0);
const otherTerminalEvents = events.filter(
(e) => e.event === 'session:terminal' && (e.data as any)?.sessionId === other
);
expect(otherTerminalEvents.length).toBe(0);
await deleteSession(baseUrl, target);
});
+130 -98
View File
@@ -19,24 +19,24 @@ const BASE_URL = `http://localhost:${PORT}`;
// Thresholds (ms)
const THRESHOLDS = {
PAGE_LOAD: 3000, // Full page load including JS init
DOMContentLoaded: 1500, // HTML parsed
SSE_CONNECT: 2000, // SSE EventSource open
TAB_CREATE_API: 200, // POST /api/sessions response
TAB_RENDER: 300, // Tab element appears in DOM
TAB_SWITCH: 400, // Tab click to active class applied (includes tmux session creation)
TERMINAL_INIT: 500, // xterm.js instance created for tab
INPUT_ROUNDTRIP: 500, // Keystroke sent via API → acknowledged
SETTINGS_OPEN: 300, // Settings modal visible
SETTINGS_CLOSE: 200, // Settings modal hidden
PAGE_LOAD: 3000, // Full page load including JS init
DOMContentLoaded: 1500, // Browser nav timing through deferred script execution
SSE_CONNECT: 2000, // SSE EventSource open
TAB_CREATE_API: 200, // POST /api/sessions response
TAB_RENDER: 300, // Tab element appears in DOM
TAB_SWITCH: 400, // Tab click to active class applied (includes tmux session creation)
TERMINAL_INIT: 500, // xterm.js instance created for tab
INPUT_ROUNDTRIP: 500, // Keystroke sent via API → acknowledged
SETTINGS_OPEN: 300, // Settings modal visible
SETTINGS_CLOSE: 200, // Settings modal hidden
SESSION_OPTIONS_OPEN: 300, // Session options modal visible
SESSION_OPTIONS_TAB: 200, // Modal tab switch
SESSION_OPTIONS_TAB: 200, // Modal tab switch
SUBAGENT_WINDOW_OPEN: 400, // Subagent window rendered
SUBAGENT_WINDOW_CLOSE: 200,
BULK_TAB_CREATE: 3000, // Create 10 sessions
BULK_TAB_SWITCH_AVG: 300, // Average per-tab switch across 10 tabs (includes buffer loads)
MEMORY_HEAP_MB: 200, // Max JS heap after heavy load
BUFFER_LOAD_16KB: 500, // Load a 16KB terminal buffer
BULK_TAB_CREATE: 3000, // Create 10 sessions
BULK_TAB_SWITCH_AVG: 300, // Average per-tab switch across 10 tabs (includes buffer loads)
MEMORY_HEAP_MB: 200, // Max JS heap after heavy load
BUFFER_LOAD_16KB: 500, // Load a 16KB terminal buffer
};
let server: WebServer;
@@ -88,6 +88,25 @@ async function measure(fn: () => Promise<void>): Promise<number> {
return performance.now() - start;
}
type BrowserNavigationTiming = {
domInteractive: number;
domContentLoadedEventEnd: number;
loadEventEnd: number;
};
/** Get the browser's own navigation timing, excluding Playwright harness overhead. */
async function getBrowserNavigationTiming(page: Page): Promise<BrowserNavigationTiming> {
return page.evaluate(() => {
const entry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
if (!entry) throw new Error('Navigation timing entry not available');
return {
domInteractive: entry.domInteractive,
domContentLoadedEventEnd: entry.domContentLoadedEventEnd,
loadEventEnd: entry.loadEventEnd,
};
});
}
/** Get JS heap size in MB (Chromium only) */
async function getHeapMB(page: Page): Promise<number> {
const metrics = await page.evaluate(() => {
@@ -123,12 +142,15 @@ describe('Page load performance', () => {
it('DOMContentLoaded fires within threshold', async () => {
({ context, page } = await freshPage());
const timing = await measure(async () => {
const wallTiming = await measure(async () => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
});
const navigationTiming = await getBrowserNavigationTiming(page);
console.log(`[page load] DOMContentLoaded: ${timing.toFixed(0)}ms`);
expect(timing).toBeLessThan(THRESHOLDS.DOMContentLoaded);
console.log(
`[page load] DOMContentLoaded: ${navigationTiming.domContentLoadedEventEnd.toFixed(0)}ms (wall ${wallTiming.toFixed(0)}ms)`
);
expect(navigationTiming.domContentLoadedEventEnd).toBeLessThan(THRESHOLDS.DOMContentLoaded);
});
it('full app initialization completes within threshold', async () => {
@@ -157,7 +179,7 @@ describe('Page load performance', () => {
const dot = document.getElementById('connectionDot');
return dot?.classList.contains('connected') || indicator.style.display === 'none';
},
{ timeout: 5000 },
{ timeout: 5000 }
);
});
@@ -225,7 +247,7 @@ describe('Session tab creation', () => {
await page.waitForFunction(
(expected: number) => document.querySelectorAll('.session-tab').length > expected,
tabCountBefore,
{ timeout: 3000 },
{ timeout: 3000 }
);
});
@@ -250,7 +272,7 @@ describe('Session tab creation', () => {
if (!container) return false;
return container.querySelector('.xterm-screen') !== null;
},
{ timeout: 3000 },
{ timeout: 3000 }
);
});
@@ -277,7 +299,7 @@ describe('Tab switching performance', () => {
await page.waitForFunction(
(count: number) => document.querySelectorAll('.session-tab').length >= count,
sessionIds.length,
{ timeout: 5000 },
{ timeout: 5000 }
);
// Select first tab
await page.locator(`.session-tab[data-id="${sessionIds[0]}"]`).click();
@@ -303,7 +325,7 @@ describe('Tab switching performance', () => {
await page.waitForFunction(
(id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'),
targetId,
{ timeout: 2000 },
{ timeout: 2000 }
);
});
timings.push(timing);
@@ -325,7 +347,7 @@ describe('Tab switching performance', () => {
await page.waitForFunction(
(id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'),
targetId,
{ timeout: 2000 },
{ timeout: 2000 }
);
});
timings.push(timing);
@@ -362,11 +384,9 @@ describe('Bulk tab operations', () => {
sessionIds.push(id);
}
// Wait for all tabs to render
await page.waitForFunction(
(count: number) => document.querySelectorAll('.session-tab').length >= count,
10,
{ timeout: 5000 },
);
await page.waitForFunction((count: number) => document.querySelectorAll('.session-tab').length >= count, 10, {
timeout: 5000,
});
});
console.log(`[bulk create] 10 sessions: ${timing.toFixed(0)}ms`);
@@ -383,7 +403,7 @@ describe('Bulk tab operations', () => {
await page.waitForFunction(
(id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'),
targetId,
{ timeout: 2000 },
{ timeout: 2000 }
);
});
timings.push(timing);
@@ -497,7 +517,10 @@ describe('Settings modal performance', () => {
it('closes within threshold', async () => {
// Make sure it's open
const isOpen = await page.locator('#appSettingsModal.active').isVisible().catch(() => false);
const isOpen = await page
.locator('#appSettingsModal.active')
.isVisible()
.catch(() => false);
if (!isOpen) {
await page.locator('.btn-settings').click();
await page.waitForSelector('#appSettingsModal.active', { timeout: 2000 });
@@ -506,10 +529,9 @@ describe('Settings modal performance', () => {
const timing = await measure(async () => {
// Press Escape to close
await page.keyboard.press('Escape');
await page.waitForFunction(
() => !document.querySelector('#appSettingsModal')?.classList.contains('active'),
{ timeout: 2000 },
);
await page.waitForFunction(() => !document.querySelector('#appSettingsModal')?.classList.contains('active'), {
timeout: 2000,
});
});
console.log(`[settings] close: ${timing.toFixed(0)}ms`);
@@ -528,10 +550,9 @@ describe('Settings modal performance', () => {
// Close
const closeTime = await measure(async () => {
await page.keyboard.press('Escape');
await page.waitForFunction(
() => !document.querySelector('#appSettingsModal')?.classList.contains('active'),
{ timeout: 2000 },
);
await page.waitForFunction(() => !document.querySelector('#appSettingsModal')?.classList.contains('active'), {
timeout: 2000,
});
});
timings.push(openTime + closeTime);
}
@@ -575,7 +596,10 @@ describe('Session options modal performance', () => {
it('tab switching within modal is instant', async () => {
// Ensure modal is open
const isOpen = await page.locator('#sessionOptionsModal.active').isVisible().catch(() => false);
const isOpen = await page
.locator('#sessionOptionsModal.active')
.isVisible()
.catch(() => false);
if (!isOpen) {
await page.locator(`.session-tab[data-id="${sessionId}"] .tab-gear`).click();
await page.waitForSelector('#sessionOptionsModal.active', { timeout: 2000 });
@@ -590,7 +614,7 @@ describe('Session options modal performance', () => {
await page.waitForFunction(
(t: string) => document.querySelector(`[data-tab="${t}"]`)?.classList.contains('active'),
tab,
{ timeout: 1000 },
{ timeout: 1000 }
);
});
timings.push(timing);
@@ -634,27 +658,32 @@ describe('Subagent window simulation', () => {
}, sessionId);
const timing = await measure(async () => {
await page.evaluate(({ agentId, cSessionId }: { agentId: string; cSessionId: string }) => {
const app = (window as unknown as {
app: {
subagents: Map<string, Record<string, unknown>>;
openSubagentWindow: (id: string) => void;
}
}).app;
// Inject fake agent data
app.subagents.set(agentId, {
agentId,
sessionId: cSessionId,
status: 'active',
description: 'Performance test agent',
startedAt: Date.now(),
lastActivityAt: Date.now(),
toolCallCount: 0,
entryCount: 0,
fileSize: 0,
});
app.openSubagentWindow(agentId);
}, { agentId: 'perf-agent-1', cSessionId: claudeSessionId });
await page.evaluate(
({ agentId, cSessionId }: { agentId: string; cSessionId: string }) => {
const app = (
window as unknown as {
app: {
subagents: Map<string, Record<string, unknown>>;
openSubagentWindow: (id: string) => void;
};
}
).app;
// Inject fake agent data
app.subagents.set(agentId, {
agentId,
sessionId: cSessionId,
status: 'active',
description: 'Performance test agent',
startedAt: Date.now(),
lastActivityAt: Date.now(),
toolCallCount: 0,
entryCount: 0,
fileSize: 0,
});
app.openSubagentWindow(agentId);
},
{ agentId: 'perf-agent-1', cSessionId: claudeSessionId }
);
await page.waitForSelector('.subagent-window', { timeout: 3000 });
});
@@ -679,7 +708,7 @@ describe('Subagent window simulation', () => {
const el = document.getElementById('subagent-window-perf-agent-1');
return el && el.style.display === 'none';
},
{ timeout: 2000 },
{ timeout: 2000 }
);
});
@@ -698,33 +727,35 @@ describe('Subagent window simulation', () => {
const timing = await measure(async () => {
for (let i = 0; i < 5; i++) {
await page.evaluate(({ idx, cSessionId }: { idx: number; cSessionId: string }) => {
const agentId = `perf-multi-agent-${idx}`;
const app = (window as unknown as {
app: {
subagents: Map<string, Record<string, unknown>>;
openSubagentWindow: (id: string) => void;
}
}).app;
app.subagents.set(agentId, {
agentId,
sessionId: cSessionId,
status: 'active',
description: `Perf agent ${idx}`,
startedAt: Date.now(),
lastActivityAt: Date.now(),
toolCallCount: 0,
entryCount: 0,
fileSize: 0,
});
app.openSubagentWindow(agentId);
}, { idx: i, cSessionId: claudeSessionId });
await page.evaluate(
({ idx, cSessionId }: { idx: number; cSessionId: string }) => {
const agentId = `perf-multi-agent-${idx}`;
const app = (
window as unknown as {
app: {
subagents: Map<string, Record<string, unknown>>;
openSubagentWindow: (id: string) => void;
};
}
).app;
app.subagents.set(agentId, {
agentId,
sessionId: cSessionId,
status: 'active',
description: `Perf agent ${idx}`,
startedAt: Date.now(),
lastActivityAt: Date.now(),
toolCallCount: 0,
entryCount: 0,
fileSize: 0,
});
app.openSubagentWindow(agentId);
},
{ idx: i, cSessionId: claudeSessionId }
);
}
// Wait for all 5
await page.waitForFunction(
() => document.querySelectorAll('.subagent-window').length >= 5,
{ timeout: 5000 },
);
await page.waitForFunction(() => document.querySelectorAll('.subagent-window').length >= 5, { timeout: 5000 });
});
const windowCount = await page.locator('.subagent-window').count();
@@ -772,7 +803,7 @@ describe('SSE event throughput', () => {
await page.waitForFunction(
(count: number) => document.querySelectorAll('.session-tab').length >= count,
sessionIds.length,
{ timeout: 5000 },
{ timeout: 5000 }
);
const elapsed = performance.now() - start;
@@ -797,7 +828,7 @@ describe('SSE event throughput', () => {
await page.waitForFunction(
(count: number) => document.querySelectorAll('.session-tab').length >= count,
sessionIds.length,
{ timeout: 5000 },
{ timeout: 5000 }
);
const start = performance.now();
@@ -813,7 +844,7 @@ describe('SSE event throughput', () => {
}
return true;
},
{ timeout: 5000 },
{ timeout: 5000 }
);
const elapsed = performance.now() - start;
@@ -880,11 +911,9 @@ describe('Memory usage under load', () => {
const id = await createSession(page, `perf-mem-${i}`);
sessionIds.push(id);
}
await page.waitForFunction(
(count: number) => document.querySelectorAll('.session-tab').length >= count,
10,
{ timeout: 5000 },
);
await page.waitForFunction((count: number) => document.querySelectorAll('.session-tab').length >= count, 10, {
timeout: 5000,
});
// Switch through all tabs
for (const id of sessionIds) {
@@ -895,10 +924,13 @@ describe('Memory usage under load', () => {
const heapAfter = await getHeapMB(page);
const heapGrowth = heapAfter - heapBefore;
console.log(`[memory] before: ${heapBefore.toFixed(1)}MB, after: ${heapAfter.toFixed(1)}MB, growth: ${heapGrowth.toFixed(1)}MB`);
console.log(
`[memory] before: ${heapBefore.toFixed(1)}MB, after: ${heapAfter.toFixed(1)}MB, growth: ${heapGrowth.toFixed(1)}MB`
);
// Heap should stay under absolute limit
if (heapAfter > 0) { // memory API may not be available
if (heapAfter > 0) {
// memory API may not be available
expect(heapAfter).toBeLessThan(THRESHOLDS.MEMORY_HEAP_MB);
}
});
+2 -1
View File
@@ -47,7 +47,8 @@ interface PushPayload {
function makeServerWithHost(host: string): WebServer {
// Constructor only assigns fields — no network/disk activity until start().
const server = new WebServer(0, false, true, host);
// 4th arg is the bind host; the title hostname is the 5th arg.
const server = new WebServer(0, false, true, '127.0.0.1', host);
// Stub push store: one subscription with all events enabled.
const fakeSub = {
endpoint: 'https://push.example.com/abc',
+17 -16
View File
@@ -106,7 +106,7 @@ describe('Ralph Integration Tests', () => {
const res = await fetch(`${baseUrl}/api/sessions/non-existent-id`);
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
@@ -136,7 +136,8 @@ describe('Ralph Integration Tests', () => {
// Verify session is gone
const getRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`);
const getData = await getRes.json();
expect(getData.error).toBe('Session not found');
expect(getRes.status).toBe(404);
expect(getData.error).toContain('not found');
});
it('should create shell session', async () => {
@@ -191,7 +192,7 @@ describe('Ralph Integration Tests', () => {
const res = await fetch(`${baseUrl}/api/sessions/fake-session/ralph-state`);
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.error).toContain('not found');
});
@@ -359,7 +360,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(400);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('INVALID_INPUT');
});
@@ -372,7 +373,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
@@ -395,7 +396,7 @@ describe('Ralph Integration Tests', () => {
createdSessions.push(createData.sessionId);
// Wait for session to be ready
await new Promise(r => setTimeout(r, 200));
await new Promise((r) => setTimeout(r, 200));
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/resize`, {
method: 'POST',
@@ -422,7 +423,7 @@ describe('Ralph Integration Tests', () => {
createdSessions.push(createData.sessionId);
// Wait for session to be ready
await new Promise(r => setTimeout(r, 200));
await new Promise((r) => setTimeout(r, 200));
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/resize`, {
method: 'POST',
@@ -431,7 +432,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(400);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('INVALID_INPUT');
});
@@ -472,7 +473,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
@@ -497,7 +498,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(400);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('INVALID_INPUT');
});
@@ -536,7 +537,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
@@ -561,7 +562,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(400);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('INVALID_INPUT');
});
@@ -626,7 +627,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
@@ -907,7 +908,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
@@ -991,7 +992,7 @@ describe('Ralph Integration Tests', () => {
const res = await fetch(`${baseUrl}/api/sessions/fake-session/output`);
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
@@ -1021,7 +1022,7 @@ describe('Ralph Integration Tests', () => {
const res = await fetch(`${baseUrl}/api/sessions/fake-session/terminal`);
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
+75
View File
@@ -305,6 +305,22 @@ describe('file-routes', () => {
expect(res.headers['content-type']).toBe('image/png');
});
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
mockedReadFile.mockResolvedValue(content as never);
mockedStat.mockResolvedValue({ size: content.length } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=malicious.svg`,
});
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('application/octet-stream');
expect(res.headers['content-disposition']).toContain('attachment; filename="malicious.svg"');
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
it('rejects path traversal in raw file serving', async () => {
mockedRealpathSync.mockReturnValue('/etc/shadow' as never);
@@ -363,4 +379,63 @@ describe('file-routes', () => {
expect(body.success).toBe(false);
});
});
// ========== GET /api/download ==========
describe('GET /api/download', () => {
it('requires a sessionId to scope downloads', async () => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/download?path=${encodeURIComponent('/tmp/test-workdir/report.txt')}`,
});
expect(res.statusCode).toBe(400);
});
it('downloads files scoped to the session working directory', async () => {
const content = Buffer.from('download content');
mockedReadFile.mockResolvedValue(content as never);
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=report.txt`,
});
expect(res.statusCode).toBe(200);
expect(res.headers['content-disposition']).toContain('filename="report.txt"');
expect(res.body).toBe('download content');
});
it('rejects absolute paths outside the session working directory', async () => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent('/var/log/app.log')}`,
});
expect(res.statusCode).toBe(404);
});
it('rejects symlink targets that escape the session working directory', async () => {
mockedRealpathSync.mockReturnValue('/tmp/outside-workdir/link.log' as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(
'/tmp/test-workdir/link.log'
)}`,
});
expect(res.statusCode).toBe(404);
});
it('blocks sensitive files even when they are inside the session working directory', async () => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=.env`,
});
expect(res.statusCode).toBe(403);
});
});
});
+24 -20
View File
@@ -32,36 +32,38 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const indexHtmlPath = join(__dirname, '..', 'src', 'web', 'public', 'index.html');
const rawTemplate = readFileSync(indexHtmlPath, 'utf-8');
function render(host?: string): string {
const server = new WebServer(0, false, true, host);
return (server as unknown as { renderIndexHtml: () => string }).renderIndexHtml();
async function render(host?: string): Promise<string> {
// 4th arg is the bind host; the title hostname is the 5th arg.
const server = new WebServer(0, false, true, '127.0.0.1', host);
// renderIndexHtml is async (it reads settings.json for the gesture bundle).
return (server as unknown as { renderIndexHtml: () => Promise<string> }).renderIndexHtml();
}
describe('WebServer index.html <title> templating (#82)', () => {
it('substitutes the bare <title>Codeman</title> with codeman:<host>', () => {
const html = render('laptop');
it('substitutes the bare <title>Codeman</title> with codeman:<host>', async () => {
const html = await render('laptop');
expect(html).toContain('<title>codeman:laptop</title>');
expect(html).not.toContain('<title>Codeman</title>');
});
it('defaults to os.hostname() when no titleHostname is supplied', () => {
const html = render();
it('defaults to os.hostname() when no titleHostname is supplied', async () => {
const html = await render();
const expected = `<title>codeman:${osHostname()}</title>`;
expect(html).toContain(expected);
});
it('treats an empty-string titleHostname as "not supplied" and falls back to os.hostname()', () => {
it('treats an empty-string titleHostname as "not supplied" and falls back to os.hostname()', async () => {
// CLI normally guarantees a non-empty string, but the constructor's
// `titleHostname || getHostname()` guard makes empty fall through —
// pin that behavior so a future refactor doesn't accidentally ship
// a `<title>codeman:</title>` to users.
const html = render('');
const html = await render('');
expect(html).toMatch(/<title>codeman:.+<\/title>/);
expect(html).not.toContain('<title>codeman:</title>');
});
it('HTML-escapes < > & in the hostname so it cannot break out of the title tag', () => {
const html = render('<script>alert(1)</script>');
it('HTML-escapes < > & in the hostname so it cannot break out of the title tag', async () => {
const html = await render('<script>alert(1)</script>');
expect(html).toContain('<title>codeman:&lt;script&gt;alert(1)&lt;/script&gt;</title>');
// The raw closing </title> from the injected payload must NOT appear
// outside the actual title element — escape-then-substitute prevents
@@ -69,16 +71,18 @@ describe('WebServer index.html <title> templating (#82)', () => {
expect(html).not.toContain('<script>alert(1)</script></title>');
});
it('escapes an ampersand without double-encoding existing entities', () => {
it('escapes an ampersand without double-encoding existing entities', async () => {
// The escaper replaces & first, then < and >. A hostname that already
// contains a literal `&` should render as `&amp;` once, not `&amp;amp;`.
const html = render('a&b');
const html = await render('a&b');
expect(html).toContain('<title>codeman:a&amp;b</title>');
expect(html).not.toContain('&amp;amp;');
});
it('only substitutes the <title> tag — the rest of the template is byte-for-byte identical', () => {
const html = render('laptop');
it('only substitutes the <title> tag — the rest of the template is identical (modulo asset cache-busting)', async () => {
// renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin
// .js/.css refs; strip them so the title remains the only other change.
const html = (await render('laptop')).replace(/(\.(?:js|css))\?v=[^"]*/g, '$1');
const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
expect(html.startsWith(beforeTitle)).toBe(true);
@@ -88,8 +92,8 @@ describe('WebServer index.html <title> templating (#82)', () => {
expect(html.length - rawTemplate.length).toBe(expectedDelta);
});
it('replaces the <title> placeholder exactly once', () => {
const html = render('laptop');
it('replaces the <title> placeholder exactly once', async () => {
const html = await render('laptop');
// Defense against a future regression where the template gains a
// second `<title>Codeman</title>` (e.g. inside a <noscript>) and only
// the first gets templated — would leave a stale literal in the served
@@ -100,9 +104,9 @@ describe('WebServer index.html <title> templating (#82)', () => {
expect(occurrencesOfOld).toBe(0);
});
it('two WebServer instances on different hostnames render distinct titles', () => {
const htmlA = render('host-a');
const htmlB = render('host-b');
it('two WebServer instances on different hostnames render distinct titles', async () => {
const htmlA = await render('host-a');
const htmlB = await render('host-b');
expect(htmlA).toContain('<title>codeman:host-a</title>');
expect(htmlB).toContain('<title>codeman:host-b</title>');
expect(htmlA).not.toContain('host-b');
+5 -1
View File
@@ -6,11 +6,15 @@
* This means tests CANNOT kill, create, or interact with real tmux
* sessions regardless of what the test code does.
*
* This setup file only handles mock/timer cleanup between tests.
* This setup file strips shell-level auth configuration that can leak from a
* running Codeman instance, then handles mock/timer cleanup between tests.
*/
import { afterEach, vi } from 'vitest';
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
+109
View File
@@ -20,6 +20,17 @@ vi.mock('node:child_process', async () => {
const actual = await vi.importActual('node:child_process');
return {
...actual,
exec: vi.fn((_cmd: string, optionsOrCallback?: unknown, maybeCallback?: unknown) => {
const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback;
if (typeof callback === 'function') {
setImmediate(() => callback(null, '', ''));
}
return {
on: vi.fn(),
kill: vi.fn(),
pid: 12345,
};
}),
execSync: vi.fn(),
spawn: vi.fn(() => ({
unref: vi.fn(),
@@ -41,6 +52,15 @@ vi.mock('node:fs', async () => {
};
});
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual('node:fs/promises');
return {
...actual,
writeFile: vi.fn(() => Promise.resolve()),
rename: vi.fn(() => Promise.resolve()),
};
});
describe('TmuxManager (unit)', () => {
let manager: TmuxManager;
const mockedExecSync = vi.mocked(execSync);
@@ -369,6 +389,95 @@ describe('TmuxManager (unit)', () => {
// No error thrown
});
});
describe('tmux launch cwd hardening', () => {
async function importWithTmuxCommandsEnabled(): Promise<typeof TmuxManager> {
const originalVitest = process.env.VITEST;
vi.resetModules();
delete process.env.VITEST;
const module = await import('../src/tmux-manager.js');
if (originalVitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = originalVitest;
}
return module.TmuxManager;
}
beforeEach(() => {
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('which tmux')) {
return '/usr/bin/tmux\n';
}
if (typeof cmd === 'string' && cmd.includes('display-message') && cmd.includes('#{pane_pid}')) {
return '4242\n';
}
return '';
});
});
it('starts new tmux sessions from /tmp and cd-bounces into the requested workspace', async () => {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
const session = await nonTestManager.createSession({
sessionId: 'abc12345-1234-5678-90ab-cdef12345678',
workingDir: '/mnt/gdrive/project with spaces',
mode: 'shell',
});
expect(session.workingDir).toBe('/mnt/gdrive/project with spaces');
expect(session.pid).toBe(4242);
const newSessionCall = mockedExecSync.mock.calls.find(
([cmd]) => typeof cmd === 'string' && cmd.includes(' new-session ')
);
expect(newSessionCall?.[0]).toBe(`tmux -L 'codeman' new-session -ds "codeman-abc12345" -c /tmp`);
expect(newSessionCall?.[1]).toEqual(expect.objectContaining({ cwd: '/tmp' }));
const respawnCall = mockedExecSync.mock.calls.find(
([cmd]) => typeof cmd === 'string' && cmd.includes(' respawn-pane ')
);
expect(respawnCall?.[0]).toContain(`tmux -L 'codeman' respawn-pane -k -c /tmp -t "codeman-abc12345"`);
expect(respawnCall?.[0]).toContain('cd \\"/mnt/gdrive/project with spaces\\" &&');
} finally {
nonTestManager.destroy();
}
});
it('respawns existing panes from /tmp and cd-bounces into the requested workspace', async () => {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
nonTestManager.registerSession({
sessionId: 'respawn1234',
muxName: 'codeman-abcd1234',
pid: 1000,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'shell',
attached: false,
});
try {
const pid = await nonTestManager.respawnPane({
sessionId: 'respawn1234',
workingDir: '/mnt/gdrive/project',
mode: 'shell',
});
expect(pid).toBe(4242);
const { exec: currentExec } = await import('node:child_process');
const respawnCall = vi
.mocked(currentExec)
.mock.calls.find(([cmd]) => typeof cmd === 'string' && cmd.includes(' respawn-pane '));
expect(respawnCall?.[0]).toContain(`tmux -L 'codeman' respawn-pane -k -c /tmp -t "codeman-abcd1234"`);
expect(respawnCall?.[0]).toContain('cd \\"/mnt/gdrive/project\\" &&');
} finally {
nonTestManager.destroy();
}
});
});
});
// ============================================================================