mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f7add7ce4 | ||
|
|
eeb5f9d0b2 | ||
|
|
2ab21c1b32 | ||
|
|
550e08a791 | ||
|
|
99ad9cb236 | ||
|
|
823f56a243 | ||
|
|
72fd231d11 | ||
|
|
65d19c725e | ||
|
|
66eb01ba8f | ||
|
|
1125f7c1c5 |
+12
-4
@@ -69,10 +69,18 @@ shared-host, multi-user, or tunneled deployments.
|
||||
- **Multi-instance tmux socket is process-wide.** Two Codeman instances on the same `CODEMAN_INSTANCE` share a tmux socket and can attach each other's live sessions — isolate with distinct `CODEMAN_INSTANCE` values.
|
||||
- **The live log-tail route reads `/var/log` and `~/logs`** in addition to the session working directory (read-only) — a deliberate choice for tailing system/app logs. On a password-protected remote deployment an authenticated user can therefore read those roots outside their session. See `docs/security-architecture.md` §5.
|
||||
|
||||
Recent hardening (this release): web-push subscription endpoints are restricted
|
||||
to https public hosts (SSRF guard — rejects internal/metadata IPs, validated at
|
||||
subscribe and send time), and tmux session names discovered on the shared socket
|
||||
are validated against the safe-name pattern before reaching any shell call site.
|
||||
- **The web-tab proxy fetches from the server's network position.** Any authenticated user can save a dashboard URL on loopback or a private range and have Codeman relay to it; that is the feature. Link-local and cloud-metadata addresses are the only refused targets (see below). On a shared host, restrict who holds an account.
|
||||
|
||||
Recent hardening (2026-09-04): the web-tab proxy, its "Test" probe and its
|
||||
WebSocket relay refuse link-local and cloud-metadata targets (`169.254.0.0/16`,
|
||||
`fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`,
|
||||
`metadata.google.internal`), judged on the RESOLVED address so a DNS name pointing
|
||||
there is refused too; proxy capabilities are revoked on logout, admin logout and
|
||||
user deletion; proxied responses carry `Referrer-Policy: same-origin`. Earlier:
|
||||
web-push subscription endpoints are restricted to https public hosts (SSRF guard,
|
||||
rejects internal/metadata IP literals, validated at subscribe and send time), and
|
||||
tmux session names discovered on the shared socket are validated against the
|
||||
safe-name pattern before reaching any shell call site.
|
||||
|
||||
For the detailed rationale, defenses, and recommended secure setups, see
|
||||
[`docs/security-architecture.md`](../docs/security-architecture.md).
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.24.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- The web-tab proxy refuses link-local and cloud-metadata targets. Its Test probe, the proxy itself and the WebSocket relay accepted any http(s) host, so a saved dashboard URL could reach `169.254.169.254` (in decimal, hex, IPv6-mapped or DNS-name form) through a capability and no cookie. Loopback and RFC1918 addresses stay allowed on purpose, since a localhost Grafana is the feature; only link-local and the fixed cloud-metadata addresses are refused, at the schema, at every connect site, and through a DNS lookup hook that judges the resolved addresses, which is what closes DNS rebinding. Adds `undici` so the proxy runs its fetch through its own agent.
|
||||
|
||||
Proxy capabilities are revoked on logout. `revokeOwner()` had shipped with no caller, so a leaked proxy URL stayed valid for as long as anything kept polling it. `POST /api/logout`, the admin forced logout and user deletion now revoke the capabilities they should, and proxied responses carry `Referrer-Policy: same-origin` with the upstream's own policy dropped, so a dashboard on a loose referrer policy cannot hand the capability to a third-party host it links to.
|
||||
|
||||
The Docker Compose deployment updates itself from App Settings again (#373, @opticon454). The checkout Compose builds from is bind-mounted at `/opt/codeman`, so an update's `git checkout` and rebuild land on the host and survive container recreation; build artefacts live in named volumes so container-compiled native modules never enter the host checkout; the image keeps devDependencies and a build toolchain; and the restart is the server exiting under `restart: unless-stopped`. An in-place update applies code only, so the updater refuses a release that changes `server.Dockerfile` or `docker-compose.yaml`, or that adds keys to `.env.example` the user's `.env` has no value for (Compose interpolates an unset variable to the empty string and starts anyway), and points at `docker/Start-Codeman.sh` on the host instead. The four global agent CLIs in the image are pinned. A follow-up makes the final step fail safe: the server exits only when the Compose file declares `CODEMAN_RESTART_BY_EXIT=1` or the daemon confirms an auto-restart policy, and otherwise the build is staged for a manual restart, so a container nothing would restart is never taken down. Details in `docs/docker-self-update.md`.
|
||||
|
||||
The test suite strips `CODEMAN_INSTANCE`, `CODEMAN_DATA_DIR` and `CODEMAN_TMUX_SOCKET` before any application module loads (#371, @opticon454), with a two-half test whose static half reads `test/setup.ts` so a dropped line fails everywhere. This replaces the throwaway data dir #356 had set for the same variable.
|
||||
|
||||
### Thanks
|
||||
- @opticon454 for the Compose self-update (#373) and the test isolation fix (#371).
|
||||
|
||||
## 1.24.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -75,7 +75,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**: 1.24.6 (must match `package.json`)
|
||||
**Version**: 1.24.7 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -211,7 +211,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design)
|
||||
|
||||
**Docker Compose deployment** (`docker/`, contributed): Codeman itself runs in a container and spawns Docker cases as **SIBLING** containers through the mounted host socket (Docker-outside-of-Docker), never nested. That inverts one assumption the bare-host path takes for granted: the daemon no longer shares Codeman's filesystem, so a bind source valid *inside* Codeman means nothing to it. `resolveDockerDaemonMountSource()` translates sources under HOME into the daemon's namespace via `CODEMAN_DOCKER_HOST_HOME`, and `CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount so a workspace resolves to the SAME absolute path on both sides (which is what keeps the transcript projHash matching, per Docker cases above). ⚠️ **`CODEMAN_CASES_PATH` must move every consumer or none**: it is resolved once in `config/cases-dir.ts` because `src/cli.ts` resolves case paths too, and when only the server's `CASES_DIR` learned the override, `codeman skill install --case <name>` reported "Case not found" on exactly the deployment the override exists for. ⚠️ **`.dockerignore` patterns match the WHOLE context-relative path**, so a bare `.env` line excludes only the ROOT file: `docker/.env` (which holds `CODEMAN_PASSWORD` and any provider keys) rode `COPY . .` into the image until `**/.env` was added — verified in both directions with a real build context. ⚠️ A Compose LONG-form bind (`type: bind`) **creates a missing host source directory ROOT-OWNED** rather than refusing, so any bind source the runtime user must write to has to be pre-created and chowned. `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` drops `--memory-swap` (and filters only that one kernel warning) for hosts without swap accounting; `--memory` still applies. `docs/docker-compose.md` + `docker/README.md` (user guides)
|
||||
**Docker Compose deployment** (`docker/`, contributed): Codeman itself runs in a container and spawns Docker cases as **SIBLING** containers through the mounted host socket (Docker-outside-of-Docker), never nested. That inverts one assumption the bare-host path takes for granted: the daemon no longer shares Codeman's filesystem, so a bind source valid *inside* Codeman means nothing to it. `resolveDockerDaemonMountSource()` translates sources under HOME into the daemon's namespace via `CODEMAN_DOCKER_HOST_HOME`, and `CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount so a workspace resolves to the SAME absolute path on both sides (which is what keeps the transcript projHash matching, per Docker cases above). ⚠️ **`CODEMAN_CASES_PATH` must move every consumer or none**: it is resolved once in `config/cases-dir.ts` because `src/cli.ts` resolves case paths too, and when only the server's `CASES_DIR` learned the override, `codeman skill install --case <name>` reported "Case not found" on exactly the deployment the override exists for. ⚠️ **`.dockerignore` patterns match the WHOLE context-relative path**, so a bare `.env` line excludes only the ROOT file: `docker/.env` (which holds `CODEMAN_PASSWORD` and any provider keys) rode `COPY . .` into the image until `**/.env` was added — verified in both directions with a real build context. ⚠️ A Compose LONG-form bind (`type: bind`) **creates a missing host source directory ROOT-OWNED** rather than refusing, so any bind source the runtime user must write to has to be pre-created and chowned. `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` drops `--memory-swap` (and filters only that one kernel warning) for hosts without swap accounting; `--memory` still applies. ⚠️ The deployment ALSO self-updates in place (the repo bind mount at `/opt/codeman` + a restart-by-exiting supervisor) — see Self-update below and `docs/docker-self-update.md` before touching `server.Dockerfile`, the compose file or `.env.example`, since each is an input to the updater's environment gate. `docs/docker-compose.md` + `docker/README.md` (user guides)
|
||||
|
||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` — discovery (search dirs, version + identity probes), the launch argv template, env handling, the `capabilities` flags that replace per-CLI branching, and the `overlays` that back the remote/docker pane commands. **No code outside `stock.ts` may branch on a CLI id**; behaviour that genuinely differs is either a capability field or a NAMED PROFILE selected by one (`profiles.ts`), and `test/cli-registry-no-id-branching.test.ts` fails the build if an id check reappears — it matches `===`, `!==`, `case '<id>':` and `[...].includes(mode)`, because an earlier `===`-only version let 36 negated branches survive the conversion (including a seven-mode Ralph chain whose own comment asked the next person to keep it in step with `isExternalCliMode()` by hand). ⚠️ Config contains no shell text: an entry declares typed argv tokens, literals are validated against a safe-word pattern at LOAD time (a bad literal rejects the whole entry — a silently dropped `--no-approve` is not cosmetic), and values resolve through patterns NAMED in code, so a user `clis.json` cannot widen its own validation. ⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug. ⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `<Mode>Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression. ⚠️ Six fields are DECLARED-FOR-LATER and read by nothing (`shortBadge`, `accent`, `capabilities.echo`/`wheelForward`/`keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed rather than measured, so re-measure before wiring one up; the list is pinned so it cannot quietly grow. Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.test.ts`, remote/docker pane commands in `test/location-overlay-commands.test.ts`. ⚠️ Anything reading the registry resolves it AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks) — a module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. `~/.codeman/clis.json` overrides any entry (read-only in this release; nothing writes it, so importing the registry has no filesystem side effects). → `docs/cli-registry.md`
|
||||
|
||||
@@ -248,7 +248,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for **claude ≥ 2.1.187 ONLY** at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. ⚠️ Codex was in that list and must never go back without a fresh measurement: codex-cli 0.147.0 ignores SGR wheel reports entirely (`mouse_any_flag=0`, inline viewport, transcript pushed into terminal scrollback), so forwarding produced a dead wheel (#227 follow-up). `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). ⚠️ **A click is hand-reported to the CLI only while the CLI actually has mouse tracking on.** The full strip removes the mouse DECSETs, so xterm's `mouseTrackingMode` is permanently `none` there and the browser hand-encodes SGR reports (`_sendSyntheticSgrTap`); without state it did that on EVERY click, so a stripped-mode pane running a plain shell (CLI exited, or a shell started inside a claude-mode session) received reports it never asked for and printed them as literal text (`[<0;88;20M`), garbling the next typed line. `_recordStrippedMouseMode()` (session.ts) records what the strip removes, `toState()` publishes `cliMouseTracking`, and `_shouldReportMouseToCli()` gates all three report sites on it. Only 1000/1001/1002/1003 count (1005/1006 are encodings, 1007 is alt-scroll), and the change broadcasts UNdebounced since a dialog can be clicked inside the 500ms window. `_logScrollRouting()` prints the routing decision and its inputs once per session — read it before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||
**Detached start + service install** (issue #231): `codeman web -d` relaunches the SAME entry script with `detached:true` (setsid), so there is no controlling terminal and no shell job entry. ⚠️ `nohup` is NOT what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and `cli.ts` handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile check + `/api/status` probe): a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. ⚠️ Neither may report success it has not observed — the parent polls `/api/status` until the child answers or dies, since `launchctl load` and a clean spawn are both silent about a server that starts and immediately exits. `--stop` verifies the pid still LOOKS like a Codeman server (`ps -o command=`) before signalling, because pids get recycled. Unit/label names live in `config/service-names.ts` so install.sh, `detectSupervisor()` and `service install` cannot drift into supervising two copies; they are instance-scoped, and identical to the historical names for the default instance. `service install` bakes the installing shell's PATH into the unit (launchd gives a job `/usr/bin:/bin:/usr/sbin:/sbin`, which finds neither a Homebrew/nvm `node` nor `tmux`/`claude`) and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
||||
|
||||
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. ⚠️ **The Compose deployment is the one supervisor that does NOT outlive the restart**: there the restart IS the container exiting (`restart: unless-stopped` relaunches it), which kills the script too — safe only because the terminal `restarting` marker is written BEFORE the kill, so nothing may be appended after it. Two config facts make it work at all and both are load-bearing: the repo is a HOST BIND MOUNT over `/opt/codeman` (a pull into the baked image copy would land in the writable layer and be silently discarded by the next `up`), and the runtime image keeps devDependencies + a build toolchain (`npm run build` is tsc+esbuild, and node-pty has no Linux prebuild), which is why `npm prune --omit=dev` is gone and the updater passes `--include=dev` against `NODE_ENV=production`. ⚠️ An in-place container update applies CODE ONLY — a restart reuses the existing image and config — so `evaluateEnvironmentGate()` REFUSES a release that changes `server.Dockerfile`/`docker-compose.yaml` (sha256 vs the baseline `Start-Codeman.sh` writes to `docker-env-applied.json` on every start) or adds `.env.example` keys the user's `.env` lacks, and refuses when the restart policy would not bring the container back. That third check exists because **Compose resolves an unset `${VAR}` to the EMPTY STRING and starts anyway**, so a new required setting otherwise arrives as a silently blank env var. Every unknown fails OPEN in the gate (no baseline, unreadable `.env`, no socket): failing closed would permanently block containers created before the fingerprint file existed. ⚠️ The KILL does not: the server exits only when `--restart-by-exit 1` was passed, i.e. the Compose file declared `CODEMAN_RESTART_BY_EXIT=1` (set ONLY there, since that file is what sets `restart: unless-stopped`; the image ENV deliberately does not) or the daemon reported an auto-restart policy; otherwise the build lands as `completed-needs-manual-restart`, because exiting blind takes a `docker run` container with no restart policy down with no UI left to recover it. The gate is re-evaluated on `POST /api/system/update`, so hiding the button is UX, not the control. ⚠️ The four global agent CLIs in `server.Dockerfile` are PINNED on purpose — unpinned, a user's CLI versions are a function of when their image was built rather than of any commit, which is the one environment change no diff-derived gate can see; pinning turns it into a Dockerfile change the gate already catches. `test/docker-compose-env-parity.test.ts` is the merge-side guard (every compose `${VAR}` ↔ an `.env.example` entry). → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||
|
||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||
|
||||
@@ -268,7 +268,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
|
||||
|
||||
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a `SessionMode` of its own** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `<base>` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
|
||||
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a `SessionMode` of its own** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `<base>` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. ⚠️ **Egress guard**: link-local and cloud-metadata targets (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, the Azure/Alibaba fixed addresses, `metadata.google.internal`) are refused at save time AND on the RESOLVED address at connect time (`webview-egress-policy.ts`, pure, plus `webview-egress.ts`: a `lookup` hook on the undici Agent behind `webviewFetch()` and on the `ws` client). An IP literal never reaches a lookup hook (`net.connect` skips DNS for it), so the synchronous hostname check at each connect site is NOT redundant. Loopback and RFC1918 stay allowed on purpose: a `localhost` Grafana is the feature. The proxy uses the `undici` PACKAGE's own `fetch` + `Agent`, never Node's global fetch with a foreign dispatcher (Node bundles its own copy; a protocol mismatch fails silently). Capabilities are revoked on logout / admin logout / user deletion (`revokeOwner`, which had NO caller for two releases while its docstring said otherwise), and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability-bearing URL to a third party. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
|
||||
|
||||
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
|
||||
|
||||
@@ -376,7 +376,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
@@ -407,7 +407,7 @@ Raw `npx vitest` skips the config (and with it `setup.ts`); always use `npm test
|
||||
|
||||
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s. `config/vitest.config.ts` is the everything-config behind `test:all`; `config/vitest.ci.config.ts` is the gate and derives its excludes from `config/test-suites.ts`, which is also what `vitest.browser.config.ts` and `vitest.perf.config.ts` derive their includes from — so the exclusions and the runners cannot drift apart. Keep shared options in sync across them.
|
||||
|
||||
**Tmux safety**: under vitest (`VITEST` env var, set automatically), `TmuxManager` no-ops ALL shell commands and becomes a pure in-memory mock — tests physically cannot create/kill/attach real tmux sessions (`IS_TEST_MODE` in `src/tmux-manager.ts`). Every docker IO path is no-op'd the same way. `Session` is test-gated too: instead of attaching a real tmux client, it spawns a raw-mode echo PTY (`TEST_PTY_SCRIPT` in `src/session.ts`), so integration tests get a live input/output loop that echoes each byte exactly once. `test/setup.ts` gives every test file a temporary `HOME`/`USERPROFILE` (all `homedir()`-derived state, `~/.codeman` and `~/codeman-cases` included, resolves into a per-file fixture; the Playwright browser cache path is preserved), and additionally strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME` (so auth state from the running instance can't leak into tests) and `CODEMAN_GESTURE` (a shell-exported gesture flag would flip render-injection assertions), and points `CODEMAN_DATA_DIR` at a throwaway dir (#356). ⚠️ That last one is what actually protects `~/.codeman`: `getDataDir()` reads `CODEMAN_DATA_DIR` as an ABSOLUTE override before it ever looks at `homedir()`, so one inherited from the shell (a second instance, a beta run) bypasses the temp HOME entirely, and a bare suite run once overwrote the real `remote-hosts.json` with a route test's fixture. `os.homedir()` itself DOES follow `$HOME`, so the temp HOME is what redirects everything else. Tests that delete case trees go through `safeRmHomeTree()` (`test/mocks`), which refuses any path outside the temp HOME, so a wrong anchor leaves a temp dir behind instead of deleting `~/codeman-cases`. ⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and with it the temp-HOME isolation.
|
||||
**Tmux safety**: under vitest (`VITEST` env var, set automatically), `TmuxManager` no-ops ALL shell commands and becomes a pure in-memory mock — tests physically cannot create/kill/attach real tmux sessions (`IS_TEST_MODE` in `src/tmux-manager.ts`). Every docker IO path is no-op'd the same way. `Session` is test-gated too: instead of attaching a real tmux client, it spawns a raw-mode echo PTY (`TEST_PTY_SCRIPT` in `src/session.ts`), so integration tests get a live input/output loop that echoes each byte exactly once. `test/setup.ts` gives every test file a temporary `HOME`/`USERPROFILE` (all `homedir()`-derived state, `~/.codeman` and `~/codeman-cases` included, resolves into a per-file fixture; the Playwright browser cache path is preserved), and additionally strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME` (so auth state from the running instance can't leak into tests) and `CODEMAN_GESTURE` (a shell-exported gesture flag would flip render-injection assertions), and strips the three instance-selection vars `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (#356/#371; `test/test-env-isolation.test.ts` pins the list, and its STATIC half reads setup.ts so a dropped `delete` fails everywhere rather than only on a box that exports the var). ⚠️ `CODEMAN_DATA_DIR` is the one that matters: `getDataDir()` reads it as an ABSOLUTE override before it ever looks at `homedir()`, so one inherited from the shell (a second instance, a beta run, a shell left over from `codeman web -d`) bypasses the temp HOME entirely, and a bare suite run once overwrote the real `remote-hosts.json` with a route test's fixture. `os.homedir()` itself DOES follow `$HOME`, so the temp HOME is what redirects everything else; `CODEMAN_INSTANCE` must be stripped in the setup file and never in a hook, because `config/instance.ts` captures it into a module-level const on first import. Tests that delete case trees go through `safeRmHomeTree()` (`test/mocks`), which refuses any path outside the temp HOME, so a wrong anchor leaves a temp dir behind instead of deleting `~/codeman-cases`. ⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and with it the temp-HOME isolation.
|
||||
|
||||
**Ports**: Pick unique ports manually, 3150+. Search `const PORT =` before adding new tests. Never 3000 (the live instance).
|
||||
|
||||
|
||||
@@ -23,13 +23,6 @@ export default defineConfig({
|
||||
include: ['test/**/*.test.ts'],
|
||||
exclude: [...configDefaults.exclude, ...NON_CI_TEST_GLOBS],
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
// SAFETY: force every worker's data dir away from prod `~/.codeman`. Route
|
||||
// tests (e.g. session-routes-workspace-hooks) write remote-hosts.json into
|
||||
// `getDataDir()`; without this a bare run clobbers the production host
|
||||
// registry (found 2026-08-29). `/tmp` is fine here — the tree is throwaway.
|
||||
env: {
|
||||
CODEMAN_DATA_DIR: '/tmp/codeman-vitest-data',
|
||||
},
|
||||
fileParallelism: false,
|
||||
testTimeout: 30000,
|
||||
teardownTimeout: 60000,
|
||||
|
||||
@@ -21,13 +21,6 @@ export default defineConfig({
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
// SAFETY: force every worker's data dir away from prod `~/.codeman`. Route
|
||||
// tests (e.g. session-routes-workspace-hooks) write remote-hosts.json into
|
||||
// `getDataDir()`; without this a bare run clobbers the production host
|
||||
// registry (found 2026-08-29). `/tmp` is fine here — the tree is throwaway.
|
||||
env: {
|
||||
CODEMAN_DATA_DIR: '/tmp/codeman-vitest-data',
|
||||
},
|
||||
// Run test files sequentially to respect mux session limits
|
||||
// Individual tests within files still run in parallel where safe
|
||||
fileParallelism: false,
|
||||
|
||||
@@ -20,6 +20,13 @@ CODEMAN_RUNTIME_USER=opencode
|
||||
# directory in the container.
|
||||
CODEMAN_APPDATA_PATH=/mnt/user/appdata/Coding/codeman
|
||||
|
||||
# Optional. Absolute host path of this Codeman checkout, mounted at
|
||||
# /opt/codeman so App Settings -> Updates can update Codeman in place. The Bash
|
||||
# start script detects it from the compose file's own location, so it only needs
|
||||
# setting for direct `docker compose` use or a checkout kept elsewhere. Point it
|
||||
# at a directory that is not a git checkout and in-app updates are unavailable.
|
||||
# CODEMAN_REPO_PATH=/mnt/user/appdata/Coding/codeman/app
|
||||
|
||||
# Required for Docker cases. This must be an absolute path on the Docker host.
|
||||
# Codeman and each isolated case use this same path, so it cannot be a
|
||||
# container-only path such as /home/opencode/codeman-cases.
|
||||
|
||||
@@ -26,6 +26,18 @@ Codeman, Claude, OpenCode, and other local sessions run as the unprivileged acco
|
||||
|
||||
To retain Docker-case support without root when running Compose directly, set `DOCKER_SOCKET_GID` to the numeric group ID of the host socket. On a standard Linux Docker host, obtain it with `stat -c '%g' /var/run/docker.sock`. The Bash start script detects it automatically.
|
||||
|
||||
## Updating
|
||||
|
||||
Use **App Settings → Updates** in the web UI. The checkout Compose builds from is
|
||||
also mounted at `/opt/codeman`, so an update's `git checkout` and rebuild persist
|
||||
on the host, and the server exiting is what restarts the container onto the new
|
||||
build.
|
||||
|
||||
Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to
|
||||
`.env.example` cannot be applied that way — the updater detects them, names what
|
||||
changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details:
|
||||
[`../docs/docker-self-update.md`](../docs/docker-self-update.md).
|
||||
|
||||
## Application data storage
|
||||
|
||||
The default configuration uses a host-folder bind mount:
|
||||
|
||||
@@ -70,4 +70,60 @@ fi
|
||||
|
||||
export DOCKER_SOCKET_GID=${socket_ids##*:}
|
||||
|
||||
repo_path=${CODEMAN_REPO_PATH:-$(cd -- "$script_dir/.." && pwd)}
|
||||
if [[ ! -d "$repo_path" ]]; then
|
||||
printf 'Error: CODEMAN_REPO_PATH is not a directory: %s\n' "$repo_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
export CODEMAN_REPO_PATH="$repo_path"
|
||||
|
||||
# The in-app updater runs `git checkout` and `npm install` against this checkout
|
||||
# as PUID:PGID. If the directory belongs to someone else, git refuses outright
|
||||
# ("detected dubious ownership") and the update fails at the first step — so warn
|
||||
# here, where the fix is obvious, rather than in a failed update hours later.
|
||||
if repo_owner=$(stat -c '%u' -- "$repo_path" 2>/dev/null || stat -f '%u' "$repo_path" 2>/dev/null); then
|
||||
if [[ "$repo_owner" != "$PUID" ]]; then
|
||||
printf 'Warning: %s is owned by UID %s but Codeman runs as UID %s.\n' "$repo_path" "$repo_owner" "$PUID" >&2
|
||||
printf 'In-app updates will fail until the ownership matches. Codeman itself still starts.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -d "$repo_path/.git" ]]; then
|
||||
printf 'Note: %s is not a git checkout, so in-app updates are unavailable.\n' "$repo_path" >&2
|
||||
fi
|
||||
|
||||
# Record what the container is about to be built and created FROM. The in-app
|
||||
# updater compares these against the release it wants to apply: a release that
|
||||
# changes either file cannot be applied by the container restarting itself (a
|
||||
# restart reuses the existing image and config), so it is refused and the user
|
||||
# is sent back here. Written on every start, so the baseline always describes
|
||||
# the container that is actually running. See docs/docker-self-update.md.
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256_of() { sha256sum -- "$1" | cut -d' ' -f1; }
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
sha256_of() { shasum -a 256 -- "$1" | cut -d' ' -f1; }
|
||||
else
|
||||
sha256_of() { printf ''; }
|
||||
fi
|
||||
|
||||
dockerfile_sha=$(sha256_of "$script_dir/server.Dockerfile")
|
||||
compose_sha=$(sha256_of "$compose_file")
|
||||
if [[ -n "$dockerfile_sha" && -n "$compose_sha" ]]; then
|
||||
# $CODEMAN_APPDATA_PATH is mounted at the runtime account's home, so this is
|
||||
# dataPath('docker-env-applied.json') as the server inside the container sees it.
|
||||
state_dir="$appdata_path/.codeman"
|
||||
mkdir -p -- "$state_dir"
|
||||
printf '{\n "dockerfileSha256": "%s",\n "composeSha256": "%s"\n}\n' \
|
||||
"$dockerfile_sha" "$compose_sha" >"$state_dir/docker-env-applied.json.tmp"
|
||||
mv -- "$state_dir/docker-env-applied.json.tmp" "$state_dir/docker-env-applied.json"
|
||||
# A root-run start (common on Unraid) would otherwise leave a root-owned
|
||||
# `.codeman` on a FIRST start, before the container has created it as PUID,
|
||||
# and the unprivileged server could then never write its own state there.
|
||||
if [[ "$EUID" == '0' ]]; then
|
||||
chown -- "$PUID:$PGID" "$state_dir" "$state_dir/docker-env-applied.json"
|
||||
fi
|
||||
else
|
||||
printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2
|
||||
fi
|
||||
|
||||
exec docker compose --env-file "$env_file" -f "$compose_file" up --build -d
|
||||
|
||||
@@ -15,6 +15,17 @@ services:
|
||||
ports:
|
||||
- "${CODEMAN_PORT}:${CODEMAN_PORT}"
|
||||
environment:
|
||||
# Tells the self-updater to restart by exiting (the restart policy below
|
||||
# relaunches it) rather than by looking for an init system that is not
|
||||
# here. Also set in the image; repeated so a container started without the
|
||||
# image default still self-identifies.
|
||||
CODEMAN_IN_CONTAINER: "1"
|
||||
# This file sets `restart: unless-stopped` below, so the updater may restart
|
||||
# the server by EXITING. Declared here and only here, never in the image: a
|
||||
# container started by plain `docker run` has no restart policy unless the
|
||||
# operator gave it one, and there the updater asks the daemon instead and
|
||||
# stages the update for a manual restart when it cannot get an answer.
|
||||
CODEMAN_RESTART_BY_EXIT: "1"
|
||||
CODEMAN_DOCKER_BRIDGE_HOOKS: ${CODEMAN_DOCKER_BRIDGE_HOOKS}
|
||||
# Host-side equivalent of the runtime user's HOME. Docker case seed,
|
||||
# credential and hook mounts are translated into the daemon namespace.
|
||||
@@ -48,6 +59,32 @@ services:
|
||||
- type: bind
|
||||
source: ${DOCKER_SOCKET}
|
||||
target: /var/run/docker.sock
|
||||
# The application source, so App Settings -> Updates can update in place.
|
||||
# This is the SAME checkout used as the build context above, mounted over
|
||||
# the image's baked copy: a `git checkout` performed inside the container
|
||||
# then lands on the host and survives the container being recreated.
|
||||
# Without it the pull would go to the container's writable layer and be
|
||||
# silently discarded by the next `up`. See docs/docker-self-update.md.
|
||||
# Defaults to `..` — the build context above — which Compose resolves
|
||||
# against the project directory, so plain `docker compose up` works with
|
||||
# no extra configuration. Set CODEMAN_REPO_PATH only to point elsewhere.
|
||||
- type: bind
|
||||
source: ${CODEMAN_REPO_PATH:-..}
|
||||
target: /opt/codeman
|
||||
# Build artefacts live in named volumes layered OVER the repo bind mount,
|
||||
# so `npm install` and `npm run build` inside the container never write
|
||||
# into the host checkout. That keeps container-compiled native modules
|
||||
# (node-pty is built from source here) out of a checkout that may also be
|
||||
# used to run Codeman natively, and keeps `git status` clean. Docker seeds
|
||||
# an EMPTY named volume from the image, so the first start inherits the
|
||||
# image's already-built node_modules and dist rather than paying for a
|
||||
# bootstrap build.
|
||||
- type: volume
|
||||
source: codeman-node-modules
|
||||
target: /opt/codeman/node_modules
|
||||
- type: volume
|
||||
source: codeman-dist
|
||||
target: /opt/codeman/dist
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
security_opt:
|
||||
@@ -63,3 +100,12 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
# Container-owned build artefacts. They persist across container recreation,
|
||||
# so an in-app update's `npm install` output is not thrown away by the next
|
||||
# `up`, and they are seeded from the image on first use. Removing them (or
|
||||
# `docker compose down -v`) is the supported reset: the next start rebuilds
|
||||
# from the image.
|
||||
codeman-node-modules:
|
||||
codeman-dist:
|
||||
|
||||
@@ -12,9 +12,12 @@ WORKDIR /opt/codeman
|
||||
|
||||
COPY . .
|
||||
|
||||
# devDependencies are deliberately KEPT (no `npm prune --omit=dev`). The in-app
|
||||
# updater rebuilds from inside this container, and `npm run build` is tsc +
|
||||
# esbuild — both devDependencies. Pruning them saves image size and takes the
|
||||
# self-updater with it. See docs/docker-self-update.md.
|
||||
RUN npm ci \
|
||||
&& npm run build \
|
||||
&& npm prune --omit=dev --ignore-scripts \
|
||||
&& npm cache clean --force
|
||||
|
||||
# The Docker CLI talks to the host daemon through the socket mounted by
|
||||
@@ -25,13 +28,21 @@ ARG CODEMAN_RUNTIME_USER=opencode
|
||||
ARG PUID=1000
|
||||
ARG PGID=1000
|
||||
|
||||
# python3/make/g++ are here for the SELF-UPDATER, not for this build. An update
|
||||
# runs `npm install` inside the running container, and node-pty ships no Linux
|
||||
# prebuild, so a release that bumps it compiles from source right here. Without
|
||||
# a toolchain that install fails and the update rolls back — every time, on the
|
||||
# releases that need it most. Same reason install.sh installs one on bare hosts.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
openssh-client \
|
||||
procps \
|
||||
python3 \
|
||||
ripgrep \
|
||||
tmux \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -59,11 +70,23 @@ COPY --from=docker:29-cli \
|
||||
|
||||
# Keep credentials out of the image. Users authenticate these CLIs at runtime
|
||||
# through Codeman sessions, and the configured host bind mount retains state.
|
||||
#
|
||||
# ⚠️ PINNED ON PURPOSE. Unpinned, the agent CLI versions a user ends up with are
|
||||
# a function of WHEN their image was built, not of any commit — so a Codeman
|
||||
# release that depends on newer CLI behaviour (the trust-dialog handling is
|
||||
# pinned to Claude Code 2.1.252's layout; wheel forwarding to >= 2.1.187) breaks
|
||||
# on an older image with no diff anywhere to explain why. In-app updates make
|
||||
# rebuilds RARER, which makes that drift worse. Pinning turns "this release needs
|
||||
# a newer CLI" into a Dockerfile change, which the updater's environment gate
|
||||
# already detects and refuses (docs/docker-self-update.md).
|
||||
#
|
||||
# Bump these deliberately, in a release. `--no-cache` is still needed to rebuild
|
||||
# this layer when only the pins change upstream.
|
||||
RUN npm install --global \
|
||||
@anthropic-ai/claude-code \
|
||||
@google/gemini-cli \
|
||||
@openai/codex \
|
||||
opencode-ai \
|
||||
@anthropic-ai/claude-code@2.1.258 \
|
||||
@google/gemini-cli@0.58.0 \
|
||||
@openai/codex@0.152.1 \
|
||||
opencode-ai@1.18.26 \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Keep the web server and every local Codeman session unprivileged. PUID and
|
||||
@@ -103,7 +126,12 @@ WORKDIR /opt/codeman
|
||||
|
||||
COPY --from=build /opt/codeman /opt/codeman
|
||||
|
||||
ENV CODEMAN_PORT=3000 \
|
||||
# CODEMAN_IN_CONTAINER tells the self-updater it must restart by exiting rather
|
||||
# than by asking an init system that is not here (src/web/self-update.ts).
|
||||
# NODE_ENV stays `production`; the updater passes `npm install --include=dev`
|
||||
# explicitly, since that value would otherwise omit the build toolchain.
|
||||
ENV CODEMAN_IN_CONTAINER=1 \
|
||||
CODEMAN_PORT=3000 \
|
||||
HOME=/home/${CODEMAN_RUNTIME_USER} \
|
||||
NODE_ENV=production
|
||||
|
||||
|
||||
@@ -247,6 +247,10 @@ Invariants:
|
||||
|
||||
**The capability, and why the auth exemption is safe.** A sandboxed iframe (no `allow-same-origin`) is OPAQUE-ORIGIN, so every request it makes is cross-site: the `SameSite=lax` `codeman_session` cookie is never attached, and writes and WS upgrades arrive with `Origin: null`, which `isAllowedRequestOrigin` rejects by design. Cookie auth therefore cannot work. `src/webview-capabilities.ts` mints a 192-bit `randomBytes` token (memory-only, so a restart invalidates every outstanding one; rolling TTL; bound to the minting user; revoked on edit/delete) which `middleware/auth.ts` recognizes via `hasValidWebviewCapability()` to skip the cookie and Origin checks. ⚠️ The **Host allowlist is never bypassed**, so DNS-rebinding protection is intact. ⚠️ There is a second, `Referer`-keyed form of the exemption for root-absolute assets that `<base href>` cannot rewrite (`fetch('/api/data')`, `url(/img.png)` in a stylesheet); it is the only exemption decided by a request-supplied header, so it is fenced to **safe methods on paths that resolve to NO registered Codeman route** (`matchesRegisteredRoute`), plus a blanket refusal of `/ws/` and `/q/`. Without that fence a page could present a webview Referer and skip auth on a real API route. ⚠️ The fence uses `findRoute()`, NOT `hasRoute()`: `hasRoute` matches the registered PATTERN literally, so `/api/sessions/abc` reports false against `/api/sessions/:id` and would hand out an exemption on a live route. It also has to treat `@fastify/static`'s root catch-all (mounted at `/`, matches everything) as "no real route", which is detectable because a root catch-all is the only route whose `*` param equals the whole request path. `/api` used to be refused by prefix instead, which permanently broke dashboards serving their own assets from an `/api/...` namespace. `test/webview-auth-exemption.test.ts` pins every edge.
|
||||
|
||||
**Egress guard (2026-09-04).** The proxy's reach is a documented property, but `169.254.169.254` sat inside it: the URL schema accepted any http(s) host, the proxy relayed arbitrary request headers and PUT/POST, and a `curl` PoC pulled an IMDSv2-shaped request through to a loopback echo server with no cookie. `src/web/webview-egress-policy.ts` (pure) refuses link-local and the fixed cloud-metadata addresses, and ONLY those: loopback and RFC1918 stay allowed because a `localhost` Grafana is the feature (`test/webview-proxy.test.ts` pins `127.0.0.1:4000` as valid). ⚠️ It is applied at three stages and each is load-bearing: the Zod schema (a clear refusal at save time), a synchronous hostname check on every connect (Node's `net.connect` skips DNS for an IP literal, so a lookup hook never sees one), and a `lookup` hook (`src/web/webview-egress.ts`) on the undici `Agent` behind `webviewFetch()` and on the `ws` client, which judges the RESOLVED addresses of a name and refuses when ANY of them is blocked (`autoSelectFamily` races the whole list). The hook is what closes rebinding: a hostname-string check alone, like the push-endpoint guard's, is bypassed by an attacker's own DNS. ⚠️ The proxy uses the `undici` PACKAGE's own `fetch` with that package's own `Agent`, never Node's global fetch with a foreign dispatcher: Node bundles its own undici, and a dispatch-protocol mismatch between package and bundle fails in ways no unit test here would see. Tests: `test/webview-egress-policy.test.ts`, `test/webview-egress.test.ts` (a real Agent against a real local server with an injected resolver), the egress block in `test/routes/webview-routes.test.ts` (schema refusal, probe refusal, and a record written straight to the store to prove the proxy re-judges).
|
||||
|
||||
**Capabilities die with the login.** `revokeOwner()` shipped for two releases with a docstring claiming logout called it and NO caller: the rolling TTL is refreshed on every use, so a leaked proxy URL stayed valid for as long as anything polled it. It is now called from `POST /api/logout` (own identity; `undefined` in single-user mode, i.e. everything), the admin forced-logout route and user deletion, pinned by `test/webview-capability-revocation.test.ts`. Proxied responses additionally carry `Referrer-Policy: same-origin` (the upstream's own policy is dropped): every URL inside the frame carries the capability, and a dashboard on `no-referrer-when-downgrade` or `unsafe-url` handed it to any third-party host it linked. `same-origin` keeps the Referer the 404 fallback and `refererPath` depend on, since both compare URL origins; a `<meta name="referrer">` inside the document can still override it, which is the dashboard author's call about their own page.
|
||||
|
||||
**Sandbox default.** The iframe carries `allow-scripts allow-forms allow-popups allow-downloads allow-modals` and gains `allow-same-origin` ONLY when the dashboard is explicitly `trusted`. A proxied page is served from Codeman's own origin, so granting it would let the dashboard read the Codeman document and drive the agent-spawning API. ⚠️ In **both** modes, `Authorization` and the `codeman_session` cookie are stripped before the upstream request (`buildUpstreamRequestHeaders`), because a trusted (same-origin) frame makes the browser attach Codeman's own Basic-auth header to every proxied request; forwarding it would hand `CODEMAN_PASSWORD` to the dashboard.
|
||||
|
||||
**Two things a sandboxed frame breaks that are invisible to `curl`.** Both were found only by driving a real dashboard in a real browser, and both present identically as the dashboard's own "Failed to fetch" while the page itself renders fine:
|
||||
|
||||
@@ -61,6 +61,16 @@ If `docker info` reports `SwapLimit=false`, set `CODEMAN_DOCKER_DISABLE_SWAP_LIM
|
||||
|
||||
If that directory was created by an earlier root-running image, change its ownership to the configured `PUID:PGID` before starting this version. This preserves existing CLI credentials and session state while allowing the unprivileged runtime account to use them.
|
||||
|
||||
## Updating
|
||||
|
||||
Codeman updates itself from **App Settings → Updates**, as it does on a bare host. The checkout mounted at `/opt/codeman` is the same directory Compose builds from, so the update's `git checkout` and rebuild land on the host and survive container recreation; the restart is the server exiting, which `restart: unless-stopped` turns into a relaunch on the new build.
|
||||
|
||||
That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those.
|
||||
|
||||
`CODEMAN_REPO_PATH` overrides which checkout is mounted. It defaults to the compose project's parent directory, so it normally needs no setting. Point it at a directory that is not a git checkout and in-app updates are reported as unavailable.
|
||||
|
||||
Full detail, including the fingerprint baseline and the troubleshooting table: [`docker-self-update.md`](docker-self-update.md).
|
||||
|
||||
## Docker cases
|
||||
|
||||
The default socket path is `/var/run/docker.sock`, which works with a standard Linux Docker Engine. The Bash start script detects its numeric group ID. When running Compose directly, set `DOCKER_SOCKET_GID`, for example using `stat -c '%g' /var/run/docker.sock`, so the unprivileged `CODEMAN_RUNTIME_USER` account can create Docker cases. Docker Desktop users should set `DOCKER_SOCKET` in `docker/.env` only when their Docker installation exposes a different compatible socket path.
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# Self-update in the Docker Compose deployment
|
||||
|
||||
Codeman running as a container updates itself from **App Settings → Updates**, the
|
||||
same place and the same button as a bare-host install. This document explains how
|
||||
that works, what it deliberately refuses to do, and how to recover when it stops.
|
||||
|
||||
The bare-host updater is documented in
|
||||
[`architecture-invariants.md#self-update`](architecture-invariants.md#self-update);
|
||||
this file covers only what the container changes.
|
||||
|
||||
## The short version
|
||||
|
||||
| Change in the release | Applied by |
|
||||
| -------------------------------- | ------------------------------------------------ |
|
||||
| Application code | The in-app updater |
|
||||
| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host |
|
||||
| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host |
|
||||
| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` |
|
||||
|
||||
The in-app updater detects all three of the bottom rows itself and refuses with a
|
||||
message naming what changed, so you never have to work out which case you are in.
|
||||
|
||||
## Why the container needs its own path
|
||||
|
||||
The bare-host updater does `git checkout <tag> && npm install && npm run build`,
|
||||
then asks systemd or launchd to restart the service. Two of those assumptions are
|
||||
false in a container:
|
||||
|
||||
1. **There is no init system.** A container's supervisor is the Docker daemon,
|
||||
which acts on the container, not on processes inside it.
|
||||
2. **The image is immutable.** A `git pull` into the image's baked `/opt/codeman`
|
||||
would land in the container's writable layer, survive `docker restart`, and be
|
||||
silently discarded by the next `docker compose up`.
|
||||
|
||||
Both are solved by configuration rather than by a second updater:
|
||||
|
||||
- **The checkout is a host bind mount.** `docker-compose.yaml` mounts the repo
|
||||
(the same directory used as the build context) over `/opt/codeman`, so the
|
||||
updater's `git checkout` writes to the host filesystem and survives the
|
||||
container being recreated.
|
||||
- **The restart is the server exiting.** `restart: unless-stopped` relaunches the
|
||||
container whenever its main process ends, including on a clean exit — so the
|
||||
updater's final step is to signal the server, and Docker starts it again on the
|
||||
freshly built `dist/`.
|
||||
|
||||
Everything else — the release-tag channel, the auto-stash, the atomic
|
||||
`update-status.json` the browser polls across the connection drop, the boot-time
|
||||
reconcile that flips `restarting` to `completed` — is the existing machinery,
|
||||
unchanged. The container path is a new `SupervisorKind`, not a new updater.
|
||||
|
||||
## What the pieces are
|
||||
|
||||
| Piece | Role |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| Repo bind mount at `/opt/codeman` | Makes the pull persistent. Without it, self-update is unavailable. |
|
||||
| `codeman-node-modules`, `codeman-dist` volumes | Container-owned build artefacts, layered over the bind mount. |
|
||||
| `CODEMAN_IN_CONTAINER=1` | Tells `detectSupervisor()` to restart by exiting. |
|
||||
| `restart: unless-stopped` | Turns that exit into a restart. Verified before every update. |
|
||||
| `CODEMAN_RESTART_BY_EXIT=1` | The Compose file's declaration of that policy, so the updater may exit even with no Docker socket. |
|
||||
| Toolchain + devDependencies in the image | Lets `npm install` and `npm run build` run inside the container. |
|
||||
| `docker-env-applied.json` | Fingerprint baseline, written by `Start-Codeman.sh` on every start. |
|
||||
|
||||
### Why build artefacts are in named volumes
|
||||
|
||||
`node_modules` and `dist` are mounted as named volumes **on top of** the repo bind
|
||||
mount. Without that, an update's `npm install` would write into the host checkout,
|
||||
leaving container-compiled native modules (node-pty builds from source here) in a
|
||||
directory that may also be used to run Codeman natively, and leaving `git status`
|
||||
permanently noisy.
|
||||
|
||||
Docker seeds an empty named volume from the image, so the first start inherits the
|
||||
image's already-built `node_modules` and `dist` and pays no bootstrap cost.
|
||||
`docker compose down -v` is the supported reset: the next start re-seeds them.
|
||||
|
||||
### Why the runtime image carries a build toolchain
|
||||
|
||||
`npm run build` is `tsc` plus `esbuild`, both devDependencies, so the image no
|
||||
longer runs `npm prune --omit=dev`. And `npm install` may rebuild node-pty, which
|
||||
ships no Linux prebuild, so `python3`, `make` and `g++` are installed as well.
|
||||
|
||||
This is the real cost of in-place updates: a noticeably larger image than a
|
||||
runtime-only one. It buys an update that takes about a minute instead of a full
|
||||
image rebuild, and it is why `NODE_ENV=production` is paired with an explicit
|
||||
`npm install --include=dev` in the updater.
|
||||
|
||||
## The environment gate
|
||||
|
||||
An in-place update applies **code only**. A restarted container reuses its existing
|
||||
image and configuration, so a release that changes the environment cannot take
|
||||
effect that way — and would half-apply: new code against an old environment. The
|
||||
updater therefore checks the **target release's own files**, read straight out of
|
||||
git with `git show <tag>:<path>` before anything is checked out.
|
||||
|
||||
### 1. `server.Dockerfile` changed, so the image must be rebuilt
|
||||
|
||||
Compared by sha256 against the fingerprint `Start-Codeman.sh` recorded when the
|
||||
running container was built.
|
||||
|
||||
### 2. `docker-compose.yaml` changed, so the container must be recreated
|
||||
|
||||
Same mechanism. A restart cannot pick up a new mount, port or environment
|
||||
variable; only recreating the container can.
|
||||
|
||||
### 3. `.env.example` gained keys your `.env` has no value for
|
||||
|
||||
The check that matters most, because **Compose will not tell you**. An unset
|
||||
`${VAR}` interpolates to the empty string; Compose prints a warning to a terminal
|
||||
nobody is watching and starts anyway. A new required setting therefore arrives as
|
||||
a silently blank environment variable and misbehaves later, far from the cause.
|
||||
The updater names the missing keys instead.
|
||||
|
||||
Commented-out lines in `.env.example` are deliberately *not* keys — that is how
|
||||
the file marks optional overrides such as `# PUID=1000`, and counting them would
|
||||
block updates on settings you are meant to leave alone.
|
||||
|
||||
### 4. A restart policy that would not bring the container back
|
||||
|
||||
Before signalling the server, the updater asks the Docker daemon for its own
|
||||
container's restart policy. If it is `no`, the update is refused: applying it
|
||||
would take Codeman down and leave no UI to recover from.
|
||||
|
||||
If the policy cannot be read at all (no Docker socket mounted) the update is
|
||||
still allowed, but the final step changes: the server exits only when the
|
||||
Compose file declared `CODEMAN_RESTART_BY_EXIT=1` (the shipped one does, because
|
||||
it is the file that sets `restart: unless-stopped`) or the daemon confirmed an
|
||||
auto-restart policy. Otherwise the build completes and the panel asks you to
|
||||
restart the container by hand. A container started by plain `docker run` with no
|
||||
restart policy therefore gets a staged update, never an outage.
|
||||
|
||||
### What the gate deliberately does not do
|
||||
|
||||
Every unknown fails **open**:
|
||||
|
||||
- A missing fingerprint baseline (a container started before this feature existed)
|
||||
is not treated as a change, or those installs could never update at all.
|
||||
- An unreadable `.env`, an unreachable Docker socket, or a target tag whose files
|
||||
cannot be read all yield "no blocker" rather than a refusal.
|
||||
|
||||
The one place an unknown does NOT fail open is the kill itself: with neither the
|
||||
Compose declaration nor a daemon answer, the updater stages the build and asks
|
||||
for a manual restart rather than exiting a server nothing may bring back.
|
||||
|
||||
The gate catches a specific, detectable class of mistake; it is not a last line of
|
||||
defence. It is also re-evaluated server-side on `POST /api/system/update`, so
|
||||
hiding the button in the UI is a courtesy rather than the control.
|
||||
|
||||
## The one residual risk
|
||||
|
||||
The gate is derived from the diff, so it cannot see a release that needs a newer
|
||||
environment **without changing any of those files** — for example, code that
|
||||
depends on newer agent-CLI behaviour.
|
||||
|
||||
That is why the four global CLIs in `server.Dockerfile` are **pinned**. Unpinned,
|
||||
the versions a user ends up with are a function of when their image was built
|
||||
rather than of any commit, and in-app updates make rebuilds rarer, which makes
|
||||
that drift worse over time. Pinned, "this release needs a newer CLI" becomes a
|
||||
Dockerfile change, which check 1 already detects. Bump them deliberately, as part
|
||||
of a release.
|
||||
|
||||
The complementary merge-side guard is `test/docker-compose-env-parity.test.ts`,
|
||||
which fails CI when a variable is added to `docker-compose.yaml` without an entry
|
||||
in `.env.example`, or the reverse.
|
||||
|
||||
## Sequence of an in-place update
|
||||
|
||||
1. **Check** — `GET /api/system/update/check` finds the latest release tag, fetches
|
||||
that one ref so the gate can read the target's files, and returns any blockers.
|
||||
2. **Start** — `POST /api/system/update` re-evaluates the gate, writes `queued` to
|
||||
`update-status.json`, stages `self-update.sh` outside the repo and runs it.
|
||||
3. **Apply** — stash if dirty, fetch the tag, check it out, `npm install
|
||||
--include=dev`, `npm run build`. A failure at any step rolls back to the
|
||||
previous commit, rebuilds it and reports `failed`; the server is never
|
||||
restarted into a broken build.
|
||||
4. **Restart** — write the terminal `restarting` marker, then signal the server.
|
||||
The container exits and Docker restarts it.
|
||||
5. **Reconcile** — the rebooted server compares its own version against the target
|
||||
and flips the status to `completed` or `failed`. The browser, still polling,
|
||||
picks that up.
|
||||
|
||||
Step 4 kills the updater script along with the container — unlike the systemd
|
||||
path, it does not outlive the restart. That is safe only because the terminal
|
||||
marker is written first, which is why nothing may be appended after the kill.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"This install can't update itself (unknown)"** — the repo bind mount is missing,
|
||||
so the container is running the baked image copy. Check `CODEMAN_REPO_PATH` and
|
||||
confirm the mounted directory really contains `.git`.
|
||||
|
||||
**The update fails immediately with a git ownership or permission error** — the
|
||||
mounted checkout belongs to a different user than the one Codeman runs as
|
||||
(`PUID`), so git refuses it as "dubious ownership". `Start-Codeman.sh` warns
|
||||
about this at start; fix it by chowning the checkout to the same account that
|
||||
owns `CODEMAN_APPDATA_PATH`.
|
||||
|
||||
**A rebuild is reported as required every time** — the fingerprint baseline does
|
||||
not match the checkout. `Start-Codeman.sh` writes it on every start, so start
|
||||
through that script rather than a bare `docker compose up` after either file
|
||||
changes.
|
||||
|
||||
**Codeman does not come back after an update** — the build succeeded, since the
|
||||
updater gates the restart on it, so read the container logs with `docker compose
|
||||
logs codeman`. To roll back, check out the previous tag in the host checkout and
|
||||
run `docker/Start-Codeman.sh`.
|
||||
|
||||
**The update failed during `npm install`** — most likely a native rebuild with no
|
||||
toolchain, meaning the image predates the toolchain being added. Rebuild once from
|
||||
the host and the in-app path works from then on.
|
||||
|
||||
**Resetting the build artefacts** — `docker compose down -v`, then
|
||||
`Start-Codeman.sh`. This discards the named volumes and re-seeds them from a fresh
|
||||
image.
|
||||
|
||||
## Disabling it
|
||||
|
||||
Set `CODEMAN_DISABLE_SELF_UPDATE=1` in `docker/.env` and pass it through in the
|
||||
compose file's `environment:` block. The Updates panel then reports that in-app
|
||||
updates are disabled, and the host-side script is the only way to update.
|
||||
@@ -518,7 +518,7 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
|
||||
|
||||
- **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑`/api`, non‑`/ws`, non‑`/q` paths. Edges pinned by `test/webview-auth-exemption.test.ts`.
|
||||
- **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard.
|
||||
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from).
|
||||
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from). The one refused destination class is link‑local and cloud‑metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`, `metadata.google.internal`): `webview-egress-policy.ts` refuses them at save time, and `webview-egress.ts` re‑judges the RESOLVED address at connect time through a `lookup` hook on the proxy's undici Agent and on its WebSocket client, so a DNS name pointing into those ranges is refused as well. Loopback and RFC1918 stay allowed on purpose. Capabilities are revoked on logout, admin logout and user deletion, and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability‑bearing URL to a third‑party host it links.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+15
-4
@@ -161,10 +161,21 @@ then every API call fails, which looks like the dashboard being broken.
|
||||
then streams the body without any time bound; a header timeout is logged
|
||||
server-side and answered as a 502 that names the limit. WebSocket handshakes use
|
||||
the separate `CODEMAN_WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS` (default 30s).
|
||||
- **Not a security boundary.** The proxy reaches whatever the Codeman server can
|
||||
reach. That is not an escalation for someone who already commands
|
||||
`--dangerously-skip-permissions` agents, but in multi-user mode it does mean a
|
||||
non-admin user's dashboard is fetched from the server's network position.
|
||||
- **Not a security boundary, with one carve-out.** The proxy reaches whatever the
|
||||
Codeman server can reach (a `localhost` dashboard is the point), so it is not an
|
||||
escalation for someone who already commands `--dangerously-skip-permissions`
|
||||
agents, but in multi-user mode it does mean a non-admin user's dashboard is
|
||||
fetched from the server's network position. The carve-out: link-local and
|
||||
cloud-metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`,
|
||||
Azure's `168.63.129.16`, Alibaba's `100.100.100.200`, the
|
||||
`metadata.google.internal` alias) are refused at save time AND at connect
|
||||
time, judged on the address a name actually resolves to. Nothing anyone embeds
|
||||
as a dashboard lives there; an instance's IAM credentials do.
|
||||
- **The proxy URL is a bearer credential.** `/webview/<cap>/...` needs no cookie,
|
||||
so treat it like a password. It is revoked when you log out, when an admin logs
|
||||
you out, and when your account is deleted, and it expires after 12 hours
|
||||
without use. Proxied responses carry `Referrer-Policy: same-origin`, so a
|
||||
dashboard that links to third-party sites does not hand them the URL.
|
||||
|
||||
## Where the code lives
|
||||
|
||||
|
||||
Generated
+12
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.24.6",
|
||||
"version": "1.24.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.24.6",
|
||||
"version": "1.24.7",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -32,6 +32,7 @@
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"undici": "^6.28.0",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.21.0",
|
||||
@@ -11550,6 +11551,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.24.6",
|
||||
"version": "1.24.7",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -102,6 +102,7 @@
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"undici": "^6.28.0",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.21.0",
|
||||
|
||||
+55
-7
@@ -7,18 +7,25 @@
|
||||
# the repo (the server stages it at ~/.codeman/self-update-runner.sh) — `git
|
||||
# checkout` rewrites the in-repo copy and bash reads scripts lazily.
|
||||
#
|
||||
# ⚠️ The `docker-compose` supervisor is the exception to "outlives": there the
|
||||
# restart IS the container exiting, which kills this script too. That is safe
|
||||
# because the terminal "restarting" marker is written before the kill and the
|
||||
# rebooted server reconciles it — but nothing may be added after that kill.
|
||||
#
|
||||
# Reports progress by writing ~/.codeman/update-status.json atomically; the
|
||||
# browser polls GET /api/system/update/status across the restart drop. The
|
||||
# freshly-booted server reconciles the final "restarting" → "completed"/"failed".
|
||||
#
|
||||
# Cross-platform: restarts via systemd (Linux), launchd (macOS), or prints a
|
||||
# manual command (foreground installs). Linux launches inside a transient
|
||||
# systemd scope so `systemctl restart codeman-web` can't kill it mid-build.
|
||||
# Cross-platform: restarts via systemd (Linux), launchd (macOS), a container exit
|
||||
# under Docker Compose (the restart policy relaunches it), or prints a manual
|
||||
# command (foreground installs). Linux launches inside a transient systemd scope
|
||||
# so `systemctl restart codeman-web` can't kill it mid-build.
|
||||
#
|
||||
# Args (all from the server, never user input — tag is validated server-side):
|
||||
# --repo <dir> --tag <codeman@X.Y.Z> --supervisor <systemd|launchd|none>
|
||||
# --repo <dir> --tag <codeman@X.Y.Z> --supervisor <systemd|launchd|docker-compose|none>
|
||||
# --status-file <path> --update-id <uuid> --from-version <ver> --node <path>
|
||||
# --log <path> [--prev-sha <sha>] [--stash]
|
||||
# --log <path> [--prev-sha <sha>] [--stash] [--server-pid <pid>]
|
||||
# [--restart-by-exit 0|1] (docker-compose only: may we exit the server?)
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
@@ -32,6 +39,7 @@ REPO=""
|
||||
TAG=""
|
||||
SUPERVISOR="none"
|
||||
SERVER_PID=""
|
||||
RESTART_BY_EXIT="0"
|
||||
STATUS_FILE=""
|
||||
UPDATE_ID=""
|
||||
FROM_VERSION=""
|
||||
@@ -52,6 +60,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--log) LOG="$2"; shift 2 ;;
|
||||
--prev-sha) PREV_SHA="$2"; shift 2 ;;
|
||||
--server-pid) SERVER_PID="$2"; shift 2 ;;
|
||||
--restart-by-exit) RESTART_BY_EXIT="$2"; shift 2 ;;
|
||||
--stash) DO_STASH=1; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
@@ -144,7 +153,7 @@ rollback_and_fail() {
|
||||
echo "[self-update] $msg — rolling back to ${PREV_SHA:-<none>}"
|
||||
if [[ -n "$PREV_SHA" ]]; then
|
||||
git checkout --force "$PREV_SHA" >/dev/null 2>&1 || true
|
||||
npm install --no-fund --no-audit >/dev/null 2>&1 || true
|
||||
npm install --no-fund --no-audit --include=dev >/dev/null 2>&1 || true
|
||||
npm run build >/dev/null 2>&1 || true
|
||||
fi
|
||||
fail "$msg — rolled back to the previous version" "$msg"
|
||||
@@ -176,7 +185,9 @@ write_status "checkout" "Checking out $TAG…"
|
||||
git -c advice.detachedHead=false checkout --force "$TAG" || rollback_and_fail "Could not check out $TAG"
|
||||
|
||||
# 4) Install dependencies (heartbeat keeps the UI live during this slow step).
|
||||
run_step "installing" "Installing dependencies" npm install --no-fund --no-audit \
|
||||
# --include=dev: tsc and esbuild are devDependencies, and the Compose image sets
|
||||
# NODE_ENV=production, which would otherwise omit them and fail the build below.
|
||||
run_step "installing" "Installing dependencies" npm install --no-fund --no-audit --include=dev \
|
||||
|| rollback_and_fail "Dependency install failed"
|
||||
|
||||
# 5) Build (gate the restart on success — never restart into a torn dist/).
|
||||
@@ -200,6 +211,43 @@ case "$SUPERVISOR" in
|
||||
|| fail "Build succeeded but launchd restart failed" "launchctl"
|
||||
}
|
||||
;;
|
||||
docker-compose)
|
||||
# In the Compose deployment there is no init system to ask: the "restart" is
|
||||
# the server EXITING, so the container's `restart: unless-stopped` policy
|
||||
# relaunches it on the dist/ we just built. The repo and dist/ live on host
|
||||
# mounts, so the new build survives the container being replaced.
|
||||
#
|
||||
# ⚠️ This script dies WITH the container it is restarting — it is a child of
|
||||
# the server process, not a survivor like the systemd-scope path. That is
|
||||
# fine, and load-bearing: the terminal "restarting" marker is already written
|
||||
# above, and the freshly-booted server reconciles it. Nothing may be appended
|
||||
# after the kill that the update depends on.
|
||||
#
|
||||
# ⚠️ The server is signalled by PID rather than `docker restart`: this
|
||||
# container's own Docker CLI talks to the HOST daemon, and a self-directed
|
||||
# restart there races the client's own death. Exiting is the one path that
|
||||
# needs no cooperation from anything outside the container.
|
||||
#
|
||||
# ⚠️ Only when the SERVER said the container comes back (`--restart-by-exit 1`:
|
||||
# the Compose file declared it, or the daemon reported an auto-restart policy).
|
||||
# An unknown policy stages the build and asks for a restart instead. Exiting
|
||||
# blind would take a container the daemon does not restart down for good,
|
||||
# with no UI left to recover it from.
|
||||
if [[ "$RESTART_BY_EXIT" != "1" ]]; then
|
||||
MANUAL_CMD="docker restart \$(hostname) # from the Docker host"
|
||||
write_status "completed-needs-manual-restart" "Update built — restart the Codeman container to apply v$TO_VERSION."
|
||||
echo "[self-update] docker-compose: restart-by-exit not confirmed — not exiting; manual restart required"
|
||||
exit 0
|
||||
fi
|
||||
if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then
|
||||
: # container exit + restart policy take it from here
|
||||
else
|
||||
MANUAL_CMD="docker restart \$(hostname) # from the Docker host"
|
||||
write_status "completed-needs-manual-restart" "Update staged — restart the Codeman container to apply v$TO_VERSION."
|
||||
echo "[self-update] docker-compose: could not signal server pid '$SERVER_PID' — manual restart required"
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
launchd-daemon)
|
||||
# System-level KeepAlive LaunchDaemon (headless Mac): kickstarting the system
|
||||
# domain needs root, but we don't need it — kill the server and launchd
|
||||
|
||||
+54
-3
@@ -7,6 +7,10 @@
|
||||
* (see `dataPath('update-status.json')`) that the browser polls across the
|
||||
* restart boundary.
|
||||
*
|
||||
* The Docker Compose deployment updates in place too (same script, same status
|
||||
* file) — see `docs/docker-self-update.md` for how the container restarts itself
|
||||
* and what the environment gate refuses.
|
||||
*
|
||||
* Backend logic: `src/web/self-update.ts`. Routes: `src/web/routes/system-routes.ts`
|
||||
* (`/api/system/update/check`, `POST /api/system/update`, `/api/system/update/status`).
|
||||
*
|
||||
@@ -17,11 +21,56 @@
|
||||
* Which init system supervises the running server (decides how we restart it).
|
||||
* `launchd-daemon` = a KeepAlive system-level LaunchDaemon (headless Macs, no GUI
|
||||
* login): restart works by killing the server and letting launchd respawn it.
|
||||
* `docker-compose` = the Compose deployment (`docker/docker-compose.yaml`): the
|
||||
* "restart" is the server exiting so the container's `restart: unless-stopped`
|
||||
* policy relaunches it on the freshly built `dist/`.
|
||||
*/
|
||||
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'none';
|
||||
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'docker-compose' | 'none';
|
||||
|
||||
/** How Codeman was installed — only `git` installs can self-update in place. */
|
||||
export type InstallKind = 'git' | 'npm' | 'unknown';
|
||||
/**
|
||||
* How Codeman was installed. `git` and `docker-compose` can self-update in
|
||||
* place; `docker-compose` is a git checkout bind-mounted into the container, so
|
||||
* the pull/build happen on the host filesystem and survive container recreation.
|
||||
*/
|
||||
export type InstallKind = 'git' | 'docker-compose' | 'npm' | 'unknown';
|
||||
|
||||
/**
|
||||
* Why an in-place container update is refused. Each is derived mechanically from
|
||||
* the target release's own files — nothing here depends on a human remembering
|
||||
* to declare something at release time.
|
||||
*
|
||||
* - `dockerfile-changed` / `compose-changed`: the release changes the ENVIRONMENT,
|
||||
* which a self-restart cannot apply (a restart reuses the existing container's
|
||||
* image and config). Needs a rebuild + recreate from the host.
|
||||
* - `env-keys-missing`: the release's `docker/.env.example` gained keys the user's
|
||||
* `docker/.env` has no value for. Compose interpolates an unset `${VAR}` to the
|
||||
* EMPTY STRING and starts anyway, so without this check a new required setting
|
||||
* arrives as a silently blank env var.
|
||||
* - `no-auto-restart`: the container's restart policy would not bring it back
|
||||
* after the server exits, so applying the update would take Codeman down.
|
||||
*/
|
||||
export type EnvironmentBlockerKind = 'dockerfile-changed' | 'compose-changed' | 'env-keys-missing' | 'no-auto-restart';
|
||||
|
||||
/** One reason an in-place container update is refused, with UI-ready text. */
|
||||
export interface EnvironmentBlocker {
|
||||
kind: EnvironmentBlockerKind;
|
||||
/** One-line explanation shown in App Settings → Updates. */
|
||||
message: string;
|
||||
/** Optional specifics (e.g. the names of the missing env keys). */
|
||||
details?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the environment gate for a candidate release. `checked: false` means
|
||||
* the gate did not run (not a container install, or the target tag's files could
|
||||
* not be read) — callers must not treat that as "no blockers".
|
||||
*/
|
||||
export interface EnvironmentGate {
|
||||
checked: boolean;
|
||||
blockers: EnvironmentBlocker[];
|
||||
/** The host command that resolves every blocker. */
|
||||
hostCommand: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle of a single update run. `idle`/`completed`/`failed`/
|
||||
@@ -96,6 +145,8 @@ export interface UpdateCheckResult {
|
||||
/** epoch ms of the check. */
|
||||
checkedAt: number;
|
||||
source: 'github-api' | 'git-ls-remote' | 'none';
|
||||
/** Environment gate for THIS candidate release (container installs only). */
|
||||
environment?: EnvironmentGate;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1079,10 +1079,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const verEl = this.$('updateCurrentVersion');
|
||||
if (verEl && data.currentVersion) verEl.textContent = `v${data.currentVersion}`;
|
||||
|
||||
if (data.installKind && data.installKind !== 'git') {
|
||||
this._setUpdateResult(
|
||||
`This install can't update itself (${escapeHtml(data.installKind)}). Update with <code>npm i -g aicodeman@latest</code>.`
|
||||
);
|
||||
// `docker-compose` self-updates in place like `git` does — the container
|
||||
// restarts itself. Anything else cannot.
|
||||
if (data.installKind && data.installKind !== 'git' && data.installKind !== 'docker-compose') {
|
||||
const hint =
|
||||
data.supervisor === 'docker-compose'
|
||||
? 'Update from the Docker host with <code>docker/Start-Codeman.sh</code>.'
|
||||
: 'Update with <code>npm i -g aicodeman@latest</code>.';
|
||||
this._setUpdateResult(`This install can't update itself (${escapeHtml(data.installKind)}). ${hint}`);
|
||||
return;
|
||||
}
|
||||
if (data.selfUpdateEnabled === false) {
|
||||
@@ -1093,6 +1097,30 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._setUpdateResult(escapeHtml(data.error));
|
||||
return;
|
||||
}
|
||||
// A container release that changes the ENVIRONMENT (Dockerfile, compose file
|
||||
// or new .env keys) cannot be applied by the container restarting itself, so
|
||||
// the update button is never offered — the host command is, instead. The
|
||||
// server re-checks this on POST, so hiding the button is UX, not the gate.
|
||||
const blockers = data.environment?.blockers || [];
|
||||
if (data.updateAvailable && blockers.length > 0) {
|
||||
const reasons = blockers
|
||||
.map((b) => {
|
||||
const details = b.details?.length ? `<br><code>${escapeHtml(b.details.join(' '))}</code>` : '';
|
||||
return `<li>${escapeHtml(b.message)}${details}</li>`;
|
||||
})
|
||||
.join('');
|
||||
this._setUpdateResult(
|
||||
`<strong>v${escapeHtml(data.latestVersion || '')}</strong> needs a rebuild on the Docker host` +
|
||||
` (current v${escapeHtml(data.currentVersion || '')}):<ul>${reasons}</ul>` +
|
||||
`Run <code>${escapeHtml(data.environment?.hostCommand || 'docker/Start-Codeman.sh')}</code> there to apply it.`
|
||||
);
|
||||
if (notes && data.notes) {
|
||||
notes.style.display = 'block';
|
||||
notes.textContent = data.notes;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.updateAvailable && data.latestVersion) {
|
||||
this._setUpdateResult(
|
||||
`Update available: <strong>v${escapeHtml(data.latestVersion)}</strong> (current v${escapeHtml(data.currentVersion || '')})`
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
UserStoreError,
|
||||
} from '../../user-store.js';
|
||||
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { appendAdminAudit } from '../admin-audit.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { AuthPort } from '../ports/auth-port.js';
|
||||
@@ -179,7 +180,10 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const revoked = revokeUserSessions(ctx.authSessions, username);
|
||||
audit(req, 'user.logout', username, { revoked });
|
||||
// Web-tab proxy capabilities are a second credential the cookie purge does not
|
||||
// touch; a forced logout that left them alive would not be a logout.
|
||||
const revokedWebviews = webviewCapabilities.revokeOwner(normalizeUsername(username));
|
||||
audit(req, 'user.logout', username, { revoked, revokedWebviews });
|
||||
return { success: true, data: { revoked } };
|
||||
});
|
||||
|
||||
@@ -201,6 +205,7 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
await ctx.cleanupSession(id, true, 'admin_delete_user').catch(() => {});
|
||||
}
|
||||
revokeUserSessions(ctx.authSessions, username);
|
||||
webviewCapabilities.revokeOwner(normalizeUsername(username));
|
||||
if (deleteSpace) await deleteUserSpace(username);
|
||||
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '../../types.js';
|
||||
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import {
|
||||
CreateSessionSchema,
|
||||
SessionNameSchema,
|
||||
@@ -821,6 +822,10 @@ export function registerSessionRoutes(
|
||||
if (sessionToken) {
|
||||
ctx.authSessions?.delete(sessionToken);
|
||||
}
|
||||
// The web-tab proxy authenticates on capabilities, not on this cookie, so a
|
||||
// logout has to retire them too or every dashboard URL opened during this
|
||||
// login keeps relaying without one (WebviewCapabilityStore.revokeOwner).
|
||||
webviewCapabilities.revokeOwner(ownerFor(req));
|
||||
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -390,6 +390,9 @@ export function registerSystemRoutes(
|
||||
'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
|
||||
'up-to-date': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
|
||||
'not-git': { http: 400, api: ApiErrorCode.INVALID_INPUT },
|
||||
// A container release that changes the ENVIRONMENT: not a client error to
|
||||
// retry, it needs a host-side rebuild (docs/docker-self-update.md).
|
||||
'env-blocked': { http: 409, api: ApiErrorCode.INVALID_INPUT },
|
||||
disabled: { http: 403, api: ApiErrorCode.INVALID_INPUT },
|
||||
'bad-tag': { http: 400, api: ApiErrorCode.INVALID_INPUT },
|
||||
error: { http: 500, api: ApiErrorCode.INTERNAL_ERROR },
|
||||
|
||||
@@ -33,7 +33,8 @@ import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { WebSocket as WsClient } from 'ws';
|
||||
import type { WebSocket } from 'ws';
|
||||
import type { ClientOptions as WsClientOptions, WebSocket } from 'ws';
|
||||
import type { Response as UndiciResponse } from 'undici';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
MAX_LIVE_WEBVIEW_FRAMES,
|
||||
@@ -47,6 +48,8 @@ import {
|
||||
} from '../../config/webview-limits.js';
|
||||
import { readWebviews, writeWebviews } from '../../webview-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { egressBlockedReason, webviewEgressLookup, webviewFetch, type EgressLookup } from '../webview-egress.js';
|
||||
import { blockedWebviewHostReason } from '../webview-egress-policy.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import type { Webview, WebviewOpenData, WebviewProbe } from '../../types.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
@@ -293,7 +296,7 @@ async function probeUrl(url: string): Promise<WebviewProbe> {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(target.href, {
|
||||
const response = await webviewFetch(target, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(WEBVIEW_PROBE_TIMEOUT_MS),
|
||||
@@ -326,6 +329,12 @@ async function probeUrl(url: string): Promise<WebviewProbe> {
|
||||
reason,
|
||||
};
|
||||
} catch (err) {
|
||||
const blocked = egressBlockedReason(err);
|
||||
if (blocked) {
|
||||
// Refused by policy, not unreachable: say so, or the user reads it as a
|
||||
// network problem and starts debugging their firewall.
|
||||
return { reachable: false, framable: false, recommendedMode: 'proxy', reason: blocked };
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
reachable: false,
|
||||
@@ -476,19 +485,19 @@ async function proxyRequest(
|
||||
// string (it can carry the dashboard's tokens).
|
||||
const logTarget = `${req.method} ${upstream.origin}${upstream.pathname}`;
|
||||
|
||||
let response: Response;
|
||||
let response: UndiciResponse;
|
||||
try {
|
||||
response = await fetch(upstream.href, {
|
||||
response = await webviewFetch(upstream, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: hasBody ? (req.body as Readable) : undefined,
|
||||
// Required by undici whenever the body is a stream.
|
||||
...(hasBody ? { duplex: 'half' } : {}),
|
||||
...(hasBody ? { duplex: 'half' as const } : {}),
|
||||
// Redirects are rewritten into the proxy prefix instead of followed, so the
|
||||
// browser's URL stays inside the frame and relative assets keep resolving.
|
||||
redirect: 'manual',
|
||||
signal: abort.signal,
|
||||
} as RequestInit);
|
||||
});
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (clientGone) {
|
||||
@@ -496,6 +505,13 @@ async function proxyRequest(
|
||||
// failure, so no warn (it would read as the dashboard being broken).
|
||||
return reply;
|
||||
}
|
||||
const blocked = egressBlockedReason(err);
|
||||
if (blocked) {
|
||||
// Policy refusal, distinct from "unreachable": a record saved before the
|
||||
// egress rule existed, or a name that now resolves into a blocked range.
|
||||
console.warn(`[Webview] refused by egress policy: ${logTarget} (webview "${webview.name}"): ${blocked}`);
|
||||
return reply.code(403).type('text/plain').send(`Forbidden: ${blocked}`);
|
||||
}
|
||||
if (headerTimedOut) {
|
||||
console.warn(
|
||||
`[Webview] upstream sent no response headers within ${WEBVIEW_UPSTREAM_TIMEOUT_MS}ms: ` +
|
||||
@@ -644,6 +660,13 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa
|
||||
return;
|
||||
}
|
||||
|
||||
// An IP literal never reaches the lookup hook (net.connect skips DNS for it),
|
||||
// so the literal form is judged here and the resolved form in the lookup.
|
||||
if (blockedWebviewHostReason(upstream.hostname)) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
socketCounts.set(webview.id, live + 1);
|
||||
let released = false;
|
||||
const release = () => {
|
||||
@@ -655,16 +678,21 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa
|
||||
};
|
||||
|
||||
const protocols = req.headers['sec-websocket-protocol'];
|
||||
// `lookup` is absent from ws's ClientOptions typings but flows through
|
||||
// http.request to net.connect untouched, which is where the resolved
|
||||
// address is judged (see webview-egress.ts).
|
||||
const upstreamOptions: WsClientOptions & { lookup: EgressLookup } = {
|
||||
headers: {
|
||||
origin: upstream.origin,
|
||||
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
|
||||
},
|
||||
handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS,
|
||||
lookup: webviewEgressLookup,
|
||||
};
|
||||
const upstreamSocket = new WsClient(
|
||||
upstreamWebSocketUrl(upstream),
|
||||
protocols ? String(protocols).split(/,\s*/) : [],
|
||||
{
|
||||
headers: {
|
||||
origin: upstream.origin,
|
||||
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
|
||||
},
|
||||
handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS,
|
||||
}
|
||||
upstreamOptions
|
||||
);
|
||||
|
||||
// Buffer anything the browser sends before the upstream handshake completes,
|
||||
@@ -703,9 +731,14 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa
|
||||
socket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||
upstreamSocket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||
socket.on('error', () => closeBoth());
|
||||
upstreamSocket.on('error', () => {
|
||||
upstreamSocket.on('error', (err: Error) => {
|
||||
release();
|
||||
if (socket.readyState === socket.OPEN) socket.close(1011, 'Upstream error');
|
||||
if (socket.readyState !== socket.OPEN) return;
|
||||
// A name that resolved into a blocked range fails inside the connect, so it
|
||||
// surfaces here rather than at the sync check above; report it as the same
|
||||
// policy refusal, not as the dashboard being broken.
|
||||
if (egressBlockedReason(err)) socket.close(4003, 'Forbidden');
|
||||
else socket.close(1011, 'Upstream error');
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { z } from 'zod';
|
||||
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
|
||||
import { isValidWebviewUrl } from './webview-proxy.js';
|
||||
import { isBlockedWebviewUrl } from './webview-egress-policy.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_BYTES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
@@ -1762,6 +1763,13 @@ const webviewUrlSchema = z
|
||||
.max(2000, 'URL too long (max 2000 chars)')
|
||||
.refine(isValidWebviewUrl, {
|
||||
message: 'Invalid URL: must be http(s), with a hostname and no embedded credentials',
|
||||
})
|
||||
// Egress policy (`webview-egress-policy.ts`): no dashboard lives at a link-local
|
||||
// or cloud-metadata address, while an IAM credential does. Refused at save time
|
||||
// for the clear message; the proxy re-judges the RESOLVED address at connect time.
|
||||
.refine((url) => !isBlockedWebviewUrl(url), {
|
||||
message:
|
||||
'Blocked URL: link-local and cloud-metadata addresses (169.254.0.0/16, metadata.google.internal, ...) cannot be dashboards',
|
||||
});
|
||||
|
||||
const WebviewBaseSchema = z.object({
|
||||
|
||||
+327
-14
@@ -16,6 +16,15 @@
|
||||
* tested, and IO wrappers (`getInstallInfo`, `checkForUpdate`, `startUpdate`,
|
||||
* `reconcileUpdateOnBoot`) that touch git/network/fs.
|
||||
*
|
||||
* DOCKER COMPOSE installs update in place too, through the same script and the
|
||||
* same status file. The repo is a host bind mount, so the pull/build land on the
|
||||
* host filesystem and survive container recreation; the "restart" is the server
|
||||
* EXITING so the container's restart policy relaunches it on the new `dist/`.
|
||||
* That applies CODE only — a restart reuses the existing container's image and
|
||||
* config — so `evaluateEnvironmentGate()` refuses a release that changes
|
||||
* `server.Dockerfile`, `docker-compose.yaml` or `.env.example`, pointing at the
|
||||
* host command instead. See `docs/docker-self-update.md`.
|
||||
*
|
||||
* Related: `src/types/update.ts`, `scripts/self-update.sh`, routes in
|
||||
* `src/web/routes/system-routes.ts`.
|
||||
*
|
||||
@@ -26,13 +35,15 @@ import { spawn, execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir, hostname, tmpdir } from 'node:os';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import type {
|
||||
EnvironmentBlocker,
|
||||
EnvironmentGate,
|
||||
InstallInfo,
|
||||
InstallKind,
|
||||
SupervisorKind,
|
||||
@@ -215,6 +226,139 @@ export function reconcileStatusDecision(
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PURE helpers — the container environment gate
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Host command that resolves every environment blocker. */
|
||||
export const DOCKER_HOST_UPDATE_COMMAND = 'docker/Start-Codeman.sh';
|
||||
|
||||
/**
|
||||
* Parse the SET keys out of a dotenv file. Commented-out lines are deliberately
|
||||
* NOT keys: `docker/.env.example` uses `# PUID=1000` to document an OPTIONAL
|
||||
* override, so treating those as required would block every update on settings
|
||||
* the user is meant to leave alone.
|
||||
*/
|
||||
export function parseEnvKeys(text: string): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const m = line.replace(/^export\s+/, '').match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
||||
if (m && !keys.includes(m[1])) keys.push(m[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys the TARGET release's `.env.example` sets that the user's `.env` does not.
|
||||
*
|
||||
* This is the check that makes a new required setting visible: Compose resolves
|
||||
* an unset `${VAR}` to the empty string and starts anyway, so a missing key is
|
||||
* otherwise silent until something misbehaves at runtime.
|
||||
*/
|
||||
export function diffRequiredEnvKeys(targetExample: string, userEnv: string): string[] {
|
||||
const have = new Set(parseEnvKeys(userEnv));
|
||||
return parseEnvKeys(targetExample).filter((k) => !have.has(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the container's restart policy relaunches it after the server exits.
|
||||
* `no` and an empty policy mean an in-place update would take Codeman DOWN
|
||||
* rather than restart it, so the update is refused instead.
|
||||
*/
|
||||
export function isAutoRestartPolicy(name: string | null | undefined): boolean {
|
||||
return name === 'always' || name === 'unless-stopped' || name === 'on-failure';
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE: may the container updater restart the server by exiting? Yes when the
|
||||
* Compose file declared it (`CODEMAN_RESTART_BY_EXIT=1`, set only there, since
|
||||
* that file is what sets `restart: unless-stopped`) or when the daemon reports an
|
||||
* auto-restart policy. Otherwise the answer is NO, and the updater stages the
|
||||
* build and asks for a manual restart instead of exiting: an unknown policy is
|
||||
* fine to fail open in the GATE (refusing would block installs with no socket),
|
||||
* but the kill itself must not fail open, or a container the daemon would not
|
||||
* bring back goes down with no UI left to recover it from.
|
||||
*/
|
||||
export function shouldRestartByExit(declared: boolean, restartPolicy: string | null): boolean {
|
||||
return declared || isAutoRestartPolicy(restartPolicy);
|
||||
}
|
||||
|
||||
/** The Compose file's declaration that exiting relaunches this container. */
|
||||
export function restartByExitDeclared(): boolean {
|
||||
return process.env.CODEMAN_RESTART_BY_EXIT === '1';
|
||||
}
|
||||
|
||||
export interface EnvironmentGateInput {
|
||||
/** sha256 of `docker/server.Dockerfile` the running container was built from. */
|
||||
appliedDockerfileHash: string | null;
|
||||
/** sha256 of `docker/server.Dockerfile` at the target release tag. */
|
||||
targetDockerfileHash: string | null;
|
||||
/** sha256 of `docker/docker-compose.yaml` the running container was created from. */
|
||||
appliedComposeHash: string | null;
|
||||
/** sha256 of `docker/docker-compose.yaml` at the target release tag. */
|
||||
targetComposeHash: string | null;
|
||||
/** Keys from `diffRequiredEnvKeys()`. */
|
||||
missingEnvKeys: string[];
|
||||
/** Docker restart policy name of the running container, or null if unknown. */
|
||||
restartPolicy: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE gate decision. An in-place container update applies CODE only: the server
|
||||
* exits and the container's restart policy relaunches it on the new `dist/`. A
|
||||
* restart reuses the existing container's image and config, so anything that
|
||||
* changes the ENVIRONMENT cannot take effect that way and is refused here with
|
||||
* the host command that can apply it.
|
||||
*
|
||||
* ⚠️ An unknown hash (null) is NOT treated as "changed": a first update from a
|
||||
* container created before the fingerprint file existed has no baseline, and
|
||||
* failing closed there would block every such install from ever updating. The
|
||||
* baseline is written by `Start-Codeman.sh`, so it exists from the first
|
||||
* host-side start onward. An unknown restart policy is likewise not a blocker —
|
||||
* the shipped Compose file sets `unless-stopped`, and the probe needs the Docker
|
||||
* socket, which a user may not have mounted.
|
||||
*/
|
||||
export function computeEnvironmentBlockers(input: EnvironmentGateInput): EnvironmentBlocker[] {
|
||||
const blockers: EnvironmentBlocker[] = [];
|
||||
|
||||
if (
|
||||
input.appliedDockerfileHash &&
|
||||
input.targetDockerfileHash &&
|
||||
input.appliedDockerfileHash !== input.targetDockerfileHash
|
||||
) {
|
||||
blockers.push({
|
||||
kind: 'dockerfile-changed',
|
||||
message: 'This release changes docker/server.Dockerfile, so the image must be rebuilt.',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.appliedComposeHash && input.targetComposeHash && input.appliedComposeHash !== input.targetComposeHash) {
|
||||
blockers.push({
|
||||
kind: 'compose-changed',
|
||||
message: 'This release changes docker/docker-compose.yaml, so the container must be recreated.',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.missingEnvKeys.length > 0) {
|
||||
blockers.push({
|
||||
kind: 'env-keys-missing',
|
||||
message: `This release adds ${input.missingEnvKeys.length} setting(s) your docker/.env has no value for.`,
|
||||
details: input.missingEnvKeys,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.restartPolicy !== null && !isAutoRestartPolicy(input.restartPolicy)) {
|
||||
blockers.push({
|
||||
kind: 'no-auto-restart',
|
||||
message: `This container's restart policy is "${input.restartPolicy}", so it would not come back after the update.`,
|
||||
});
|
||||
}
|
||||
|
||||
return blockers;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Status file IO
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -273,19 +417,149 @@ export function resolveInstallDir(): string {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this process runs inside a container. `/.dockerenv` is created by the
|
||||
* Docker daemon itself; the env var is set by our own Compose file so the check
|
||||
* also holds under runtimes that omit that file.
|
||||
*/
|
||||
export function isRunningInContainer(): boolean {
|
||||
return process.env.CODEMAN_IN_CONTAINER === '1' || existsSync('/.dockerenv');
|
||||
}
|
||||
|
||||
function detectInstallKind(dir: string): InstallKind {
|
||||
if (existsSync(join(dir, '.git'))) return 'git';
|
||||
// A container whose code is a bind-mounted checkout updates in place (the pull
|
||||
// and build land on the host filesystem and survive container recreation). A
|
||||
// container WITHOUT that mount runs a baked image copy — a pull there would go
|
||||
// to the writable layer and vanish on the next `up`, so it is not updatable.
|
||||
if (existsSync(join(dir, '.git'))) return isRunningInContainer() ? 'docker-compose' : 'git';
|
||||
// Global npm install ships only dist/ (no src/, no .git).
|
||||
if (!existsSync(join(dir, 'src'))) return 'npm';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Install kinds whose update is applied in place by `scripts/self-update.sh`. */
|
||||
export function canSelfUpdateInPlace(kind: InstallKind): boolean {
|
||||
return kind === 'git' || kind === 'docker-compose';
|
||||
}
|
||||
|
||||
/** Path of the fingerprint baseline written by `docker/Start-Codeman.sh`. */
|
||||
const DOCKER_ENV_APPLIED_FILE = dataPath('docker-env-applied.json');
|
||||
|
||||
/** Files whose content defines the container ENVIRONMENT (vs. the app's code). */
|
||||
const DOCKERFILE_REL = 'docker/server.Dockerfile';
|
||||
const COMPOSE_REL = 'docker/docker-compose.yaml';
|
||||
const ENV_EXAMPLE_REL = 'docker/.env.example';
|
||||
const ENV_REL = 'docker/.env';
|
||||
|
||||
function sha256(text: string): string {
|
||||
return createHash('sha256').update(text, 'utf-8').digest('hex');
|
||||
}
|
||||
|
||||
/** Read a file at a git TAG without checking it out (`git show tag:path`). */
|
||||
function gitShowAtTag(repo: string, tag: string, relPath: string): string | null {
|
||||
return tryExec('git', ['show', `${tag}:${relPath}`], repo);
|
||||
}
|
||||
|
||||
function readFileOrNull(path: string): string | null {
|
||||
try {
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fingerprints the RUNNING container was created from, recorded on the host
|
||||
* by `Start-Codeman.sh` at each build/recreate. Returns nulls when absent (a
|
||||
* container started before this file existed) — `computeEnvironmentBlockers()`
|
||||
* deliberately treats an unknown baseline as "not a blocker".
|
||||
*/
|
||||
function readAppliedEnvironmentFingerprints(): { dockerfile: string | null; compose: string | null } {
|
||||
const raw = readFileOrNull(DOCKER_ENV_APPLIED_FILE);
|
||||
if (!raw) return { dockerfile: null, compose: null };
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { dockerfileSha256?: string; composeSha256?: string };
|
||||
return { dockerfile: parsed.dockerfileSha256 ?? null, compose: parsed.composeSha256 ?? null };
|
||||
} catch {
|
||||
return { dockerfile: null, compose: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart policy of the container we're running in, via the mounted Docker
|
||||
* socket. Returns null when the socket or CLI is unavailable — an unknown policy
|
||||
* is not a blocker (see `computeEnvironmentBlockers`).
|
||||
*/
|
||||
function detectOwnRestartPolicy(): string | null {
|
||||
// Docker sets HOSTNAME to the short container id; os.hostname() is the same
|
||||
// value when the env var is absent. A custom `hostname:` in the compose file
|
||||
// makes both unresolvable to the daemon, which fails open (unknown is not a
|
||||
// blocker) rather than refusing an update over a cosmetic setting.
|
||||
const id = process.env.HOSTNAME || hostname();
|
||||
if (!id) return null;
|
||||
const out = tryExec('docker', ['inspect', '--format', '{{.HostConfig.RestartPolicy.Name}}', id]);
|
||||
return out && out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the environment gate for a candidate release tag. Reads the TARGET
|
||||
* tag's files straight out of git (`git show`), so nothing is checked out and the
|
||||
* answer is available at CHECK time — the UI can refuse before the user commits
|
||||
* to an update.
|
||||
*/
|
||||
export function evaluateEnvironmentGate(installDir: string, tag: string): EnvironmentGate {
|
||||
// `git show <tag>:<path>` needs the tag's objects locally, and neither the
|
||||
// GitHub API nor `ls-remote` fetches anything — so a check that has never seen
|
||||
// this tag would read nothing and report a falsely clean gate. Fetch the one
|
||||
// ref first (cheap: it deltas against what the clone already has) and only
|
||||
// then read. The updater fetches the same ref again; both are idempotent.
|
||||
if (tryExec('git', ['rev-parse', '--verify', '--quiet', `${tag}^{commit}`], installDir) === null) {
|
||||
tryExec(
|
||||
'git',
|
||||
['fetch', '--tags', '--force', 'origin', `refs/tags/${tag}:refs/tags/${tag}`],
|
||||
installDir,
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
const targetDockerfile = gitShowAtTag(installDir, tag, DOCKERFILE_REL);
|
||||
const targetCompose = gitShowAtTag(installDir, tag, COMPOSE_REL);
|
||||
const targetExample = gitShowAtTag(installDir, tag, ENV_EXAMPLE_REL);
|
||||
|
||||
// No environment files at the target tag at all: we cannot judge, so say so
|
||||
// rather than reporting a clean gate the caller would trust.
|
||||
if (targetDockerfile === null && targetCompose === null && targetExample === null) {
|
||||
return { checked: false, blockers: [], hostCommand: DOCKER_HOST_UPDATE_COMMAND };
|
||||
}
|
||||
|
||||
const applied = readAppliedEnvironmentFingerprints();
|
||||
const userEnv = readFileOrNull(join(installDir, ENV_REL));
|
||||
|
||||
const blockers = computeEnvironmentBlockers({
|
||||
appliedDockerfileHash: applied.dockerfile,
|
||||
targetDockerfileHash: targetDockerfile === null ? null : sha256(targetDockerfile),
|
||||
appliedComposeHash: applied.compose,
|
||||
targetComposeHash: targetCompose === null ? null : sha256(targetCompose),
|
||||
// A missing/unreadable .env cannot be diffed — report no missing keys rather
|
||||
// than every key, which would block on an install using a non-standard path.
|
||||
missingEnvKeys: targetExample !== null && userEnv !== null ? diffRequiredEnvKeys(targetExample, userEnv) : [],
|
||||
restartPolicy: detectOwnRestartPolicy(),
|
||||
});
|
||||
|
||||
return { checked: true, blockers, hostCommand: DOCKER_HOST_UPDATE_COMMAND };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which init system supervises us. Detection happens HERE (in the running
|
||||
* server, which has a rich env) and the result is passed to the updater script —
|
||||
* the detached child must not re-probe with a stripped-down environment.
|
||||
*/
|
||||
export function detectSupervisor(): SupervisorKind {
|
||||
// Checked FIRST: a container has no init system of its own, and its "restart"
|
||||
// is the server exiting so the Docker restart policy relaunches it. Probing
|
||||
// systemd here would find nothing and report `none`, which stages the update
|
||||
// and then asks the user to restart by hand for no reason.
|
||||
if (isRunningInContainer()) return 'docker-compose';
|
||||
if (process.platform === 'darwin') {
|
||||
if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd';
|
||||
// Headless Macs (no GUI login → no gui domain) run Codeman as a system-level
|
||||
@@ -389,17 +663,29 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
checkedAt,
|
||||
source: 'none',
|
||||
};
|
||||
if (info.installKind !== 'git') {
|
||||
return { ...base, error: 'Not a git install — self-update is unavailable.' };
|
||||
if (!canSelfUpdateInPlace(info.installKind)) {
|
||||
return {
|
||||
...base,
|
||||
error:
|
||||
info.installKind === 'unknown' && isRunningInContainer()
|
||||
? 'This container runs a baked image copy with no repository mounted — self-update is unavailable. See docs/docker-self-update.md.'
|
||||
: 'Not a git install — self-update is unavailable.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Attach the container environment gate to a finished check result. */
|
||||
const withGate = (result: UpdateCheckResult): UpdateCheckResult => {
|
||||
if (info.installKind !== 'docker-compose' || !result.latestTag || !result.updateAvailable) return result;
|
||||
return { ...result, environment: evaluateEnvironmentGate(info.installDir, result.latestTag) };
|
||||
};
|
||||
|
||||
const remote = tryExec('git', ['remote', 'get-url', 'origin'], info.installDir);
|
||||
const gh = remote ? parseGitHubRepo(remote) : null;
|
||||
|
||||
if (gh) {
|
||||
const rel = await fetchLatestReleaseFromGitHub(gh.owner, gh.repo);
|
||||
if (rel) {
|
||||
return {
|
||||
return withGate({
|
||||
...base,
|
||||
latestVersion: rel.version,
|
||||
latestTag: rel.tag,
|
||||
@@ -407,20 +693,20 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
htmlUrl: rel.htmlUrl,
|
||||
updateAvailable: isNewerStableVersion(info.currentVersion, rel.version),
|
||||
source: 'github-api',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: enumerate remote tags directly (works for non-GitHub remotes too).
|
||||
const viaGit = fetchLatestTagViaGit(info.installDir);
|
||||
if (viaGit) {
|
||||
return {
|
||||
return withGate({
|
||||
...base,
|
||||
latestVersion: viaGit.version,
|
||||
latestTag: viaGit.tag,
|
||||
updateAvailable: isNewerStableVersion(info.currentVersion, viaGit.version),
|
||||
source: 'git-ls-remote',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { ...base, error: 'Could not reach the update server (GitHub API + git ls-remote both failed).' };
|
||||
@@ -432,7 +718,11 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
|
||||
export type StartUpdateResult =
|
||||
| { ok: true; updateId: string; toTag: string; toVersion: string | null }
|
||||
| { ok: false; code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'error'; message: string };
|
||||
| {
|
||||
ok: false;
|
||||
code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'env-blocked' | 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy the updater script OUT of the repo before running it. The script lives in
|
||||
@@ -497,11 +787,13 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
if (!info.selfUpdateEnabled) {
|
||||
return { ok: false, code: 'disabled', message: 'Self-update is disabled (CODEMAN_DISABLE_SELF_UPDATE=1).' };
|
||||
}
|
||||
if (info.installKind !== 'git') {
|
||||
if (!canSelfUpdateInPlace(info.installKind)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'not-git',
|
||||
message: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
message: isRunningInContainer()
|
||||
? 'This container has no repository mounted. Update from the host with docker/Start-Codeman.sh.'
|
||||
: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
};
|
||||
}
|
||||
const existing = readUpdateStatus();
|
||||
@@ -517,6 +809,20 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
return { ok: false, code: 'bad-tag', message: `Refusing to update to an unrecognized tag: ${check.latestTag}` };
|
||||
}
|
||||
|
||||
// Re-evaluate rather than trusting the check the browser saw: the UI hides the
|
||||
// button when the gate blocks, but the endpoint is reachable directly and the
|
||||
// release could have moved between the check and the click.
|
||||
if (info.installKind === 'docker-compose') {
|
||||
const gate = evaluateEnvironmentGate(info.installDir, check.latestTag);
|
||||
if (gate.blockers.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'env-blocked',
|
||||
message: `${gate.blockers.map((b) => b.message).join(' ')} Run ${gate.hostCommand} on the Docker host to apply this release.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const prevSha = tryExec('git', ['rev-parse', 'HEAD'], info.installDir);
|
||||
const runner = stageRunner(info.installDir);
|
||||
if (!runner) {
|
||||
@@ -558,11 +864,18 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
process.execPath,
|
||||
'--log',
|
||||
logFile,
|
||||
// For the launchd-daemon restart path: the updater kills this PID and the
|
||||
// KeepAlive daemon respawns the server on the freshly built dist/.
|
||||
// For the launchd-daemon and docker-compose restart paths: the updater kills
|
||||
// this PID and the supervisor (KeepAlive daemon / Docker restart policy)
|
||||
// respawns the server on the freshly built dist/.
|
||||
'--server-pid',
|
||||
String(process.pid),
|
||||
];
|
||||
if (info.supervisor === 'docker-compose') {
|
||||
// Decided HERE, where the Docker socket and the Compose env are reachable;
|
||||
// the updater only reads the answer. Without a yes it never exits the server.
|
||||
const byExit = shouldRestartByExit(restartByExitDeclared(), detectOwnRestartPolicy());
|
||||
args.push('--restart-by-exit', byExit ? '1' : '0');
|
||||
}
|
||||
if (prevSha) args.push('--prev-sha', prevSha);
|
||||
if (info.dirty) args.push('--stash');
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @fileoverview Egress policy for the web-tab proxy: which upstream ADDRESSES
|
||||
* a saved dashboard URL may never resolve to.
|
||||
*
|
||||
* Pure (no IO), so the same predicate serves three call sites that see the target
|
||||
* at different stages: the Zod schema (a URL being saved), the sync check on a
|
||||
* hostname that is already an IP literal (Node's `net.connect` skips DNS for
|
||||
* those, so a lookup hook never sees them), and the DNS lookup hook that judges
|
||||
* the RESOLVED addresses of a name (`webview-egress.ts`), which is what closes
|
||||
* the rebinding hole a hostname-string check alone leaves open.
|
||||
*
|
||||
* What is blocked, and only this: link-local ranges and the fixed cloud-metadata
|
||||
* addresses that live there or beside them. Loopback and RFC1918 are deliberately
|
||||
* ALLOWED: a `localhost` Grafana or a LAN Home Assistant is the documented use
|
||||
* case for web tabs (`docs/web-tabs.md`), and the proxy's reach into the server's
|
||||
* own network is a documented property, not a bug. Nothing a person would
|
||||
* embed as a dashboard lives at 169.254.169.254, while an IAM credential does.
|
||||
*/
|
||||
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
/**
|
||||
* Hostnames that are metadata-service aliases on the clouds that define them.
|
||||
* Belt and braces: each also RESOLVES to a blocked address, which the lookup hook
|
||||
* catches, but naming them here gives the user a clear refusal at save time
|
||||
* instead of a DNS-shaped failure at open time.
|
||||
*/
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
'metadata.google.internal', // GCP
|
||||
'metadata', // GCP short alias (resolves on every GCE VM)
|
||||
'instance-data', // AWS legacy IMDS alias
|
||||
]);
|
||||
|
||||
/** Fixed single-address metadata endpoints outside the link-local range. */
|
||||
const BLOCKED_IPV4_HOSTS = new Set([
|
||||
'168.63.129.16', // Azure WireServer (IMDS helper, DHCP/heartbeat endpoint)
|
||||
'100.100.100.200', // Alibaba Cloud metadata
|
||||
]);
|
||||
|
||||
function parseIpv4(host: string): [number, number, number, number] | null {
|
||||
const parts = host.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
const nums = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : NaN));
|
||||
if (nums.some((n) => Number.isNaN(n) || n > 255)) return null;
|
||||
return nums as [number, number, number, number];
|
||||
}
|
||||
|
||||
function isBlockedIpv4(host: string): boolean {
|
||||
const octets = parseIpv4(host);
|
||||
if (!octets) return false;
|
||||
const [a, b] = octets;
|
||||
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local, incl. 169.254.169.254 (AWS/Azure/GCP/OpenStack/Oracle/DO)
|
||||
return BLOCKED_IPV4_HOSTS.has(octets.join('.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand an IPv6 literal into its eight 16-bit groups. Accepts the compressed
|
||||
* forms `URL.hostname` and DNS produce (`::1`, `::ffff:7f00:1`, `fd00:ec2::254`)
|
||||
* plus a dotted IPv4 tail (`::ffff:127.0.0.1`). Returns null for anything it
|
||||
* cannot parse, and the caller treats null as "not blocked" because every caller
|
||||
* gates on `isIP()` first, so null only ever means a zone id or a form Node itself
|
||||
* would refuse to connect to.
|
||||
*/
|
||||
function expandIpv6(raw: string): number[] | null {
|
||||
let text = raw.toLowerCase();
|
||||
const zone = text.indexOf('%');
|
||||
if (zone !== -1) text = text.slice(0, zone);
|
||||
|
||||
const lastColon = text.lastIndexOf(':');
|
||||
const tail = text.slice(lastColon + 1);
|
||||
if (tail.includes('.')) {
|
||||
const v4 = parseIpv4(tail);
|
||||
if (!v4) return null;
|
||||
const hi = ((v4[0] << 8) | v4[1]).toString(16);
|
||||
const lo = ((v4[2] << 8) | v4[3]).toString(16);
|
||||
text = `${text.slice(0, lastColon + 1)}${hi}:${lo}`;
|
||||
}
|
||||
|
||||
const halves = text.split('::');
|
||||
if (halves.length > 2) return null;
|
||||
const head = halves[0] === '' ? [] : halves[0].split(':');
|
||||
const rest = halves.length === 2 && halves[1] !== '' ? halves[1].split(':') : [];
|
||||
const missing = 8 - head.length - rest.length;
|
||||
if (halves.length === 2 ? missing < 1 : missing !== 0) return null;
|
||||
const groups = halves.length === 2 ? [...head, ...new Array<string>(missing).fill('0'), ...rest] : head;
|
||||
if (groups.length !== 8) return null;
|
||||
const out = groups.map((g) => (/^[0-9a-f]{1,4}$/.test(g) ? parseInt(g, 16) : NaN));
|
||||
return out.some((n) => Number.isNaN(n)) ? null : out;
|
||||
}
|
||||
|
||||
function isBlockedIpv6(host: string): boolean {
|
||||
const groups = expandIpv6(host);
|
||||
if (!groups) return false;
|
||||
// fe80::/10 link-local.
|
||||
if ((groups[0] & 0xffc0) === 0xfe80) return true;
|
||||
// fd00:ec2::254, the AWS IMDS IPv6 endpoint.
|
||||
if (
|
||||
groups[0] === 0xfd00 &&
|
||||
groups[1] === 0x0ec2 &&
|
||||
groups[2] === 0 &&
|
||||
groups[3] === 0 &&
|
||||
groups[4] === 0 &&
|
||||
groups[5] === 0 &&
|
||||
groups[6] === 0 &&
|
||||
groups[7] === 0x0254
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// IPv4-mapped (::ffff:a.b.c.d): judge the embedded IPv4.
|
||||
if (
|
||||
groups[0] === 0 &&
|
||||
groups[1] === 0 &&
|
||||
groups[2] === 0 &&
|
||||
groups[3] === 0 &&
|
||||
groups[4] === 0 &&
|
||||
groups[5] === 0xffff
|
||||
) {
|
||||
const v4 = `${groups[6] >> 8}.${groups[6] & 0xff}.${groups[7] >> 8}.${groups[7] & 0xff}`;
|
||||
return isBlockedIpv4(v4);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `address` (an IP literal, bracket-free) is one the proxy must never
|
||||
* connect to. Non-IP input is never blocked here: names are judged by
|
||||
* `isBlockedWebviewHostname()` at save time and by their resolved addresses at
|
||||
* connect time.
|
||||
*/
|
||||
export function isBlockedEgressAddress(address: string): boolean {
|
||||
const kind = isIP(address);
|
||||
if (kind === 4) return isBlockedIpv4(address);
|
||||
if (kind === 6) return isBlockedIpv6(address);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge a URL hostname as `URL.hostname` hands it over: IPv6 literals arrive in
|
||||
* brackets, names may carry a trailing dot, and case is irrelevant.
|
||||
*
|
||||
* @returns a short human-readable reason when blocked, null when allowed.
|
||||
*/
|
||||
export function blockedWebviewHostReason(hostname: string): string | null {
|
||||
const host = hostname
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.replace(/\.$/, '')
|
||||
.toLowerCase();
|
||||
if (isBlockedEgressAddress(host)) return `${host} is a link-local or cloud-metadata address`;
|
||||
if (BLOCKED_HOSTNAMES.has(host)) return `${host} is a cloud-metadata hostname`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Schema-friendly boolean form of `blockedWebviewHostReason()` over a raw URL string. */
|
||||
export function isBlockedWebviewUrl(raw: string): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw.trim());
|
||||
} catch {
|
||||
return false; // not this predicate's job; the URL shape check rejects it
|
||||
}
|
||||
return blockedWebviewHostReason(url.hostname) !== null;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @fileoverview Guarded egress for the web-tab proxy: the IO half of the policy in
|
||||
* `webview-egress-policy.ts`.
|
||||
*
|
||||
* Three outbound paths exist for a saved dashboard URL (the "Test" probe, the
|
||||
* HTTP proxy, the WebSocket relay), and all three must judge the RESOLVED address
|
||||
* rather than the hostname string, or a name pointing at 169.254.169.254 (an
|
||||
* attacker's own DNS, or `metadata.google.internal` on GCP) walks straight past
|
||||
* a literal-only check. So:
|
||||
*
|
||||
* - `createEgressLookup()` is a `net.connect`-shaped `lookup` that resolves with
|
||||
* `all: true` and refuses when ANY returned address is blocked (Happy Eyeballs
|
||||
* may otherwise pick the one we did not inspect).
|
||||
* - `webviewFetch()` runs undici's own `fetch` through an `Agent` whose connector
|
||||
* uses that lookup. undici's fetch rather than Node's global one, and undici's
|
||||
* Agent rather than a dispatcher handed to the global fetch, so the two are
|
||||
* always the same undici version: Node bundles its own copy, and a mismatched
|
||||
* dispatch protocol between the two fails in ways no test here would catch.
|
||||
* - The WebSocket relay passes the same lookup to `ws`, which forwards it to
|
||||
* `http.request`.
|
||||
*
|
||||
* ⚠️ A lookup hook never sees an IP LITERAL: Node's `net.connect` skips DNS for
|
||||
* those. Every caller therefore runs `blockedWebviewHostReason()` on the URL's
|
||||
* hostname synchronously BEFORE connecting, and `webviewFetch()` does it for its
|
||||
* own callers. Neither half is redundant.
|
||||
*/
|
||||
|
||||
import { promises as dns, type LookupAddress, type LookupOptions } from 'node:dns';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
import { Agent, fetch as undiciFetch, type RequestInit, type Response } from 'undici';
|
||||
import { blockedWebviewHostReason, isBlockedEgressAddress } from './webview-egress-policy.js';
|
||||
|
||||
export const EGRESS_BLOCKED_CODE = 'CODEMAN_EGRESS_BLOCKED';
|
||||
|
||||
/** Thrown (or delivered as the lookup error) when a target resolves into a blocked range. */
|
||||
export class WebviewEgressBlockedError extends Error {
|
||||
readonly code = EGRESS_BLOCKED_CODE;
|
||||
constructor(reason: string) {
|
||||
super(`Blocked: ${reason}; the web-tab proxy never relays to link-local or cloud-metadata addresses`);
|
||||
this.name = 'WebviewEgressBlockedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal message when `err`, or anything in its `cause` chain, is an egress
|
||||
* refusal; null otherwise. undici's fetch wraps a connect failure as
|
||||
* `TypeError('fetch failed', { cause })`, so the interesting error is one level
|
||||
* down, and callers want ITS message, not "fetch failed".
|
||||
*/
|
||||
export function egressBlockedReason(err: unknown): string | null {
|
||||
let current: unknown = err;
|
||||
for (let depth = 0; depth < 8 && current && typeof current === 'object'; depth++) {
|
||||
const candidate = current as { code?: unknown; message?: unknown; cause?: unknown };
|
||||
if (candidate.code === EGRESS_BLOCKED_CODE) {
|
||||
return typeof candidate.message === 'string' ? candidate.message : 'Blocked by egress policy';
|
||||
}
|
||||
current = candidate.cause;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Boolean form of `egressBlockedReason()`. */
|
||||
export function isEgressBlockedError(err: unknown): boolean {
|
||||
return egressBlockedReason(err) !== null;
|
||||
}
|
||||
|
||||
/** `net.connect`'s `lookup` signature, which undici's connector and `ws` both forward to it. */
|
||||
export type EgressLookup = LookupFunction;
|
||||
|
||||
/** Resolver seam for tests: what the lookup consults for a name's addresses. */
|
||||
export type ResolveAll = (hostname: string, options: LookupOptions) => Promise<LookupAddress[]>;
|
||||
|
||||
const defaultResolveAll: ResolveAll = (hostname, options) => {
|
||||
const family = typeof options.family === 'string' ? Number(options.family.replace(/^IPv/i, '')) : options.family;
|
||||
return dns.lookup(hostname, {
|
||||
...(family === 4 || family === 6 ? { family } : {}),
|
||||
...(options.hints !== undefined ? { hints: options.hints } : {}),
|
||||
all: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a `lookup` for `net.connect` / undici's connector / `ws` that refuses
|
||||
* blocked resolved addresses. Every address is inspected, not just the first:
|
||||
* with `autoSelectFamily` Node races the whole list.
|
||||
*/
|
||||
export function createEgressLookup(resolve: ResolveAll = defaultResolveAll): EgressLookup {
|
||||
return (hostname, options, callback) => {
|
||||
// Node's callback type carries a non-optional address; on error `net` reads
|
||||
// only `err`, so the placeholder values are never looked at.
|
||||
const fail = (err: NodeJS.ErrnoException) => callback(err, '', 0);
|
||||
resolve(hostname, options ?? {}).then(
|
||||
(addresses) => {
|
||||
const blocked = addresses.find((entry) => isBlockedEgressAddress(entry.address));
|
||||
if (blocked) {
|
||||
fail(new WebviewEgressBlockedError(`${hostname} resolves to ${blocked.address}`));
|
||||
return;
|
||||
}
|
||||
if (options?.all) {
|
||||
callback(null, addresses, 0);
|
||||
return;
|
||||
}
|
||||
const first = addresses[0];
|
||||
if (!first) {
|
||||
const notFound: NodeJS.ErrnoException = new Error(`getaddrinfo ENOTFOUND ${hostname}`);
|
||||
notFound.code = 'ENOTFOUND';
|
||||
fail(notFound);
|
||||
return;
|
||||
}
|
||||
callback(null, first.address, first.family);
|
||||
},
|
||||
(err: NodeJS.ErrnoException) => fail(err)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** Process-wide lookup for the WebSocket relay (and anything else `net`-shaped). */
|
||||
export const webviewEgressLookup: EgressLookup = createEgressLookup();
|
||||
|
||||
/**
|
||||
* An undici `Agent` whose connections resolve through `lookup`. Exported as a
|
||||
* factory so a test can inject a resolver and prove the hook is honoured
|
||||
* end-to-end; production uses the lazily-built singleton below.
|
||||
*/
|
||||
export function createWebviewDispatcher(lookup: EgressLookup = webviewEgressLookup): Agent {
|
||||
return new Agent({ connect: { lookup } });
|
||||
}
|
||||
|
||||
let dispatcher: Agent | undefined;
|
||||
function webviewDispatcher(): Agent {
|
||||
dispatcher ??= createWebviewDispatcher();
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* `fetch` for dashboard targets. Refuses a blocked IP literal synchronously (the
|
||||
* lookup hook never sees one) and routes everything else through the guarded
|
||||
* Agent, where a name resolving into a blocked range fails the connect with a
|
||||
* `WebviewEgressBlockedError` as the `cause` of undici's `fetch failed` TypeError.
|
||||
* Check either shape with `isEgressBlockedError()`.
|
||||
*/
|
||||
export function webviewFetch(target: URL, init: RequestInit = {}): Promise<Response> {
|
||||
const reason = blockedWebviewHostReason(target.hostname);
|
||||
if (reason) return Promise.reject(new WebviewEgressBlockedError(reason));
|
||||
return undiciFetch(target.href, { ...init, dispatcher: webviewDispatcher() });
|
||||
}
|
||||
@@ -93,6 +93,10 @@ const DROP_RESPONSE_HEADERS = new Set([
|
||||
'access-control-allow-headers',
|
||||
'access-control-expose-headers',
|
||||
'access-control-max-age',
|
||||
// The capability rides in every proxied URL, so the upstream's own referrer
|
||||
// policy must not decide whether third parties receive it. Ours is stamped in
|
||||
// buildDownstreamResponseHeaders.
|
||||
'referrer-policy',
|
||||
]);
|
||||
|
||||
/** The same-origin path prefix an iframe loads for a given capability. */
|
||||
@@ -352,6 +356,15 @@ export function buildDownstreamResponseHeaders(
|
||||
headers[lower] = value;
|
||||
}
|
||||
|
||||
// Every URL inside the frame carries the capability, and a dashboard that sets
|
||||
// `no-referrer-when-downgrade` or `unsafe-url` would hand it to any third-party
|
||||
// host it links or embeds. `same-origin` keeps the Referer on requests back to
|
||||
// Codeman (the 404 fallback and `refererPath` rely on it; both compare URL
|
||||
// origins, which an opaque-origin frame still satisfies) and strips it for
|
||||
// everyone else. A `<meta name="referrer">` inside the document can still
|
||||
// override this; that is the dashboard author's own decision about their page.
|
||||
headers['referrer-policy'] = 'same-origin';
|
||||
|
||||
const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext));
|
||||
|
||||
return { headers, setCookie, csp };
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
*
|
||||
* - 128 bits of `randomBytes` entropy, base64url, never derived from anything.
|
||||
* - Held in memory only. A restart invalidates every outstanding capability.
|
||||
* - Rolling TTL: refreshed on use, expired after inactivity.
|
||||
* - Rolling TTL: refreshed on use, expired after inactivity, and revoked outright
|
||||
* on logout, admin logout and user deletion (`revokeOwner`).
|
||||
* - Bound to the minting user, so multi-user ownership survives the exemption.
|
||||
* - Grants exactly one thing: relaying bytes to that one saved URL. It reaches no
|
||||
* session, no file, no API surface.
|
||||
@@ -81,15 +82,33 @@ export class WebviewCapabilityStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Revoke every capability minted by a user (called on logout / user deletion). */
|
||||
revokeOwner(owner: string): void {
|
||||
/**
|
||||
* Revoke every capability bound to an identity. Called from `POST /api/logout`
|
||||
* (the caller's own identity, which in single-user mode is `undefined`, i.e.
|
||||
* every capability there is), from the admin logout route, and from user
|
||||
* deletion.
|
||||
*
|
||||
* ⚠️ This method shipped for two releases with NO caller while its docstring
|
||||
* claimed logout invoked it. The rolling TTL is refreshed on every use, so a
|
||||
* proxy URL that leaked (browser history, a shared screenshot, a dashboard with
|
||||
* a loose referrer policy) stayed valid indefinitely as long as something kept
|
||||
* polling it. Logging out is the user's one deliberate "invalidate what I
|
||||
* opened" gesture, and it has to reach here; `test/webview-capability-revocation.test.ts`
|
||||
* pins each call site.
|
||||
*
|
||||
* @returns how many capabilities were revoked (for the admin audit line).
|
||||
*/
|
||||
revokeOwner(owner: string | undefined): number {
|
||||
let revoked = 0;
|
||||
for (const [webviewId, token] of [...this.byWebview]) {
|
||||
const record = this.capabilities.peek(token);
|
||||
if (record?.owner === owner) {
|
||||
this.capabilities.delete(token);
|
||||
this.byWebview.delete(webviewId);
|
||||
revoked++;
|
||||
}
|
||||
}
|
||||
return revoked;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
|
||||
@@ -43,10 +43,10 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// LINKED_CASES_FILE is dataPath('linked-cases.json') → CODEMAN_DATA_DIR,
|
||||
// which test/setup.ts points at a throwaway /tmp dir, so a plain delete is
|
||||
// safe here. Only homedir()-derived paths (CASES_DIR/LINKED_ROOT) need the
|
||||
// containment gate.
|
||||
// LINKED_CASES_FILE is dataPath('linked-cases.json'), which test/setup.ts
|
||||
// sandboxes (temp HOME, and an inherited CODEMAN_DATA_DIR is stripped), so a
|
||||
// plain delete is safe here. The case trees still go through the containment
|
||||
// gate as defense in depth.
|
||||
rmSync(LINKED_CASES_FILE, { force: true });
|
||||
safeRmHomeTree(CASES_DIR);
|
||||
safeRmHomeTree(LINKED_ROOT);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @fileoverview Static parity check between docker/docker-compose.yaml and
|
||||
* docker/.env.example.
|
||||
*
|
||||
* This is the MERGE GATE for the container environment. A feature that needs a
|
||||
* new setting must add it to BOTH files; forgetting one is what produces the
|
||||
* failure the in-app updater cannot defend against, because Compose resolves an
|
||||
* unset `${VAR}` to the EMPTY STRING and starts anyway — the container comes up
|
||||
* with a silently blank setting and misbehaves later, far from the cause.
|
||||
*
|
||||
* Failing here costs a line in a PR. Failing in production costs a debugging
|
||||
* session on someone else's server. Related: docs/docker-self-update.md.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { parseEnvKeys } from '../src/web/self-update.js';
|
||||
|
||||
const DOCKER_DIR = join(process.cwd(), 'docker');
|
||||
const compose = readFileSync(join(DOCKER_DIR, 'docker-compose.yaml'), 'utf-8');
|
||||
const example = readFileSync(join(DOCKER_DIR, '.env.example'), 'utf-8');
|
||||
|
||||
/**
|
||||
* Every `${VAR}` / `${VAR:-default}` the compose file interpolates. Compose's
|
||||
* own built-ins are excluded — they are supplied by Compose, not by .env.
|
||||
*/
|
||||
function composeVariables(text: string): string[] {
|
||||
const found = new Set<string>();
|
||||
for (const m of text.matchAll(/\$\{([A-Z_][A-Z0-9_]*)(?::?-[^}]*)?\}/g)) found.add(m[1]);
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
/** Keys .env.example mentions at all, including the commented-out optional ones. */
|
||||
function documentedKeys(text: string): Set<string> {
|
||||
const keys = new Set(parseEnvKeys(text));
|
||||
for (const m of text.matchAll(/^#\s*([A-Z_][A-Z0-9_]*)=/gm)) keys.add(m[1]);
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables Compose or the start script provides, which therefore need no entry
|
||||
* in .env.example. Keep this list SHORT and justified — every addition is a
|
||||
* setting the parity check stops guarding.
|
||||
*/
|
||||
const PROVIDED_ELSEWHERE = new Set([
|
||||
// Derived by docker/Start-Codeman.sh from the appdata dir and socket owner.
|
||||
'PUID',
|
||||
'PGID',
|
||||
'DOCKER_SOCKET_GID',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Keys .env.example sets for an OVERRIDE documented in docker/README.md (the
|
||||
* macvlan networking example), which the base compose file deliberately does not
|
||||
* read. They are settings for a file that is not this one, not dead entries.
|
||||
*/
|
||||
const EXAMPLE_ONLY_KEYS = new Set([
|
||||
'CODEMAN_MACVLAN_NETWORK',
|
||||
'CODEMAN_IPV4_ADDRESS',
|
||||
'CODEMAN_MAC_ADDRESS',
|
||||
'CODEMAN_MACVLAN_PARENT',
|
||||
'CODEMAN_MACVLAN_SUBNET',
|
||||
'CODEMAN_MACVLAN_GATEWAY',
|
||||
]);
|
||||
|
||||
describe('docker compose ↔ .env.example parity', () => {
|
||||
it('every variable the compose file reads is documented in .env.example', () => {
|
||||
const documented = documentedKeys(example);
|
||||
const undocumented = composeVariables(compose).filter((v) => !documented.has(v) && !PROVIDED_ELSEWHERE.has(v));
|
||||
expect(undocumented, `add these to docker/.env.example: ${undocumented.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('every key .env.example SETS is actually read by the compose file', () => {
|
||||
// Commented-out entries are exempt: they document optional overrides and
|
||||
// example-only values (the macvlan block) that the base file never reads.
|
||||
const used = new Set(composeVariables(compose));
|
||||
const unused = parseEnvKeys(example).filter((k) => !used.has(k) && !EXAMPLE_ONLY_KEYS.has(k));
|
||||
expect(unused, `these are set in .env.example but unused: ${unused.join(', ')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the Docker Compose self-update path.
|
||||
*
|
||||
* Covers the PURE half of the container environment gate: which release changes
|
||||
* can be applied by the container restarting itself, and which must go back to
|
||||
* the host. The IO half (`evaluateEnvironmentGate`) shells out to git and docker
|
||||
* and is exercised by hand — see docs/docker-self-update.md.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
canSelfUpdateInPlace,
|
||||
computeEnvironmentBlockers,
|
||||
diffRequiredEnvKeys,
|
||||
isAutoRestartPolicy,
|
||||
parseEnvKeys,
|
||||
shouldRestartByExit,
|
||||
type EnvironmentGateInput,
|
||||
} from '../src/web/self-update.js';
|
||||
|
||||
/** A gate input where nothing has changed — each test perturbs one field. */
|
||||
const CLEAN: EnvironmentGateInput = {
|
||||
appliedDockerfileHash: 'aaa',
|
||||
targetDockerfileHash: 'aaa',
|
||||
appliedComposeHash: 'bbb',
|
||||
targetComposeHash: 'bbb',
|
||||
missingEnvKeys: [],
|
||||
restartPolicy: 'unless-stopped',
|
||||
};
|
||||
|
||||
describe('canSelfUpdateInPlace', () => {
|
||||
it('accepts git and docker-compose, rejects npm and unknown', () => {
|
||||
expect(canSelfUpdateInPlace('git')).toBe(true);
|
||||
expect(canSelfUpdateInPlace('docker-compose')).toBe(true);
|
||||
expect(canSelfUpdateInPlace('npm')).toBe(false);
|
||||
// A container with no repo mounted: a pull would land in the writable layer.
|
||||
expect(canSelfUpdateInPlace('unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEnvKeys', () => {
|
||||
it('reads set keys and ignores blanks, comments and values', () => {
|
||||
expect(parseEnvKeys('A=1\n\nB=two words\n')).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('does NOT treat a commented-out key as set', () => {
|
||||
// .env.example documents optional overrides as `# PUID=1000`. Counting those
|
||||
// as required would block every update on settings the user should not set.
|
||||
expect(parseEnvKeys('# PUID=1000\nCODEMAN_PORT=3000')).toEqual(['CODEMAN_PORT']);
|
||||
});
|
||||
|
||||
it('handles `export` prefixes and repeated keys', () => {
|
||||
expect(parseEnvKeys('export A=1\nA=2\n')).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('ignores lines that are not assignments', () => {
|
||||
expect(parseEnvKeys('just a line\n=novalue\n1BAD=x\nOK=y')).toEqual(['OK']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffRequiredEnvKeys', () => {
|
||||
it('reports keys the release added that the user has no value for', () => {
|
||||
expect(diffRequiredEnvKeys('A=\nB=\nC=', 'A=1\nC=3')).toEqual(['B']);
|
||||
});
|
||||
|
||||
it('ignores keys the user set that the release dropped', () => {
|
||||
expect(diffRequiredEnvKeys('A=', 'A=1\nOBSOLETE=2')).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts a key the user set to an EMPTY value as present', () => {
|
||||
// `GEMINI_API_KEY=` is a deliberate opt-out, not a missing setting.
|
||||
expect(diffRequiredEnvKeys('GEMINI_API_KEY=', 'GEMINI_API_KEY=')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAutoRestartPolicy', () => {
|
||||
it('accepts the policies that relaunch the container after the server exits', () => {
|
||||
expect(isAutoRestartPolicy('unless-stopped')).toBe(true);
|
||||
expect(isAutoRestartPolicy('always')).toBe(true);
|
||||
expect(isAutoRestartPolicy('on-failure')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects "no" and unknown values', () => {
|
||||
expect(isAutoRestartPolicy('no')).toBe(false);
|
||||
expect(isAutoRestartPolicy('')).toBe(false);
|
||||
expect(isAutoRestartPolicy(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRestartByExit', () => {
|
||||
it('exits when the Compose file declared it, whatever the daemon says', () => {
|
||||
expect(shouldRestartByExit(true, null)).toBe(true);
|
||||
expect(shouldRestartByExit(true, 'unless-stopped')).toBe(true);
|
||||
});
|
||||
|
||||
it('exits when the daemon confirms an auto-restart policy', () => {
|
||||
expect(shouldRestartByExit(false, 'unless-stopped')).toBe(true);
|
||||
expect(shouldRestartByExit(false, 'always')).toBe(true);
|
||||
});
|
||||
|
||||
// ⚠️ The gate fails open on an unknown policy; the KILL must not. A container
|
||||
// nothing restarts would otherwise go down with no UI left to recover it.
|
||||
it('does NOT exit on an unknown or non-restarting policy without the declaration', () => {
|
||||
expect(shouldRestartByExit(false, null)).toBe(false);
|
||||
expect(shouldRestartByExit(false, 'no')).toBe(false);
|
||||
expect(shouldRestartByExit(false, '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeEnvironmentBlockers', () => {
|
||||
it('allows a code-only release', () => {
|
||||
expect(computeEnvironmentBlockers(CLEAN)).toEqual([]);
|
||||
});
|
||||
|
||||
it('blocks a release that changes the Dockerfile', () => {
|
||||
const blockers = computeEnvironmentBlockers({ ...CLEAN, targetDockerfileHash: 'zzz' });
|
||||
expect(blockers.map((b) => b.kind)).toEqual(['dockerfile-changed']);
|
||||
});
|
||||
|
||||
it('blocks a release that changes the compose file', () => {
|
||||
const blockers = computeEnvironmentBlockers({ ...CLEAN, targetComposeHash: 'zzz' });
|
||||
expect(blockers.map((b) => b.kind)).toEqual(['compose-changed']);
|
||||
});
|
||||
|
||||
it('blocks and NAMES missing env keys', () => {
|
||||
const blockers = computeEnvironmentBlockers({ ...CLEAN, missingEnvKeys: ['CODEMAN_NEW_THING'] });
|
||||
expect(blockers[0].kind).toBe('env-keys-missing');
|
||||
expect(blockers[0].details).toEqual(['CODEMAN_NEW_THING']);
|
||||
});
|
||||
|
||||
it('blocks when the container would not come back', () => {
|
||||
const blockers = computeEnvironmentBlockers({ ...CLEAN, restartPolicy: 'no' });
|
||||
expect(blockers.map((b) => b.kind)).toEqual(['no-auto-restart']);
|
||||
// The message says which policy, so the fix is obvious from the UI alone.
|
||||
expect(blockers[0].message).toContain('"no"');
|
||||
});
|
||||
|
||||
it('reports every blocker at once rather than stopping at the first', () => {
|
||||
const blockers = computeEnvironmentBlockers({
|
||||
...CLEAN,
|
||||
targetDockerfileHash: 'zzz',
|
||||
targetComposeHash: 'yyy',
|
||||
missingEnvKeys: ['A'],
|
||||
restartPolicy: 'no',
|
||||
});
|
||||
expect(blockers.map((b) => b.kind)).toEqual([
|
||||
'dockerfile-changed',
|
||||
'compose-changed',
|
||||
'env-keys-missing',
|
||||
'no-auto-restart',
|
||||
]);
|
||||
});
|
||||
|
||||
// ⚠️ Regression guards for the fail-OPEN decisions. An unknown baseline is not
|
||||
// evidence of a change, and failing closed there would permanently block every
|
||||
// container created before the fingerprint file existed.
|
||||
it('does not block when the applied baseline is unknown', () => {
|
||||
expect(computeEnvironmentBlockers({ ...CLEAN, appliedDockerfileHash: null, appliedComposeHash: null })).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not block when the target files cannot be read', () => {
|
||||
expect(computeEnvironmentBlockers({ ...CLEAN, targetDockerfileHash: null, targetComposeHash: null })).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not block when the restart policy is unknown', () => {
|
||||
// The probe needs the Docker socket, which a user may not have mounted.
|
||||
expect(computeEnvironmentBlockers({ ...CLEAN, restartPolicy: null })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -169,8 +169,9 @@ describe('POST /api/sessions workspace hooks', () => {
|
||||
// as a junk directory under the server cwd. statusLineTelemetry rides along:
|
||||
// applyStatusLineConfig mkdirs the same way and used to run for remote attaches.
|
||||
// SAFETY (2026-08-29): write straight to `getDataDir()` — `test/setup.ts`
|
||||
// already sandboxes CODEMAN_DATA_DIR for the whole file (same convention as
|
||||
// the docker-hosts fixtures below). A prior version of this test stubbed
|
||||
// already sandboxes the data dir for the whole file (temp HOME, inherited
|
||||
// CODEMAN_DATA_DIR stripped; same convention as the docker-hosts fixtures
|
||||
// below). A prior version of this test stubbed
|
||||
// CODEMAN_DATA_DIR to a SEPARATE throwaway dir for just this write, but
|
||||
// `session-routes.ts`'s `CODEMAN_CONFIG_DIR` is a module-load-time constant
|
||||
// (frozen at the sandboxed dir before this test ever runs), so that fixture
|
||||
|
||||
@@ -16,6 +16,7 @@ import { registerWebviewRoutes } from '../../src/web/routes/webview-routes.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { webviewCapabilities } from '../../src/webview-capabilities.js';
|
||||
import { capabilityFromProxyPath } from '../../src/web/webview-proxy.js';
|
||||
import { writeWebviews } from '../../src/webview-store.js';
|
||||
import { TabLayoutService } from '../../src/tab-layout-service.js';
|
||||
import type { TabLayout } from '../../src/tab-layout.js';
|
||||
|
||||
@@ -286,3 +287,59 @@ describe('POST /api/webviews/probe', () => {
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('egress policy: link-local and cloud-metadata targets', () => {
|
||||
it('refuses to SAVE a metadata address, in every spelling, with a message that says why', async () => {
|
||||
for (const url of [
|
||||
'http://169.254.169.254/latest/meta-data/',
|
||||
'http://2852039166/', // decimal form of 169.254.169.254
|
||||
'http://[fd00:ec2::254]/',
|
||||
'http://metadata.google.internal/computeMetadata/v1/',
|
||||
]) {
|
||||
const res = await create({ name: 'IMDS', url });
|
||||
expect(res.statusCode, url).toBe(400);
|
||||
expect(res.body, url).toMatch(/Blocked URL/);
|
||||
}
|
||||
});
|
||||
|
||||
it('still saves the loopback dashboards the feature exists for', async () => {
|
||||
expect((await create({ name: 'Grafana', url: 'http://127.0.0.1:4000/' })).statusCode).toBe(200);
|
||||
expect((await create({ name: 'Local', url: 'http://localhost:3080/' })).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('the probe refuses the same targets up front, before any connection is attempted', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webviews/probe',
|
||||
payload: { url: 'http://169.254.169.254/' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toMatch(/Blocked URL/);
|
||||
});
|
||||
|
||||
it('the proxy refuses a record saved before the rule existed with a 403, never a relay', async () => {
|
||||
// Written straight to the store: the schema would refuse it today, which is
|
||||
// exactly why the proxy must judge the target again at connect time.
|
||||
await writeWebviews(tmpDir, [
|
||||
{
|
||||
id: 'legacy-imds',
|
||||
name: 'legacy',
|
||||
url: 'http://169.254.169.254/',
|
||||
embedMode: 'proxy',
|
||||
trusted: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
const cap = webviewCapabilities.mint('legacy-imds', undefined);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${cap}/latest/meta-data/` });
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toMatch(/link-local or cloud-metadata/);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('refused by egress policy'));
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
webviewCapabilities.revokeWebview('legacy-imds');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+38
-20
@@ -5,8 +5,11 @@
|
||||
* mode before application modules load. Tests therefore cannot touch the real
|
||||
* Codeman state/cases tree or launch external tmux-backed agent sessions.
|
||||
*
|
||||
* This setup file strips shell-level auth configuration that can leak from a
|
||||
* running Codeman instance, then handles mock/timer cleanup between tests.
|
||||
* This setup file strips shell-level configuration that can leak from a running
|
||||
* Codeman instance — auth (`CODEMAN_PASSWORD`/`CODEMAN_USERNAME`), the gesture
|
||||
* flag, and the three INSTANCE-selection vars that would otherwise point the
|
||||
* suite at a real data dir or tmux socket — then handles mock/timer cleanup
|
||||
* between tests.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
@@ -18,9 +21,7 @@ const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
const originalVitest = process.env.VITEST;
|
||||
const originalPlaywrightBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
const originalCodemanDataDir = process.env.CODEMAN_DATA_DIR;
|
||||
const testHome = mkdtempSync(join(tmpdir(), 'codeman-vitest-'));
|
||||
const testDataDir = join(tmpdir(), `codeman-vitest-data-${process.pid}`);
|
||||
|
||||
if (originalPlaywrightBrowsersPath === undefined && originalHome) {
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH =
|
||||
@@ -34,17 +35,6 @@ process.env.HOME = testHome;
|
||||
process.env.USERPROFILE = testHome;
|
||||
process.env.VITEST = 'true';
|
||||
|
||||
// SAFETY: `getDataDir()` is `process.env.CODEMAN_DATA_DIR || join(homedir(), '.codeman<suffix>')`.
|
||||
// The temp HOME above already redirects the second half (`os.homedir()` follows
|
||||
// `$HOME`; libuv checks the env var before the passwd entry), but the first half
|
||||
// is an ABSOLUTE override: a `CODEMAN_DATA_DIR` inherited from the shell (a
|
||||
// second instance, a beta run) bypasses the temp HOME entirely, and a bare suite
|
||||
// run then reads and writes the REAL data dir (found 2026-08-29:
|
||||
// `session-routes-workspace-hooks.test.ts` overwrote the production
|
||||
// `remote-hosts.json` with an `h1/box/10.0.0.5` fixture, wiping every user-defined
|
||||
// remote host and emptying the launch case dropdown). Point it at a throwaway dir.
|
||||
process.env.CODEMAN_DATA_DIR = testDataDir;
|
||||
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
// Gesture availability changes renderIndexHtml output (injects the
|
||||
@@ -52,6 +42,39 @@ delete process.env.CODEMAN_USERNAME;
|
||||
// (test/server-index-title.test.ts) when the shell exports CODEMAN_GESTURE=1.
|
||||
delete process.env.CODEMAN_GESTURE;
|
||||
|
||||
// Instance selection is PROCESS-WIDE and is what `src/config/instance.ts` derives
|
||||
// both the data dir and the tmux socket from, so a shell that exports any of these
|
||||
// three reaches straight past the temp HOME above and undoes the isolation this
|
||||
// file exists to provide:
|
||||
//
|
||||
// - CODEMAN_DATA_DIR is the dangerous one. It is an ABSOLUTE override read in
|
||||
// `getDataDir()`, so it bypasses HOME entirely: a developer who exports it
|
||||
// (or a shell left over from `codeman web -d`) has the suite reading and
|
||||
// WRITING their real `state.json`, `users.json`, `intents.json` and
|
||||
// `hook-secret` instead of a throwaway tree. Found live 2026-08-29 (#356):
|
||||
// `session-routes-workspace-hooks.test.ts` overwrote a production
|
||||
// `remote-hosts.json` with its `h1/box/10.0.0.5` fixture. `os.homedir()`
|
||||
// itself DOES follow `$HOME`, so with this var gone `getDataDir()` lands
|
||||
// under the temp HOME like everything else. (#356 first answered this by
|
||||
// pointing the var at a second throwaway dir; deleting it is the same
|
||||
// protection with one tree to clean up.)
|
||||
// - CODEMAN_INSTANCE moves the data dir to `~/.codeman-<name>` and the socket to
|
||||
// `codeman-<name>`. Inside the temp HOME that is not a data-loss risk, but it
|
||||
// silently changes the paths tests assert on — and `scripts/run-beta.sh`
|
||||
// exports it, so any shell that has run a beta carries it.
|
||||
// - CODEMAN_TMUX_SOCKET renames the socket `resolveTmuxSocketName()` returns.
|
||||
// `TmuxManager` no-ops its shell commands under vitest, so this is assertion
|
||||
// drift rather than a stray `tmux -L` against prod — but it is the same class
|
||||
// of leak and the same one-line fix.
|
||||
//
|
||||
// ⚠️ These must be deleted HERE rather than in a test, because `CODEMAN_INSTANCE`
|
||||
// is captured into a module-level const the first time `config/instance.ts` is
|
||||
// imported. A setup file runs before any application module loads; a beforeEach
|
||||
// would already be too late.
|
||||
delete process.env.CODEMAN_INSTANCE;
|
||||
delete process.env.CODEMAN_DATA_DIR;
|
||||
delete process.env.CODEMAN_TMUX_SOCKET;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
@@ -79,11 +102,7 @@ afterAll(async () => {
|
||||
if (originalPlaywrightBrowsersPath === undefined) delete process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
else process.env.PLAYWRIGHT_BROWSERS_PATH = originalPlaywrightBrowsersPath;
|
||||
|
||||
if (originalCodemanDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
|
||||
else process.env.CODEMAN_DATA_DIR = originalCodemanDataDir;
|
||||
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
rmSync(testDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// afterAll never fires for a fully-skipped test file (no tests execute), which
|
||||
@@ -91,5 +110,4 @@ afterAll(async () => {
|
||||
// with force is a no-op when afterAll already removed it.
|
||||
process.on('exit', () => {
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
rmSync(testDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @fileoverview Pins the environment isolation `test/setup.ts` provides.
|
||||
*
|
||||
* The suite's hermeticity rests on a temp `HOME` plus a short list of env vars that are
|
||||
* deleted before any application module loads. That list is easy to under-maintain: it grew
|
||||
* once for auth (`CODEMAN_PASSWORD`/`CODEMAN_USERNAME`) and once for `CODEMAN_GESTURE`, both
|
||||
* times only after a leak had already produced a confusing failure, and it was still missing
|
||||
* the three INSTANCE-selection vars.
|
||||
*
|
||||
* Those three matter more than the ones already on the list, because `src/config/instance.ts`
|
||||
* derives BOTH the data dir and the tmux socket from them, and `CODEMAN_DATA_DIR` is an
|
||||
* absolute path that bypasses `HOME` entirely — so a developer who exports it has the suite
|
||||
* reading and writing their real `state.json` rather than a throwaway tree.
|
||||
*
|
||||
* ⚠️ The runtime half of this file cannot fail on a machine where the vars were never set, so
|
||||
* it is not enough on its own: a `delete` line removed from `setup.ts` would still pass here
|
||||
* on almost every developer's box and on CI. The STATIC half is what actually guards the
|
||||
* list — it reads `setup.ts` and asserts each name is deleted there, which fails wherever the
|
||||
* suite runs. Both halves are deliberate; do not drop the static one as redundant.
|
||||
*
|
||||
* Port: none (pure, over process.env and one source file).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/** Every env var `setup.ts` must strip, with why it would otherwise leak. */
|
||||
const STRIPPED_ENV_VARS: Array<[name: string, why: string]> = [
|
||||
['CODEMAN_PASSWORD', 'auth from a running instance would make protected routes behave differently'],
|
||||
['CODEMAN_USERNAME', 'same, and it changes which owner scoping resolves to'],
|
||||
['CODEMAN_GESTURE', 'flips renderIndexHtml output and breaks byte-identity assertions'],
|
||||
['CODEMAN_INSTANCE', 'moves the data dir to ~/.codeman-<name> and the tmux socket to codeman-<name>'],
|
||||
['CODEMAN_DATA_DIR', 'ABSOLUTE override: bypasses the temp HOME and points the suite at a real data dir'],
|
||||
['CODEMAN_TMUX_SOCKET', 'renames the socket resolveTmuxSocketName() returns'],
|
||||
];
|
||||
|
||||
const SETUP_SOURCE = readFileSync(fileURLToPath(new URL('./setup.ts', import.meta.url)), 'utf-8');
|
||||
|
||||
/**
|
||||
* Just the top-of-file STRIP section, cut at the first hook.
|
||||
*
|
||||
* The teardown below it restores HOME/USERPROFILE/VITEST/PLAYWRIGHT_BROWSERS_PATH with the
|
||||
* same `delete` syntax, and those are the opposite of a strip — counting them would make the
|
||||
* anti-drift check demand a reason for a var the suite deliberately puts back.
|
||||
*/
|
||||
const SETUP_STRIP_SECTION = SETUP_SOURCE.split(/^afterEach\(/m)[0];
|
||||
|
||||
describe('test environment isolation', () => {
|
||||
it.each(STRIPPED_ENV_VARS)('%s is unset while the suite runs', (name) => {
|
||||
expect(process.env[name], `${name} leaked into the test environment`).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(STRIPPED_ENV_VARS)('setup.ts deletes %s (%s)', (name) => {
|
||||
// The half that fails everywhere, not just on a machine that happens to export the var.
|
||||
expect(SETUP_SOURCE, `setup.ts no longer deletes ${name}`).toContain(`delete process.env.${name};`);
|
||||
});
|
||||
|
||||
it('runs against a throwaway HOME, not the real one', () => {
|
||||
// The property every other test's isolation is built on: `~/.codeman` and `~/codeman-cases`
|
||||
// both resolve under here, so a test that writes state cannot reach the developer's own.
|
||||
const home = process.env.HOME ?? process.env.USERPROFILE;
|
||||
expect(home).toBeTruthy();
|
||||
expect(home).toContain('codeman-vitest-');
|
||||
});
|
||||
|
||||
it('lists every name the setup file strips (anti-drift)', () => {
|
||||
// Catches the other direction: a var added to setup.ts but never given a reason here, so
|
||||
// the next person cannot tell whether it is load-bearing or left over.
|
||||
const deleted = [...SETUP_STRIP_SECTION.matchAll(/delete process\.env\.([A-Z0-9_]+);/g)].map((m) => m[1]).sort();
|
||||
expect(deleted).toEqual(STRIPPED_ENV_VARS.map(([name]) => name).sort());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Web-tab proxy capabilities must die with the login that minted them.
|
||||
*
|
||||
* `WebviewCapabilityStore.revokeOwner()` shipped for two releases with a docstring
|
||||
* saying logout called it and NO caller. The capability is a bearer credential
|
||||
* exempt from cookie auth, with a rolling TTL refreshed on every use, so a leaked
|
||||
* proxy URL stayed valid indefinitely. These tests pin every call site:
|
||||
* `POST /api/logout` (own identity), the admin forced logout, and user deletion.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebviewCapabilityStore, webviewCapabilities } from '../src/webview-capabilities.js';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './routes/_route-test-utils.js';
|
||||
import { registerSessionRoutes } from '../src/web/routes/session-routes.js';
|
||||
import { registerAdminRoutes } from '../src/web/routes/admin-routes.js';
|
||||
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
||||
|
||||
const PASSWORD = 'correct-horse-battery-staple';
|
||||
|
||||
describe('WebviewCapabilityStore.revokeOwner', () => {
|
||||
it('revokes exactly the identity asked for, the single-user `undefined` identity included', () => {
|
||||
const store = new WebviewCapabilityStore();
|
||||
const solo = store.mint('wv-solo', undefined);
|
||||
const alice = store.mint('wv-alice', 'alice');
|
||||
const bob = store.mint('wv-bob', 'bob');
|
||||
|
||||
expect(store.revokeOwner('alice')).toBe(1);
|
||||
expect(store.resolve(alice)).toBeUndefined();
|
||||
expect(store.resolve(bob)).toBeDefined();
|
||||
expect(store.resolve(solo)).toBeDefined();
|
||||
|
||||
expect(store.revokeOwner(undefined)).toBe(1);
|
||||
expect(store.resolve(solo)).toBeUndefined();
|
||||
expect(store.resolve(bob)).toBeDefined();
|
||||
|
||||
// A later open mints a NEW token rather than resurrecting the revoked one.
|
||||
expect(store.mint('wv-alice', 'alice')).not.toBe(alice);
|
||||
expect(store.revokeOwner('nobody')).toBe(0);
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/logout', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createRouteTestHarness(registerSessionRoutes);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
it('single-user: every outstanding capability dies with the login', async () => {
|
||||
const cap = webviewCapabilities.mint('wv-logout-solo', undefined);
|
||||
expect(webviewCapabilities.resolve(cap)).toBeDefined();
|
||||
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/logout' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/logout in multi-user mode', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let savedMode: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
savedMode = process.env.CODEMAN_MULTIUSER;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
harness = await createRouteTestHarness(registerSessionRoutes, { authUser: { username: 'peon', role: 'user' } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.app.close();
|
||||
if (savedMode === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = savedMode;
|
||||
});
|
||||
|
||||
it("revokes only the caller's capabilities, never another user's", async () => {
|
||||
const mine = webviewCapabilities.mint('wv-peon-own', 'peon');
|
||||
const theirs = webviewCapabilities.mint('wv-boss-own', 'boss');
|
||||
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/logout' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(webviewCapabilities.resolve(mine)).toBeUndefined();
|
||||
expect(webviewCapabilities.resolve(theirs)).toBeDefined();
|
||||
webviewCapabilities.revokeWebview('wv-boss-own');
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin routes (multi-user)', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let savedMode: string | undefined;
|
||||
|
||||
// The temp HOME from test/setup.ts is per-FILE, so users.json persists across
|
||||
// the tests in this block.
|
||||
beforeAll(async () => {
|
||||
savedMode = process.env.CODEMAN_MULTIUSER;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
invalidateUsersCache();
|
||||
await createUser({ username: 'boss', role: 'admin', password: PASSWORD });
|
||||
await createUser({ username: 'peon', role: 'user', password: PASSWORD });
|
||||
harness = await createRouteTestHarness(registerAdminRoutes, { authUser: { username: 'boss', role: 'admin' } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.app.close();
|
||||
if (savedMode === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = savedMode;
|
||||
invalidateUsersCache();
|
||||
});
|
||||
|
||||
it('a forced logout revokes the target user (normalised) and leaves the admin alone', async () => {
|
||||
const peon = webviewCapabilities.mint('wv-peon-forced', 'peon');
|
||||
const boss = webviewCapabilities.mint('wv-boss-forced', 'boss');
|
||||
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/admin/users/PEON/logout' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(webviewCapabilities.resolve(peon)).toBeUndefined();
|
||||
expect(webviewCapabilities.resolve(boss)).toBeDefined();
|
||||
webviewCapabilities.revokeWebview('wv-boss-forced');
|
||||
});
|
||||
|
||||
it('deleting a user revokes whatever that user had open', async () => {
|
||||
const peon = webviewCapabilities.mint('wv-peon-deleted', 'peon');
|
||||
|
||||
const res = await harness.app.inject({ method: 'DELETE', url: '/api/admin/users/peon' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(webviewCapabilities.resolve(peon)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Egress policy for the web-tab proxy (src/web/webview-egress-policy.ts).
|
||||
*
|
||||
* The proxy reaches whatever the server can reach ON PURPOSE (a localhost
|
||||
* Grafana is the documented use case), so this policy blocks only the ranges no
|
||||
* dashboard lives in and a cloud credential does: link-local and the fixed
|
||||
* metadata endpoints. Both halves are pinned: what is refused, and what must
|
||||
* stay allowed so the feature keeps working.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
blockedWebviewHostReason,
|
||||
isBlockedEgressAddress,
|
||||
isBlockedWebviewUrl,
|
||||
} from '../src/web/webview-egress-policy.js';
|
||||
|
||||
describe('isBlockedEgressAddress', () => {
|
||||
it('blocks the IPv4 link-local range, which every major cloud puts IMDS in', () => {
|
||||
expect(isBlockedEgressAddress('169.254.169.254')).toBe(true);
|
||||
expect(isBlockedEgressAddress('169.254.0.23')).toBe(true); // Tencent metadata
|
||||
expect(isBlockedEgressAddress('169.254.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks the fixed metadata endpoints outside link-local', () => {
|
||||
expect(isBlockedEgressAddress('168.63.129.16')).toBe(true); // Azure WireServer
|
||||
expect(isBlockedEgressAddress('100.100.100.200')).toBe(true); // Alibaba Cloud
|
||||
});
|
||||
|
||||
it('blocks IPv6 link-local and the AWS IMDS IPv6 endpoint in every spelling', () => {
|
||||
expect(isBlockedEgressAddress('fe80::1')).toBe(true);
|
||||
expect(isBlockedEgressAddress('FE80::1%eth0')).toBe(true);
|
||||
expect(isBlockedEgressAddress('febf:ffff::1')).toBe(true);
|
||||
expect(isBlockedEgressAddress('fd00:ec2::254')).toBe(true);
|
||||
expect(isBlockedEgressAddress('fd00:0ec2:0000:0000:0000:0000:0000:0254')).toBe(true);
|
||||
});
|
||||
|
||||
it('judges the embedded IPv4 of a mapped address, dotted or hex', () => {
|
||||
expect(isBlockedEgressAddress('::ffff:169.254.169.254')).toBe(true);
|
||||
expect(isBlockedEgressAddress('::ffff:a9fe:a9fe')).toBe(true); // URL.hostname's form
|
||||
expect(isBlockedEgressAddress('::ffff:127.0.0.1')).toBe(false);
|
||||
expect(isBlockedEgressAddress('::ffff:7f00:1')).toBe(false);
|
||||
});
|
||||
|
||||
it('ALLOWS loopback and private ranges: localhost dashboards are the feature', () => {
|
||||
expect(isBlockedEgressAddress('127.0.0.1')).toBe(false);
|
||||
expect(isBlockedEgressAddress('::1')).toBe(false);
|
||||
expect(isBlockedEgressAddress('10.0.0.5')).toBe(false);
|
||||
expect(isBlockedEgressAddress('192.168.1.20')).toBe(false);
|
||||
expect(isBlockedEgressAddress('172.16.0.9')).toBe(false);
|
||||
expect(isBlockedEgressAddress('100.64.0.1')).toBe(false); // tailnet CGNAT range
|
||||
expect(isBlockedEgressAddress('fd7a:115c:a1e0::1')).toBe(false); // tailnet ULA
|
||||
expect(isBlockedEgressAddress('fd00:ec2::255')).toBe(false); // neighbour of the AWS address
|
||||
});
|
||||
|
||||
it('never blocks a name: names are judged by what they resolve to', () => {
|
||||
expect(isBlockedEgressAddress('metadata.google.internal')).toBe(false);
|
||||
expect(isBlockedEgressAddress('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blockedWebviewHostReason', () => {
|
||||
it('accepts URL.hostname forms: bracketed IPv6, trailing dot, mixed case', () => {
|
||||
expect(blockedWebviewHostReason('[fe80::1]')).toMatch(/link-local/);
|
||||
expect(blockedWebviewHostReason('[::ffff:a9fe:a9fe]')).toMatch(/link-local/);
|
||||
expect(blockedWebviewHostReason('METADATA.GOOGLE.INTERNAL.')).toMatch(/metadata hostname/);
|
||||
expect(blockedWebviewHostReason('[::1]')).toBeNull();
|
||||
});
|
||||
|
||||
it('names the cloud metadata aliases even though they would also fail resolution', () => {
|
||||
expect(blockedWebviewHostReason('metadata')).not.toBeNull();
|
||||
expect(blockedWebviewHostReason('instance-data')).not.toBeNull();
|
||||
expect(blockedWebviewHostReason('metadata.example.com')).toBeNull();
|
||||
expect(blockedWebviewHostReason('grafana.internal')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBlockedWebviewUrl (schema refine)', () => {
|
||||
it('sees through the URL normalisations an attacker would lean on', () => {
|
||||
// Decimal and hex hosts normalise to dotted quads inside `new URL`.
|
||||
expect(isBlockedWebviewUrl('http://2852039166/latest/meta-data/')).toBe(true); // 169.254.169.254
|
||||
expect(isBlockedWebviewUrl('http://0xa9fea9fe/')).toBe(true);
|
||||
expect(isBlockedWebviewUrl('http://169.254.169.254:80/')).toBe(true);
|
||||
expect(isBlockedWebviewUrl('http://[fd00:ec2::254]/')).toBe(true);
|
||||
expect(isBlockedWebviewUrl('http://metadata.google.internal/computeMetadata/v1/')).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves every documented dashboard shape alone', () => {
|
||||
expect(isBlockedWebviewUrl('http://127.0.0.1:4000/grafana/')).toBe(false);
|
||||
expect(isBlockedWebviewUrl('http://localhost:3080/')).toBe(false);
|
||||
expect(isBlockedWebviewUrl('https://homeassistant.tailf80371.ts.net/')).toBe(false);
|
||||
expect(isBlockedWebviewUrl('http://192.168.1.20:9000/')).toBe(false);
|
||||
});
|
||||
|
||||
it("is not the URL-shape check: garbage is someone else's refusal", () => {
|
||||
expect(isBlockedWebviewUrl('not a url')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Guarded egress for the web-tab proxy (src/web/webview-egress.ts).
|
||||
*
|
||||
* The policy is judged on RESOLVED addresses through a `lookup` hook, because a
|
||||
* hostname-string check cannot see where `metadata.google.internal`, or an
|
||||
* attacker's own DNS name, actually points. These tests inject a resolver and
|
||||
* drive a real undici Agent against a real local HTTP server, so what is pinned
|
||||
* is that undici honours the hook end-to-end, not that a helper returns a value.
|
||||
* Port: ephemeral (server.listen(0)).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { LookupAddress } from 'node:dns';
|
||||
import { fetch as undiciFetch } from 'undici';
|
||||
import {
|
||||
createEgressLookup,
|
||||
createWebviewDispatcher,
|
||||
egressBlockedReason,
|
||||
isEgressBlockedError,
|
||||
webviewFetch,
|
||||
WebviewEgressBlockedError,
|
||||
type EgressLookup,
|
||||
} from '../src/web/webview-egress.js';
|
||||
|
||||
type LookupCallbackArgs = Parameters<Parameters<EgressLookup>[2]>;
|
||||
|
||||
const NAMES: Record<string, LookupAddress[]> = {
|
||||
'dash.test': [{ address: '127.0.0.1', family: 4 }],
|
||||
'meta.test': [{ address: '169.254.169.254', family: 4 }],
|
||||
// Happy Eyeballs shape: one fine address and one blocked one.
|
||||
'mixed.test': [
|
||||
{ address: '127.0.0.1', family: 4 },
|
||||
{ address: 'fd00:ec2::254', family: 6 },
|
||||
],
|
||||
'nowhere.test': [],
|
||||
};
|
||||
|
||||
const fakeResolve = async (hostname: string): Promise<LookupAddress[]> => {
|
||||
const found = NAMES[hostname];
|
||||
if (!found) {
|
||||
const err: NodeJS.ErrnoException = new Error(`getaddrinfo ENOTFOUND ${hostname}`);
|
||||
err.code = 'ENOTFOUND';
|
||||
throw err;
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
function callLookup(hostname: string, options: { all?: boolean }): Promise<LookupCallbackArgs> {
|
||||
const lookup = createEgressLookup(fakeResolve);
|
||||
return new Promise((resolve) => lookup(hostname, options, (...args) => resolve(args)));
|
||||
}
|
||||
|
||||
describe('createEgressLookup', () => {
|
||||
it("answers in net.connect's single-address shape when `all` is not requested", async () => {
|
||||
const [err, address, family] = await callLookup('dash.test', {});
|
||||
expect(err).toBeNull();
|
||||
expect(address).toBe('127.0.0.1');
|
||||
expect(family).toBe(4);
|
||||
});
|
||||
|
||||
it('answers the array shape autoSelectFamily asks for', async () => {
|
||||
const [err, addresses] = await callLookup('dash.test', { all: true });
|
||||
expect(err).toBeNull();
|
||||
expect(addresses).toEqual([{ address: '127.0.0.1', family: 4 }]);
|
||||
});
|
||||
|
||||
it('refuses a name that resolves into a blocked range, naming both', async () => {
|
||||
const [err] = await callLookup('meta.test', {});
|
||||
expect(err).toBeInstanceOf(WebviewEgressBlockedError);
|
||||
expect(err?.message).toContain('meta.test resolves to 169.254.169.254');
|
||||
});
|
||||
|
||||
it('refuses when ANY resolved address is blocked, not just the first', async () => {
|
||||
const [err] = await callLookup('mixed.test', { all: true });
|
||||
expect(err).toBeInstanceOf(WebviewEgressBlockedError);
|
||||
});
|
||||
|
||||
it('passes resolver errors and empty answers through as ordinary DNS failures', async () => {
|
||||
const [notFound] = await callLookup('unknown.test', {});
|
||||
expect(notFound?.code).toBe('ENOTFOUND');
|
||||
expect(isEgressBlockedError(notFound)).toBe(false);
|
||||
const [empty] = await callLookup('nowhere.test', {});
|
||||
expect(empty?.code).toBe('ENOTFOUND');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guarded undici Agent (end-to-end against a local upstream)', () => {
|
||||
let upstream: Server;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
upstream = createServer((req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end(`served ${req.headers.host ?? ''}`);
|
||||
});
|
||||
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
||||
port = (upstream.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('connects through the hook: a name resolving to loopback reaches the server', async () => {
|
||||
const dispatcher = createWebviewDispatcher(createEgressLookup(fakeResolve));
|
||||
try {
|
||||
const res = await undiciFetch(`http://dash.test:${port}/`, { dispatcher });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe(`served dash.test:${port}`);
|
||||
} finally {
|
||||
await dispatcher.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails the connect when the name resolves into a blocked range, with the reason as the cause', async () => {
|
||||
const dispatcher = createWebviewDispatcher(createEgressLookup(fakeResolve));
|
||||
try {
|
||||
const attempt = undiciFetch(`http://meta.test:${port}/latest/meta-data/`, { dispatcher });
|
||||
await expect(attempt).rejects.toThrow();
|
||||
const err = await attempt.catch((e: unknown) => e);
|
||||
expect(isEgressBlockedError(err)).toBe(true);
|
||||
expect(egressBlockedReason(err)).toContain('169.254.169.254');
|
||||
} finally {
|
||||
await dispatcher.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('webviewFetch', () => {
|
||||
it('refuses a blocked IP literal synchronously, since net.connect never consults lookup for one', async () => {
|
||||
const attempt = webviewFetch(new URL('http://169.254.169.254/latest/meta-data/'));
|
||||
await expect(attempt).rejects.toBeInstanceOf(WebviewEgressBlockedError);
|
||||
const err = await attempt.catch((e: unknown) => e);
|
||||
expect(egressBlockedReason(err)).toMatch(/169\.254\.169\.254/);
|
||||
});
|
||||
|
||||
it('refuses the bracketed IPv6 and the alias forms the same way', async () => {
|
||||
await expect(webviewFetch(new URL('http://[fd00:ec2::254]/'))).rejects.toBeInstanceOf(WebviewEgressBlockedError);
|
||||
await expect(webviewFetch(new URL('http://metadata.google.internal/'))).rejects.toBeInstanceOf(
|
||||
WebviewEgressBlockedError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('egressBlockedReason', () => {
|
||||
it('walks a cause chain and ignores unrelated errors', () => {
|
||||
const inner = new WebviewEgressBlockedError('x resolves to 169.254.1.1');
|
||||
const wrapped = new TypeError('fetch failed', { cause: inner });
|
||||
expect(egressBlockedReason(wrapped)).toBe(inner.message);
|
||||
expect(egressBlockedReason(new Error('ECONNREFUSED'))).toBeNull();
|
||||
expect(egressBlockedReason(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -653,3 +653,34 @@ describe('misc helpers', () => {
|
||||
expect(proxyPrefixFor(CAP)).toBe(PREFIX);
|
||||
});
|
||||
});
|
||||
|
||||
describe('referrer policy on proxied responses', () => {
|
||||
const CAP = 'c'.repeat(32);
|
||||
const requestUrl = new URL('http://127.0.0.1:4000/');
|
||||
|
||||
it('stamps same-origin and drops the upstream policy, so the capability in the URL never reaches a third party', () => {
|
||||
const { headers } = buildDownstreamResponseHeaders(
|
||||
[
|
||||
['referrer-policy', 'unsafe-url'],
|
||||
['content-type', 'text/html'],
|
||||
],
|
||||
[],
|
||||
CAP,
|
||||
requestUrl,
|
||||
false
|
||||
);
|
||||
expect(headers['referrer-policy']).toBe('same-origin');
|
||||
expect(headers['content-type']).toBe('text/html');
|
||||
});
|
||||
|
||||
it('stamps it even when the upstream sent none (the browser default would still leak on a downgrade-style policy)', () => {
|
||||
const { headers } = buildDownstreamResponseHeaders(
|
||||
[['content-type', 'application/json']],
|
||||
[],
|
||||
CAP,
|
||||
requestUrl,
|
||||
false
|
||||
);
|
||||
expect(headers['referrer-policy']).toBe('same-origin');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user