mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d9dd70b51 | ||
|
|
ed47a599be | ||
|
|
ccb3afc9ee | ||
|
|
c3b0dc345b | ||
|
|
0ab2416460 | ||
|
|
ac6fe6ef79 | ||
|
|
dafe3de185 | ||
|
|
2a06f7a5a8 | ||
|
|
453605a58f | ||
|
|
4d8857f72a | ||
|
|
f496e35d71 | ||
|
|
91070f5dda | ||
|
|
fdce57ce5a | ||
|
|
d1928f300e | ||
|
|
ca731c67b3 | ||
|
|
a3fe0ae728 | ||
|
|
82825cbfb3 | ||
|
|
d1868516f7 | ||
|
|
3e1272a675 | ||
|
|
db6cd838b1 | ||
|
|
66a41f5aa9 | ||
|
|
a36c1f62db | ||
|
|
8b2c857c3f | ||
|
|
583678c950 | ||
|
|
5728b86a68 | ||
|
|
39ef17b6af | ||
|
|
814362b67b | ||
|
|
e9f9497259 | ||
|
|
8768ca4a5a | ||
|
|
df9214ba9a | ||
|
|
5f4c89b990 | ||
|
|
54615e2371 | ||
|
|
828b1664f7 |
@@ -1,5 +1,54 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 0ab2416: Opt-in multi-user mode (`--multiuser` / `CODEMAN_MULTIUSER=1`, off by default).
|
||||
|
||||
Named users with individually scrypt-hashed passwords in `~/.codeman/users.json`, per-user case spaces under `~/codeman-users/<name>/cases`, and ownership scoping of sessions (create/list/delete/mutate, incl. bulk delete), cases, cron jobs + run history, scheduled runs, search, file previews, session history, away digest, subagent/workflow monitors, and real-time SSE/WS streams (including the debounced session/task update path, clipboard, and push notifications). A non-admin's `workingDir` is realpath-confined to their own space at every spawn/link path (session create, quick-start, cron create/fire, scheduled runs, case link/docker-link, docker import). Non-admin users default to Claude's classifier-guarded `--permission-mode auto`; raw shell mode, cron `launchCommand`, skip-permissions, and the Codex/Gemini bypass switches require an explicit per-user `canBypassPermissions` grant (enforced at every spawn site incl. one-shots, plan generation, scheduled runs, and remote launches). Machine-level resources (remote/Docker hosts + host reads, mux sessions, orchestrator, tunnel, self-update, settings) are admin-only. Admin API (`/api/admin/users*`) with one-time passwords, last-admin invariants (validated before any teardown), and an append-only audit log; self-service `/api/me` + password change; a frontend admin Users tab + change-password modal; and `codeman users add|passwd|list|rm` CLI. Also adds a global `auto` Claude startup permission mode. When off, behavior is byte-identical to single-user.
|
||||
|
||||
Auth hardening: the login throttle verifies the password before consulting the per-account failure bucket (a correct password can never be locked out); the `mustChangePassword` lockbox covers the WebSocket terminal; the cookie fast-path re-validates identity against the store each request (so a CLI/admin delete/disable/demote takes effect promptly); a role/grant change revokes the target's sessions. (Known limitation: a bare CLI `codeman users passwd` reset — no delete — does not by itself revoke an already-active cookie until it expires; use `codeman users rm`, the admin API, or a restart to force-revoke.) Data-integrity hardening: the store distinguishes a missing users file from a corrupt/unreadable one (so a transient read error can't overwrite all accounts) and writes via a unique per-process temp file; the earlier fire-and-forget `touchLastLogin` corruption race is serialized.
|
||||
|
||||
Note: multi-user mode separates workspaces for a trusted team; it is not a security boundary between users (all sessions share the host OS account). Pair with Docker cases for real isolation.
|
||||
|
||||
## 1.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Docker session mode** hardening + fixes, plus a File Viewer header button.
|
||||
|
||||
**What Docker session mode is** (recap): a case can run inside an isolated, hardened Docker container instead of on the host, and any of the CLI backends (Claude, Codex, Gemini, OpenCode, or a plain shell) runs inside it. It is a location overlay on cases — not a new session mode — and the container analog of remote-SSH cases: a local tmux pane `docker exec`s into a durable in-container tmux, with exactly one long-lived container per case that multiple sessions share. The workspace, credentials, and conversation transcripts are bind-mounted so the agent is authenticated and resumable; containers are hardened by default (`--cap-drop ALL`, `--security-opt no-new-privileges`, non-root, pids/memory caps, `--init`, never `--privileged` or the docker socket) and export-safe. Start one with the one-click "Run in Docker" checkbox on Create Case, or the Docker tab for full control.
|
||||
|
||||
This release fixes the rough edges found running it for real:
|
||||
|
||||
Docker cases:
|
||||
- **Seamless Claude auth in containers**: `~/.claude.json` is no longer bind-mounted as a single file (a mount point that broke Claude's atomic-rename config writes — forcing re-auth and, via failed in-place writes, corrupting the host `~/.claude.json`). It is now seeded as a writable, onboarding-complete copy, so a docker session boots straight to the prompt (no theme picker, login, or folder-trust prompt).
|
||||
- **Claude-state isolation**: containers no longer bind-mount the whole `~/.claude` directory (which wrote backups/tasks/teams/settings back into the host). Only `~/.claude/projects` transcripts are shared (host watchers + `--resume`); credentials, settings, and stats-cache are seeded as writable copies; everything else stays container-local.
|
||||
- **Codex/Gemini/gcloud/opencode isolation**: same treatment — codex shares `sessions/` + `history.jsonl` (response-viewer + resume) and seeds `auth.json`/`config.toml`; gemini/gcloud/opencode are whole seed-copies. Containers never write their credential state back into the host dirs.
|
||||
- **Base image auto-builds on first use**: a missing `codeman/agent:base` no longer blocks case creation or launch; it builds locally on first use (concurrency-safe, with SSE progress toasts).
|
||||
- **UTF-8 locale**: containers set `LANG`/`LC_ALL=C.UTF-8` so tmux renders Claude's box-drawing correctly (fixes `qqqq` line artifacts).
|
||||
- **Create Case UI**: larger, collapsed-by-default "Run in Docker" settings with a shorter hint; dockerized cases show a short `(docker)` tag (or the custom host id) in the case menus.
|
||||
- **Tab naming**: docker/remote (and codex/gemini/opencode) sessions now follow the `w<n>-<case>` convention instead of `codeman-<id>`.
|
||||
|
||||
Other:
|
||||
- **File Viewer header button** (opt-in via App Settings, Header Displays): toggle the file browser panel from the header.
|
||||
- Fixed a timezone-boundary flaky test in the away-digest route suite.
|
||||
|
||||
## 1.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add **Docker session mode**: a case can now run inside an isolated Docker container instead of on the host, with configurable network / resource / credential settings, multiple sessions sharing one per-case container, and one-click export to move a container (toolchain + workspace) to another machine.
|
||||
- Docker is a location overlay on cases (not a new session mode), mirroring the remote-SSH feature: a local tmux pane runs `docker exec -it` into a durable in-container tmux server. The container is scoped to the case (`codeman-case-<name>`), so multiple sessions share it; killing one session never stops the shared container.
|
||||
- New `/api/docker-hosts` CRUD, `/api/cases/docker-link`, and a `/api/quick-start` docker branch. Create Case gains a **Docker** tab. Base image is built locally via `scripts/build-agent-image.mjs` (node + claude/codex/gemini/opencode + tmux, secret-free, arbitrary-uid-writable HOME).
|
||||
- Hardened by default: `--cap-drop ALL`, `--security-opt no-new-privileges`, non-root, `--pids-limit`, `--memory`==`--memory-swap`, `--init`; never `--privileged` or the docker socket. Convenient credential default bind-mounts host `~/.claude` etc. read-write (never captured by `docker commit`); a sealed profile is opt-in.
|
||||
- Two-layer durability: reconnect after a Codeman restart reattaches the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript via `--resume`.
|
||||
- Export / import: full-image (`docker commit` + `save` + workspace tar + manifest) or workspace-only, to one portable `.codeman-container.tgz`; import validates checksums, guards path traversal, and re-tags the loaded image into a quarantined namespace. Instance-scoped boot reaper cleans orphaned containers. New `docker:*` SSE events. Docs in `docs/docker-cases.md`.
|
||||
- Robustness: sets `CLAUDE_CODE_TMPDIR` in the container so claude launches regardless of workspace path. In-container hooks require the server to be reachable from the container (documented); on a loopback-only bind, idle detection falls back to output-based.
|
||||
|
||||
Also wire session, away-digest, and cron header-button visibility toggles in App Settings.
|
||||
|
||||
## 1.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -366,6 +366,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, or **Codex** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
|
||||
- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
|
||||
- **Image input** — paste or drag-and-drop images straight into a session
|
||||
@@ -376,6 +377,41 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
|
||||
---
|
||||
|
||||
## Isolated Docker Sessions
|
||||
|
||||
Run a case inside its own hardened Docker container instead of directly on your host — for security isolation, reproducible toolchains, and one-click portability.
|
||||
|
||||
- **One click** — on **New Case → Create New**, tick **🐳 Run in an isolated Docker container**. Codeman creates the case folder, spins up a container with default settings, and starts the agent inside it. No host/image/network fields to fill in.
|
||||
- **Resource templates** — expand the checkbox for a **Small / Medium / Large / GPU** preset (memory, CPUs, GPU), or set your own. **Disk is elastic** — storage grows as data flows in, no fixed cap.
|
||||
- **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others.
|
||||
- **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket. Your existing `~/.claude` login is bind-mounted (credentials stay on the host, never captured in exports); a **sealed** profile (no host mounts, network off) is one toggle away.
|
||||
- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case.
|
||||
- **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript.
|
||||
|
||||
Prerequisite: Docker (or Podman) and the base image — build it once with `node scripts/build-agent-image.mjs`. Full guide: [`docs/docker-cases.md`](docs/docker-cases.md).
|
||||
|
||||
---
|
||||
|
||||
## Multi-User Mode (opt-in)
|
||||
|
||||
Share one Codeman with a small trusted team, each person getting their own login and workspace. **Off by default** — without the flag, nothing changes.
|
||||
|
||||
Enable with `codeman web --multiuser` (or `CODEMAN_MULTIUSER=1`). Create the first admin, then manage users from the CLI or the **Users** tab in App Settings:
|
||||
|
||||
```bash
|
||||
codeman users add alice --admin # prompts for a password (or --password-stdin)
|
||||
codeman users add bob # a regular user
|
||||
codeman users list
|
||||
```
|
||||
|
||||
- **Per-user spaces** — each user's cases live under `~/codeman-users/<name>/cases`; sessions, cases, search, and real-time events are scoped to their owner. Admins see everything.
|
||||
- **Individually revocable logins** — named users with scrypt-hashed passwords in `~/.codeman/users.json`; disable, reset (one-time password), or delete an account at any time. Admin actions are audited to `~/.codeman/admin-audit.jsonl`.
|
||||
- **Safer defaults for regular users** — non-admins run Claude in `--permission-mode auto` (Anthropic's classifier-guarded mode); raw shell sessions, cron `launchCommand`, and skip-permissions require an explicit per-user grant.
|
||||
|
||||
> ⚠️ **This separates workspaces; it does not sandbox users from each other.** Every session runs as the same OS account, so a determined user's agent can still reach another user's files. For real isolation, pair users with **Docker cases** or run separate instances under separate OS accounts. See [`docs/multi-user-plan.md`](docs/multi-user-plan.md) and the multi-user section of [`docs/security-architecture.md`](docs/security-architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## Remote Access — Cloudflare Tunnel
|
||||
|
||||
Access Codeman from your phone or any device outside your local network using a free [Cloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) — no port forwarding, no DNS, no static IP required.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Codeman agent base image (built locally by scripts/build-agent-image.mjs).
|
||||
#
|
||||
# Contains the agent toolchain (node + the CLIs + git/tmux/ripgrep) but NO
|
||||
# secrets: credentials are delivered at RUNTIME via bind mounts (~/.claude etc.)
|
||||
# or name-only `docker exec --env`, never baked in, so `docker save` exports stay
|
||||
# secret-free. tmux is a HARD prerequisite (the in-container tmux is what makes a
|
||||
# reconnect durable), so it is installed here and probed before launch.
|
||||
#
|
||||
# HOME is made writable by an ARBITRARY host uid via the OpenShift "gid 0,
|
||||
# group-writable" convention: on Linux we run `--user <hostUid>:0`, so the agent
|
||||
# uid is the host uid (workspace files stay host-owned) while gid 0 keeps $HOME
|
||||
# writable even though the uid is not the baked 1000.
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
# Base toolchain. `curl` is needed for the hook callbacks (`curl -sk $CODEMAN_API_URL`),
|
||||
# `procps` for `ps`, `tmux` for the durable in-container session.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
tmux \
|
||||
ripgrep \
|
||||
curl \
|
||||
ca-certificates \
|
||||
less \
|
||||
procps \
|
||||
openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# The agent CLIs (all four backends Codeman supports). Pinning is left to the
|
||||
# rebuild cadence (see docs/docker-cases-plan.md, user-decision 2).
|
||||
RUN npm install -g \
|
||||
@anthropic-ai/claude-code \
|
||||
@openai/codex \
|
||||
@google/gemini-cli \
|
||||
opencode-ai \
|
||||
&& npm cache clean --force
|
||||
|
||||
# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is
|
||||
# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at
|
||||
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
|
||||
# only matters for a hand-run / Docker Desktop container. gid 0 + group-writable
|
||||
# HOME (OpenShift arbitrary-uid convention) keeps $HOME writable for any uid.
|
||||
# UTF-8 locale so tmux/Ink render Unicode box-drawing instead of VT100 ACS `q`
|
||||
# glyphs (C.UTF-8 is built into glibc; no locales package needed). Codeman also
|
||||
# sets these at run time so containers built before this line still get UTF-8.
|
||||
ENV LANG=C.UTF-8 LC_ALL=C.UTF-8
|
||||
ENV HOME=/home/agent
|
||||
# `.claude` (+ `.claude/projects` mount point) and `.codex` (+ `.codex/sessions`) are
|
||||
# pre-created gid-0 group-writable so the container owns its OWN credential config
|
||||
# dirs: tokens/settings/config are seeded in as writable copies and each CLI's runtime
|
||||
# state (backups, tasks, refreshed tokens) stays container-local, while ONLY the shared
|
||||
# transcript/rollout dirs (`.claude/projects`, `.codex/sessions`) are bind-mounted from
|
||||
# the host. (gemini/gcloud/opencode are whole seed-copies and need no pre-created dir.)
|
||||
RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
|
||||
&& mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \
|
||||
/home/agent/.claude/projects /home/agent/.codex/sessions \
|
||||
&& chgrp -R 0 /home/agent \
|
||||
&& chmod -R g=u /home/agent
|
||||
|
||||
USER agent
|
||||
WORKDIR /home/agent
|
||||
|
||||
# Codeman overrides the command with `sleep infinity` at create time; this is the
|
||||
# fallback so a hand-run container also idles rather than exiting.
|
||||
CMD ["sleep", "infinity"]
|
||||
@@ -0,0 +1,433 @@
|
||||
<!-- Design doc generated via ultracode multi-agent workflow (wf_e3a7498b-26f): 3 architecture proposals -> judge panel -> synthesis -> completeness critic. -->
|
||||
|
||||
# Docker Session Mode, Implementation Plan
|
||||
|
||||
## Decisions (locked 2026-07-19, by repo owner)
|
||||
|
||||
1. **Isolation posture**: CONVENIENT default (bind-mount host `~/.claude` etc. read-write so the existing login just works; network on; still hardened non-root + cap-drop + resource caps). SEALED profile (`mountCredentials:false` + `network:none`) is a per-case opt-in.
|
||||
2. **Export**: offer BOTH full-image (`commit`+`save`+workspace tar) AND workspace-only, side by side, no default (ask each time).
|
||||
3. **Base image**: BUILD LOCALLY on first use via `scripts/build-agent-image.mjs` from a repo `docker/agent.Dockerfile`. No registry required. (GHCR pull can be added later.)
|
||||
4. **Hooks**: WIRE HOOKS NOW. Codeman scaffolds `.claude/settings.local.json` + CLAUDE.md into the linked host workspace dir (same as local cases), enabling in-container permission prompts, hook-idle detection, and the Claude Model picker.
|
||||
|
||||
Adopted defaults for the remaining open items (Section 10): resume-on-restart ON; container is per-CASE and shared by multiple sessions (killing one session only kills its in-container tmux session, never `docker stop` while siblings remain; stop/remove only on explicit teardown or case-delete); rootless caps = ship-with-warning (`capsEnforced` surfaced); remote docker daemon = local-first; podman = docker-first best-effort.
|
||||
|
||||
## Implementation status (branch `feat/docker-session-mode`)
|
||||
|
||||
DONE and END-TO-END VERIFIED against a real docker daemon (create host, link case, quick-start shell in a real container, workspace bind-mount round-trip, hook scaffolding, session-delete keeps the shared container up, case-delete `docker rm`s it):
|
||||
|
||||
- Phase 0-1: types (`DockerHost`/`DockerCase`/`SessionDocker`), `src/docker-hosts.ts` (storage, pure `buildDockerBaseArgs`/`buildDockerCreateArgs`, `containerApiUrl`, `hostGatewayAlias`, config-hash, credential-mount resolution, daemon probes), `DockerHostSchema`/`DockerCaseLinkSchema`. 26 unit tests.
|
||||
- Phase 2: `tmux-manager` `buildDockerLaunchCommand` (image-check -> ensure -> start -> exec, resume-aware), `buildDockerKillCommand` (in-container tmux only, multi-session safe), stop/remove; wired into `createSession`/`respawnPane`/`killSession`. 14 unit tests.
|
||||
- Phase 3: `Session` threading (`_docker`, toState, option builders, in-container cliVersion probe, `resolveMuxAttachCwd`), `server.ts` recovery round-trip.
|
||||
- Phase 4: `case-routes` `/api/docker-hosts` CRUD + `/api/cases/docker-link` + listing + docker-unlink; `session-routes` `/api/quick-start` docker branch (rejects per-session config, probes availability + tmux, scaffolds hooks, seeds resume id).
|
||||
- Phase 5 (partial): `docker/agent.Dockerfile` + `scripts/build-agent-image.mjs` (built + verified: node 22, tmux, claude/codex/gemini/opencode, arbitrary-uid HOME). Host-guard allowlists `host.docker.internal`/`host.containers.internal` for in-container hooks.
|
||||
- Full CI green (3445 tests).
|
||||
|
||||
REMAINING:
|
||||
|
||||
- Phase 6: export / import (`docker commit` + `save | gzip` + workspace tar + manifest; `load` + quarantined re-tag), GC / boot reaper, disk-safety prechecks, drift-recreate route, SSE `docker:*` events. THE "move to a new machine" feature.
|
||||
- Phase 7: frontend Create Case "Docker" tab + `linkDockerCase` + run wiring + case-picker labels + export/import UI.
|
||||
- Phase 8: CLAUDE.md "Docker cases" Key Pattern + `docs/docker-cases.md` + COM.
|
||||
- Deferred refinements: in-container model-picker via `settings.local.json`; live mid-run resume-id capture into `DockerCase.lastClaudeSessionId`; rootless/Desktop uid probe (currently a platform heuristic).
|
||||
|
||||
## 1. Goal & user stories
|
||||
|
||||
Add "Docker cases" to Codeman: a case can point at a container instead of a local or remote-SSH path, and any of the five CLI backends (`claude` / `shell` / `opencode` / `codex` / `gemini`) runs inside that container. It is modeled as a LOCATION OVERLAY on cases, exactly like the remote-SSH feature (COD-94/#145), never as a sixth `SessionMode`.
|
||||
|
||||
User stories:
|
||||
|
||||
- As the repo owner, I link a case to a per-project container so an autonomous Claude/Ralph run executes in a hardened sandbox (cap-drop, non-root, resource caps) instead of directly on my host, while keeping my existing OAuth login and transcript history working with zero extra setup.
|
||||
- I set default, per-case-changeable container settings (image, network mode, memory/cpu/pids caps) at link time and edit them later, and edits actually take effect through a recreate-on-drift path (see Section 4).
|
||||
- I reconnect after a Codeman restart and land back in the SAME running agent with the conversation intact. When the CONTAINER itself was stopped/rebooted/OOM-killed (which destroys the in-container tmux), the next launch RESUMES the last conversation from the bind-mounted transcript rather than starting fresh (durability model in Section 2, Key decision 1).
|
||||
- I export a finished run's whole environment (toolchain plus workspace) to a portable, secret-free `.tar.gz`, move it to another machine, and import it back into a fresh case in one click.
|
||||
- The container never accumulates: killing the session stops it, deleting the case removes it, and an instance-scoped boot reaper reaps containers whose case is gone.
|
||||
|
||||
Non-goals for the MVP: multi-tenant untrusted-code isolation guarantees (Codeman is loopback-default and single-operator, and the agent already runs `--dangerously-skip-permissions` on the host today), Kubernetes/compose orchestration, and per-command ephemeral containers.
|
||||
|
||||
## 2. Chosen architecture and why
|
||||
|
||||
The design grafts the strongest idea from each of the three proposals:
|
||||
|
||||
- Overlay-not-a-mode + faithful remote-SSH mirror (from "Docker Cases as a Location Overlay"): lowest churn, rides the existing quick-start / mux-sessions / state / recovery plumbing.
|
||||
- Convenient-but-hardened default with an opt-in sealed profile, plus exec-time name-only secret env (from "Sealed Sandbox"): a strict security improvement over today's on-host execution without the UX tax of forcing an in-container re-login.
|
||||
- One-artifact export + in-app import route (from "Container-as-Cargo"): the genuinely new, high-value capability Codeman lacks.
|
||||
|
||||
### Key decision 1: persistent per-CASE container, durable in-container tmux, AND resume-on-restart (the two-layer durability model)
|
||||
|
||||
Exactly one long-lived container per Docker case, named as a pure slug function `codeman-case-<slug>` (Docker charset `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`; Codeman already slugs case names for tmux), so create-if-missing and boot recovery are idempotent. PID1 is `sleep infinity` under `--init` (tini reaps zombies and forwards `docker stop`'s SIGTERM); the CLI is NOT the container command. The CLI runs inside a DURABLE in-container tmux on a dedicated socket `-L codeman-docker`, session `codeman-dkr-<id8>`, the direct analog of remote's `-L codeman-remote` / `codeman-ssh-<id8>`.
|
||||
|
||||
Two DIFFERENT failure surfaces need two DIFFERENT recovery layers, and conflating them is the central flaw the critic caught:
|
||||
|
||||
1. Codeman-PROCESS restart while the container stays up: the in-container tmux is still alive, so `tmux new-session -A` (attach-or-create) reattaches the SAME live agent and the paneCommand is ignored. This is the remote-SSH durability idiom and it works unchanged.
|
||||
2. CONTAINER stop / daemon restart / host reboot / OOM-kill: the in-container tmux is GONE (fresh PID1). `new-session -A` will now CREATE a fresh session and run the paneCommand, which would start a brand-new conversation. This is the case the raw plan silently lost. Because the transcript directory is bind-mounted from the host (Key decision 3), the fix is to launch with RESUME: the paneCommand becomes `exec claude --dangerously-skip-permissions --resume <claudeSessionId>` (codex uses `resume <id>`, gemini `--resume <id>`) whenever a captured `claudeSessionId` exists. The `-A` semantics make this self-selecting: the resume flag only ever executes when tmux is actually re-created, which is exactly when the live session was lost. When tmux is still alive (case 1), attach wins and the flag is inert.
|
||||
|
||||
Capturing / persisting / reusing the resume id (the missing mechanism the critic flagged): Codeman already learns `Session.claudeSessionId` from transcript correlation (which works here because projHash matches, Key decision 3) and persists it in `SessionState`. We thread that value into `createSessionOptions` / `respawnPaneOptions` for docker so `buildDockerLaunchCommand` can inject the resume flag on any relaunch. To make a NEW Codeman session (new `id8`) re-launched against the same case resume its predecessor's conversation, we ALSO persist `lastClaudeSessionId` on the `DockerCase` record; the quick-start docker branch seeds the new `Session` with it when the `dockerResumeOnStart` setting is on. First-ever launch has no id, so it starts fresh. This is user-decision 7 (default resume behavior).
|
||||
|
||||
Reconciling with stop-on-kill and with the `--restart` policy (the internal inconsistency the critic found): the container is created with `--restart no` uniformly (Codeman's idempotent create-if-missing plus boot recovery is the single recovery mechanism; a restart policy would not preserve the conversation anyway because a restarted container gets a fresh PID1/tmux). Boot recovery re-runs `buildDockerLaunchCommand` from the restored `MuxSession.docker` (`docker inspect || docker create; docker start`, then exec with resume), so a host reboot or daemon restart recreates+starts the container and resumes the conversation instead of the session vanishing. `reconcileSessions` (tmux-manager.ts ~1800-1815) must NOT hard-delete a docker session merely because no LOCAL pane exists after the local `-L codeman` server died; docker (like remote) sessions are restored from `mux-sessions.json` and relaunched. This relaunch path is explicitly part of Phase 4/Phase 3 recovery work, not assumed.
|
||||
|
||||
Why this over the alternatives: `docker exec` gets SIGHUP and dies when its client TTY closes, so a bare `docker exec claude` restarts the CLI on every reconnect/respawn. The inner tmux plus resume is what makes reconnect idempotent across BOTH failure surfaces. Because this durability is the single most important design point, tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent fallback to bare exec. Rejected alternatives: ephemeral-per-run or bare-exec containers (no reattach durability); a literal `'docker'` `SessionMode` (touches dozens of switch/enum sites and diverges from the remote overlay precedent, since Docker is a LOCATION orthogonal to the 5 CLI backends).
|
||||
|
||||
### Key decision 2: CLI + auth delivery
|
||||
|
||||
One prebuilt base image (built once, contains NO secrets): `node:22-bookworm-slim` + `git tmux ripgrep ca-certificates`, `npm i -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai`, an `agent` user, HOME dirs made writable by an arbitrary host uid via the OpenShift "gid 0, group-writable" convention (Key decision 6). Because the toolchain is baked, export is reproducible and needs no network at import time. The image name/namespace/registry and its refresh cadence are user-decision 2 (the `codeman/agent:base` placeholder implies a Docker Hub org the project may not own).
|
||||
|
||||
Credentials are delivered ONLY at runtime, two commit-safe channels, default convenient:
|
||||
|
||||
- OAuth/config-file CLIs (Claude Max/Pro, gcloud, opencode): bind-mount the host credential dirs read-write (`~/.claude`, `~/.codex`, `~/.gemini` + `~/.config/gcloud`, `~/.config/opencode`) so the common user "just works" with no in-container login. Because these are bind mounts, `docker commit` (which captures only the container's own writable layer, never bind mounts) physically cannot capture them, so exports stay secret-free.
|
||||
- API-key CLIs (codex/gemini): exec-time NAME-ONLY `docker exec --env OPENAI_API_KEY --env GEMINI_API_KEY ...` (no `=value`), sourced from Codeman's own process env. Only the key NAME appears in argv (no `ps` leak), and per-exec env is never captured by `docker commit`. This is the technique Codeman already uses via `tmux setenv` for the local Codex/Gemini panes, so it composes with existing machinery.
|
||||
|
||||
Per-host `DockerHost.mountCredentials` defaults `true` (convenient); setting it `false` yields a SEALED profile (no host cred mounts, in-container login only) for genuinely untrusted work. CRITICAL sealed-mode export rule (the leak the critic caught): in sealed mode the in-container login writes tokens into the container's OWN writable layer, which `docker commit` DOES capture, so a full-image export of a sealed container would ship credentials. Therefore full-image export is REFUSED for `mountCredentials:false` containers by default; the user may either take a workspace-only export (always safe) or opt into a pre-commit scrub that `docker exec`s `rm -rf ~/.claude ~/.codex ~/.gemini ~/.config/gcloud ~/.config/opencode` inside the container before commit (destructive to the in-container login, which is the point). This is enforced in the export route, not left to a manifest assertion.
|
||||
|
||||
Per-session `envOverrides` / `effort` / `codexConfig` / `geminiConfig` / `openCodeConfig` are REJECTED at quick-start exactly like the remote branch (session-routes.ts ~1698-1710). `modelOverride` is the one deliberate difference from remote: because the docker workspace is a REAL bind-mounted host dir that Codeman scaffolds (Key decision 5 and Section 6), `updateCaseModel()` can write the `model` key into `<workspace>/.claude/settings.local.json` and the in-container `claude` reads it, so the App Settings Claude Model picker works for docker cases. `effort` is a `--effort` CLI arg applied only by the local-spawn path we bypass, so it stays rejected (surfaced honestly in the UI, not silently inert). Per-mode command customization goes through `DockerHost.commands.<mode>` (`defaultDockerCommandForMode`, mirror of `defaultRemoteCommandForMode` at remote-hosts.ts:60). NEVER bake secrets into an image layer and NEVER pass a secret via create-time `-e` (both are committed).
|
||||
|
||||
Rejected alternative: sealed-by-default. For a single-operator loopback tool where the agent already runs skip-permissions on the host, forcing an in-container OAuth re-login is a UX regression with little real gain. We keep sealed as an opt-in. Rejected alternative: baking a login into the image, which leaks the instant you `docker save`.
|
||||
|
||||
### Key decision 3: workspace mount, container CWD, and transcript correlation
|
||||
|
||||
Bind-mount the host workspace dir into the container at the SAME absolute path (`dst == src`, mirror the host path), and set both `Session.workingDir` and the container workdir to that host path.
|
||||
|
||||
Two problems this solves that the raw proposals got wrong:
|
||||
|
||||
- File features: `DockerCase.hostWorkspacePath` is a REAL host directory, so `Session.workingDir = hostWorkspacePath` keeps file-routes, attachments, image-watcher, and previews working on real host bytes (unlike remote, where the path is remote-only and those features no-op). All three proposals wired `casePath = <container path>`; we deliberately diverge and use the host path.
|
||||
- Transcript correlation: Claude writes transcripts under `~/.claude/projects/<hash-of-CWD>/`. By mirroring the host path as the container CWD, the projHash computed inside the container equals the host-side hash Codeman's transcript/subagent/workflow watchers expect, so correlation keeps working (and, in turn, feeds the resume-id capture in Key decision 1). A `/workspace`-style fixed dst would break it. Mirror-vs-fixed is user-decision 3.
|
||||
|
||||
`resolveMuxAttachCwd` still returns `/tmp` for docker sessions (the LOCAL bash pane only runs `docker exec`; it never needs the workspace as its cwd), mirroring remote.
|
||||
|
||||
### Key decision 4: network default and the engine-specific host gateway
|
||||
|
||||
Default `bridge` (own netns, NAT egress, no inbound), per-case changeable to `none` (offline shell sandbox; warned because it breaks the API CLIs) or `custom` (a user-defined bridge `codeman-net-<slug>`, the chokepoint for a future egress allowlist). `host` networking and any `-p` inbound publish are structurally unrepresentable in the flag builder and schema. Rationale: every API-backed CLI (Claude, Codex, Gemini) plus npm/git needs egress, so `bridge` is the only sane functional default; `none` is reserved for `shell`.
|
||||
|
||||
The host-callback gateway alias is ENGINE-SPECIFIC (the critic's podman finding): Docker uses `host.docker.internal`, Podman uses `host.containers.internal` (Docker's alias only exists on recent podman). A helper `hostGatewayAlias(engine)` returns the right name; Section 2.5, the create args, the `CODEMAN_API_URL` rewrite, and the host-guard allowlist all consume it, and BOTH aliases are added to the allowlist so a mixed fleet keeps working.
|
||||
|
||||
### Key decision 5: hooks actually reach the host AND are actually installed
|
||||
|
||||
Two independent things must both be true for a hook to fire, and the raw plan wired only the first:
|
||||
|
||||
1. Network reachability. Claude Code hooks POST to `$CODEMAN_API_URL` (`curl -sk`). Inside a bridge container `localhost` is the container and prod binds `127.0.0.1`, so we set `--add-host <gatewayAlias>:host-gateway` on create (skipped on Docker Desktop, where the alias is native), add the gateway alias to the host guard, and provide `CODEMAN_API_URL` and the hook secret (below).
|
||||
2. Hook INSTALLATION. Hooks live in `<workspace>/.claude/settings.local.json`, written by the quick-start scaffolding block (around session-routes.ts ~1776) that calls `writeHooksConfig()` / `updateCaseModel()`. The raw plan extended the `!remote` guard to `!remote && !docker`, which would SKIP that block and silently disable ALL hooks regardless of networking. For docker the workspace is a REAL bind-mounted host dir, so the scaffolding block MUST run. Precise fix: extend to `!remote && !docker` ONLY the LOCAL-CLI-availability and local-spawn guards (the ones that stat the local binary or build the local spawn command); leave the workspace-scaffolding guard at `!remote` so it runs for docker. This same decision is what makes `modelOverride` work (Key decision 2). Consequence, surfaced as user-decision 4: linking a docker case now WRITES `.claude/settings.local.json` (and the CLAUDE.md scaffold, matching local-case behavior) into the user's real host directory, a behavioral shift from "link a dir" to "link and scaffold a dir."
|
||||
|
||||
`CODEMAN_API_URL` derivation (the wrong-scheme bug the critic caught): prod is HTTPS-only on 3000, and `server.ts` (~2000) auto-sets `process.env.CODEMAN_API_URL = ${protocol}://${apiHost}:${port}`. Hardcoding `http://host.docker.internal:3000` fails every hook. Instead a pure helper `containerApiUrl(process.env.CODEMAN_API_URL, engine)` parses the running URL and substitutes ONLY the hostname with `hostGatewayAlias(engine)`, preserving scheme and port (`https://host.docker.internal:3000`). Unit-tested against http, https, non-default ports, and both engines. Passed as create-time `--env CODEMAN_API_URL=<derived>` (case-stable, non-secret).
|
||||
|
||||
Hook secret and session attribution:
|
||||
- `~/.codeman/hook-secret` is bind-mounted read-only to a container path; `--env CODEMAN_HOOK_SECRET_FILE=<that path>` is create-time (a path is non-secret; the bytes ride the bind mount and are never committed).
|
||||
- `CODEMAN_SESSION_ID` (which the generated hooks reference at hooks-config.ts:78-80 to attribute events) plus `CODEMAN_MUX=1` are SESSION-scoped, so they are passed at EXEC time via `docker exec --env CODEMAN_SESSION_ID=<id> --env CODEMAN_MUX=1` (non-secret, value inline is fine, and exec env is not committed). Because a `tmux` session started fresh only inherits the invoking env when it starts the SERVER, the launch chain ALSO runs `tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID <id>` (and `CODEMAN_MUX`) so reattaches and newly created panes see the same values. This mirrors how Codeman already injects per-session env into tmux for the external CLIs.
|
||||
|
||||
Hooks-in-MVP-vs-deferred stays user-decision 4; if deferred, docker ships as explicitly hook-degraded and we lean on output-based idle detection through the docker-exec PTY.
|
||||
|
||||
### Key decision 6: uid / HOME / rootless enforcement / macOS Docker Desktop
|
||||
|
||||
The raw plan showed `--user 1000:1000` in one place and `--user "$(id -u):$(id -g)"` in another and never resolved HOME writability; this section fixes all of it.
|
||||
|
||||
- Linux native (docker rootful or rootless): run `--user <hostUid>:0` (host uid, GID 0). The image follows the OpenShift arbitrary-uid convention: `HOME=/home/agent`, and `/home/agent` plus the tool cache dirs (`~/.npm`, `~/.cache`, `~/.config`) are owned `root:0` and group-writable (`chmod -R g+w`, `g+s` on dirs) so a process with GID 0 can write HOME even though its UID is not 1000. This keeps workspace files host-owned (the agent's UID is the host UID) AND keeps HOME writable, so the CLIs actually start.
|
||||
- Podman rootless: use `--userns=keep-id` (maps the host uid to the image's `agent` uid inside the container) instead of `--user`, so `/home/agent` is owned by the running user and workspace files are host-owned. This is a real per-engine branch in `buildDockerCreateArgs`.
|
||||
- macOS Docker Desktop: `--user <macUid>` (e.g. 501) does not own the image's `/home/agent`, so non-bind HOME writes fail EACCES and the CLIs may not start; Desktop also does its own bind-mount uid translation, provides `host.docker.internal` natively (no `--add-host`), and its VM memory ceiling can cap `--memory`. Detect Desktop via `docker info` (Server OS `linuxkit` / `OperatingString` contains "Docker Desktop") and take a dedicated path: do NOT pass `--user` (run as the image's baked `agent` uid and rely on Desktop's translation for workspace access), skip `--add-host`, and note in the UI that memory caps are subject to the VM ceiling.
|
||||
|
||||
Rootless resource-cap enforcement (the silently-inert risk): rootless Docker without cgroup-v2 systemd delegation (`Delegate=yes`) silently IGNORES `--memory`/`--cpus`/`--pids-limit`. The probe checks `docker info` for `CgroupVersion=2` plus rootless plus delegation; if caps cannot be enforced, `checkDockerAvailable` returns `capsEnforced:false` and the link/probe surfaces "resource caps are advisory on this engine." Whether to REQUIRE delegation or ship-with-warning is user-decision 6.
|
||||
|
||||
## 3. Data model
|
||||
|
||||
New TypeScript types in `src/types/session.ts`, added right after the remote types (lines 46-99). SessionMode (line 44) is UNCHANGED.
|
||||
|
||||
```ts
|
||||
export type DockerCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
|
||||
export type DockerEngine = 'docker' | 'podman';
|
||||
export type DockerNetworkMode = 'bridge' | 'none' | 'custom'; // never 'host'
|
||||
|
||||
export interface DockerResourceLimits {
|
||||
memory?: string; // '4g' -> --memory 4g --memory-swap 4g (swap==memory: real OOM cap)
|
||||
cpus?: string; // '2'
|
||||
pidsLimit?: number; // 512 (fork-bomb guard)
|
||||
nofile?: string; // '4096:8192'
|
||||
shmSize?: string; // optional; only when a tool needs /dev/shm
|
||||
}
|
||||
|
||||
export interface DockerHost {
|
||||
id: string;
|
||||
label: string;
|
||||
engine?: DockerEngine; // default resolved by probe (docker, else podman)
|
||||
image: string; // default resolved image ref (see user-decision 2)
|
||||
daemonHost?: string; // advanced: -H ssh://user@host / DOCKER_HOST
|
||||
context?: string; // advanced: --context <ctx>
|
||||
network?: DockerNetworkMode; // default 'bridge'
|
||||
networkName?: string; // when network === 'custom'
|
||||
resources?: DockerResourceLimits;
|
||||
mountCredentials?: boolean; // default true (false = sealed; blocks full-image export)
|
||||
hooksEnabled?: boolean; // default true (host-gateway callback wiring)
|
||||
resumeOnStart?: boolean; // default true (see Key decision 1 / user-decision 7)
|
||||
commands?: Partial<Record<DockerCommandMode, string>>;
|
||||
extraCreateArgs?: string[]; // validated like extraSshOptions
|
||||
extraExecArgs?: string[];
|
||||
}
|
||||
|
||||
export interface DockerCase {
|
||||
name: string;
|
||||
type: 'docker';
|
||||
hostId: string;
|
||||
hostWorkspacePath: string; // absolute HOST dir: bind src + Session.workingDir
|
||||
containerWorkdir?: string; // container path; default = hostWorkspacePath (mirror -> projHash match)
|
||||
container?: string; // default codeman-case-<slug>
|
||||
lastClaudeSessionId?: string; // captured resume id (Key decision 1)
|
||||
}
|
||||
|
||||
export interface SessionDocker { // flattened, round-trips through mux/state (mirror SessionRemote at 91)
|
||||
hostId: string;
|
||||
label: string;
|
||||
engine: DockerEngine;
|
||||
image: string;
|
||||
containerName: string;
|
||||
hostWorkspacePath: string;
|
||||
containerWorkdir: string;
|
||||
network: DockerNetworkMode;
|
||||
networkName?: string;
|
||||
resources?: DockerResourceLimits;
|
||||
mountCredentials: boolean;
|
||||
hooksEnabled: boolean;
|
||||
resumeOnStart: boolean;
|
||||
daemonHost?: string;
|
||||
context?: string;
|
||||
commands?: Partial<Record<DockerCommandMode, string>>;
|
||||
extraCreateArgs?: string[];
|
||||
extraExecArgs?: string[];
|
||||
configHash?: string; // drift detection (Key decision, Section 4)
|
||||
}
|
||||
```
|
||||
|
||||
- `SessionState` gains `docker?: SessionDocker` immediately after `remote?` (line 219). It persists automatically because `SessionState` is structural and `state-store.ts` stores `toState()` verbatim.
|
||||
- `src/mux-interface.ts`: add `docker?: SessionDocker` to `MuxSession` (after line 38), `CreateSessionOptions` (after 81), `RespawnPaneOptions` (after 105). `MuxSession.docker` round-trips through `mux-sessions.json` automatically.
|
||||
- `src/types/api.ts` `CaseInfo`: add `'docker'` to the `location` union and a `docker?: { hostId; container; image?; path; network }` display block.
|
||||
- `src/services/unified-session-service.ts`: add a boolean `docker?` flag on `UnifiedSessionItem` and source rows, set from `MuxSession.docker` presence (mirror the `remote` flag at ~line 200 and the harvest at session-routes.ts:2313).
|
||||
|
||||
New state files (all via `dataPath()`, mirroring `remote-hosts.json` / `remote-cases.json`):
|
||||
|
||||
- `~/.codeman/docker-hosts.json` (reusable engine/image/network/resource profiles).
|
||||
- `~/.codeman/docker-cases.json` (`name -> DockerCase`, including `lastClaudeSessionId`).
|
||||
- `~/.codeman/docker-exports/` (dedicated dir for `.image.tar.gz` + `.workspace.tar.gz` + `manifest.json`; never inline in state.json; retention/pruning per Section 5).
|
||||
|
||||
No new `state.json` / `mux-sessions.json` files: `SessionState.docker` and `MuxSession.docker` ride the existing serialization.
|
||||
|
||||
## 4. Container lifecycle (exact command shapes)
|
||||
|
||||
All builders are PURE string functions (directly unit-testable). Host values interpolated into the outer `bash -c "..."` layer (container name, image, workdir, host paths) are `shellescape()`'d and, for user-supplied fields, schema-rejected for `$`/backtick via `NO_SHELL_META`. The escaping chain here is DEEPER than remote's single `ssh '<tmux ...>'`: the whole `docker inspect || docker create <dozens of --mount/--env/shellescaped host paths>` is interpolated into `bash -c "..."` then `JSON.stringify`'d into respawn-pane. This is a known place to get stuck, so it is covered by concrete escaping tests (Section 9), including host workspace paths containing spaces, not just a "we call shellescape" claim.
|
||||
|
||||
New in `src/tmux-manager.ts`:
|
||||
|
||||
```ts
|
||||
const DOCKER_TMUX_SOCKET = 'codeman-docker';
|
||||
// 'dkr' letters deliberately FAIL SAFE_MUX_NAME_PATTERN (^codeman-[a-f0-9-]+$),
|
||||
// so a Codeman running INSIDE the container never adopts/resizes/respawns our session.
|
||||
export function dockerTmuxSessionName(id: string): string { return `codeman-dkr-${id.slice(0, 8)}`; }
|
||||
```
|
||||
|
||||
`buildDockerBaseArgs(docker)` (pure, in `docker-hosts.ts`, mirror of `buildSshConnectionArgs`) emits the engine prefix tokens: `docker` (or `podman`) + optional `--context <ctx>` or `-H <daemonHost>`. `buildDockerCreateArgs(docker, sessionId)` emits the `docker create` flag array (with the per-engine uid/userns branch from Key decision 6).
|
||||
|
||||
IMAGE PRESENCE (before any create, the auto-pull footgun the critic caught): the launch chain runs `docker image inspect <image> >/dev/null 2>&1` first; on miss it exits with a distinct message ("base image <ref> not present: build with scripts/build-agent-image.mjs or pull it") rather than triggering a blocking multi-GB auto-pull inside the tmux pane. `docker create` carries `--pull=never`. The tmux-availability probe likewise uses `docker run --rm --pull=never <image> sh -lc 'command -v tmux'` and reports the same build/pull hint if the image is absent, so the 15s-bounded probe never hangs on a pull.
|
||||
|
||||
CREATE (the ensure step, embedded in the launch string):
|
||||
|
||||
```
|
||||
docker create \
|
||||
--name codeman-case-myproj --hostname myproj \
|
||||
--label codeman.managed=1 --label codeman.instance=<CODEMAN_INSTANCE> \
|
||||
--label codeman.case=myproj --label codeman.session=<id8> \
|
||||
--label codeman.confighash=<hash> \
|
||||
--pull=never --init --restart no \
|
||||
--user 1000:0 \
|
||||
--workdir '/home/arkon/cases/myproj' \
|
||||
--mount type=bind,src='/home/arkon/cases/myproj',dst='/home/arkon/cases/myproj' \
|
||||
--mount type=bind,src='/home/arkon/.claude',dst='/home/agent/.claude' \
|
||||
--mount type=bind,src='/home/arkon/.codeman/hook-secret',dst='/home/agent/.codeman/hook-secret',readonly \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--memory 4g --memory-swap 4g --cpus 2 --pids-limit 512 --ulimit nofile=4096:8192 \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
--network bridge \
|
||||
--env HOME=/home/agent --env TERM=xterm-256color --env COLORTERM=truecolor \
|
||||
--env CODEMAN_API_URL=https://host.docker.internal:3000 \
|
||||
--env CODEMAN_HOOK_SECRET_FILE=/home/agent/.codeman/hook-secret \
|
||||
codeman/agent:base \
|
||||
sleep infinity
|
||||
```
|
||||
|
||||
- `--user 1000:0` shown is the Linux-native form with GID 0 (Key decision 6); it is actually `--user <hostUid>:0`, or `--userns=keep-id` for podman rootless, or omitted on Docker Desktop. The literal is illustrative only.
|
||||
- Create-time `--env` carries only NON-SESSION, non-secret, case-stable values (safe to be committed): the DERIVED `CODEMAN_API_URL` (https-preserving, Key decision 5) and the hook-secret FILE PATH. `CODEMAN_SESSION_ID`/`CODEMAN_MUX` and the codex/gemini key NAMES are exec-time only.
|
||||
- `codeman.instance=<CODEMAN_INSTANCE>` is REQUIRED on the label set so the boot reaper is instance-scoped (a beta/second instance must never reap prod's containers).
|
||||
- `codeman.confighash` is a stable hash of the drift-relevant create args (image, resources, network, mounts, non-session env). Drift detection (user story 2, the config-never-takes-effect gap): on launch the ensure block compares the desired hash to the existing container's label; on mismatch the launch does NOT silently reuse the stale container. Instead the docker route returns a "container config changed, recreate?" action (SSE + UI confirm), and on confirm Codeman `docker rm`'s and recreates. rm destroys in-image (non-bind) state, but the workspace and transcripts survive on their bind mounts and the conversation is restored via `--resume`, so the recreate is safe. Auto-recreate-vs-prompt is a UI choice; the MVP prompts.
|
||||
- `--restart no` (resolved consistently with Key decision 1; recovery is Codeman's idempotent create-if-missing, not an engine restart policy, which also matters for Podman which has no daemon).
|
||||
|
||||
EXEC (`buildDockerLaunchCommand`, the docker analog of `buildRemoteLaunchCommand`, TTY-correct, resume-aware). The whole thing is ONE `bash -c` string that image-checks, ensures, starts, primes tmux env, then execs:
|
||||
|
||||
```
|
||||
docker image inspect codeman/agent:base >/dev/null 2>&1 || { echo 'Codeman: base image codeman/agent:base not present (build or pull it)'; exit 1; } ; \
|
||||
docker inspect codeman-case-myproj >/dev/null 2>&1 || docker create <all create args above> ; \
|
||||
docker start codeman-case-myproj >/dev/null 2>&1 || { echo 'Codeman: container codeman-case-myproj failed to start (daemon down?)'; exit 1; } ; \
|
||||
exec docker exec -it \
|
||||
--workdir '/home/arkon/cases/myproj' \
|
||||
--env TERM=xterm-256color --env COLORTERM=truecolor \
|
||||
--env CODEMAN_SESSION_ID=1a2b3c4d --env CODEMAN_MUX=1 \
|
||||
--env OPENAI_API_KEY --env GEMINI_API_KEY \
|
||||
codeman-case-myproj \
|
||||
sh -lc 'tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID 1a2b3c4d \; setenv -g CODEMAN_MUX 1 \; new-session -A -s codeman-dkr-1a2b3c4d -c '\''/home/arkon/cases/myproj'\'' '\''cd /home/arkon/cases/myproj && exec claude --dangerously-skip-permissions --resume <claudeSessionId>'\'' \; set -t codeman-dkr-1a2b3c4d status off \; set -t codeman-dkr-1a2b3c4d mouse off \; set -t codeman-dkr-1a2b3c4d prefix C-q \; set -s escape-time 0'
|
||||
```
|
||||
|
||||
- `docker exec -it`: `-t` allocates a PTY and forwards SIGWINCH into the container so the Ink TUI re-lays-out on pane resize; `TERM`/`COLORTERM` prevent degraded rendering. `--env OPENAI_API_KEY` (name only) is present only for codex/gemini and is exec-time (never committed). `CODEMAN_SESSION_ID`/`CODEMAN_MUX` are exec-time values plus a `tmux setenv -g` prime so reattaches and new panes inherit them (Key decision 5).
|
||||
- `--resume <claudeSessionId>` (codex `resume <id>`, gemini `--resume <id>`) is appended to `modeCommand` ONLY when a captured id exists; on first launch it is omitted. `new-session -A` makes the flag inert on a live-tmux reattach and effective only when tmux is re-created (Key decision 1).
|
||||
- `modeCommand = docker.commands?.[mode] || defaultDockerCommandForMode(mode)` (`exec claude --dangerously-skip-permissions`, `exec bash -l`, etc.), with the resume suffix injected by the builder.
|
||||
- Escaping survives every layer identically to remote in shape but deeper in nesting: `paneCommand` (`cd ... && exec ...`) is one shellescaped tmux arg, the whole `tmuxInvocation` is one shellescaped `sh -lc` arg, and the outer string is `JSON.stringify()`'d into `bash -c` by respawn-pane (tmux-manager.ts:1329).
|
||||
|
||||
Wire-up (extend the two existing seams to 3-way):
|
||||
|
||||
- createSession (tmux-manager.ts:1276): `const fullCmd = docker ? buildDockerLaunchCommand({ mode, docker, sessionId, resumeSessionId }) : remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;`
|
||||
- launchCmd cd-skip (tmux-manager.ts:1327): `const launchCmd = (remote || docker) ? fullCmd : \`cd ${JSON.stringify(workingDir)} && ${fullCmd}\`;`
|
||||
- respawnPane: same two edits at lines 1524 and 1542.
|
||||
|
||||
START / reattach-after-reboot: the ensure block (image-check, `docker inspect || docker create`, `docker start`) is fully idempotent, so boot recovery just re-runs `buildDockerLaunchCommand` from the restored `MuxSession.docker` with the persisted resume id. A rebooted host recreates the container and resumes the conversation.
|
||||
|
||||
DOCKER-DOWN surfacing (the PTY-exit-breaker false-trip risk): if `docker start` or `docker exec` cannot attach (daemon down, container missing), the launch prints a docker-specific message and exits, which alone would still count toward `session-pty-exit-breaker` and show a generic "respawn breaker tripped" push. To avoid masking the cause, the docker reattach path runs a fast `checkDockerAvailable` pre-flight: if the daemon/container is unreachable, Codeman broadcasts a docker-specific error (SSE + push, "container <name> is not running / daemon down") and SKIPS the auto-reattach that would trip the breaker, rather than fast-looping `docker exec`.
|
||||
|
||||
STOP / KILL (`killSession` Strategy 3c, right after remote's Strategy 3b at tmux-manager.ts:1719, guarded by `IS_TEST_MODE`):
|
||||
|
||||
```ts
|
||||
if (session.docker) {
|
||||
// best-effort, fire-and-forget, timeout-bounded so it never blocks the local kill
|
||||
execAsync(buildDockerKillCommand({ docker: session.docker, sessionId }), { timeout: EXEC_TIMEOUT_MS }).catch(() => {});
|
||||
}
|
||||
```
|
||||
|
||||
`buildDockerKillCommand` emits: `docker exec codeman-case-<slug> tmux -L codeman-docker kill-session -t codeman-dkr-<id8> ; docker stop -t 10 codeman-case-<slug>`. Stopping frees CPU/RAM and, per Key decision 1, is safe for conversation continuity because the NEXT launch resumes from the bind-mounted transcript via `--resume`. Whether to stop at all (RAM vs instant live-agent reattach) is user-decision 6/1 (reframed honestly). The bind-mounted workspace and transcripts always survive on the host.
|
||||
|
||||
REMOVE: only on explicit case delete (`docker rm -f codeman-case-<slug>`), gated behind an "export first?" UI prompt because rm destroys any in-image (non-bind) state. Instance-scoped boot reaper (fixing the racy/cross-instance reaper): after `docker-cases.json` is loaded AND after `restoreMuxSessions` has run, enumerate `docker ps -a --filter label=codeman.managed=1 --filter label=codeman.instance=<CODEMAN_INSTANCE> --format '{{.Names}}\t{{index .Labels "codeman.case"}}'` and `docker rm -f` only containers whose case is gone from THIS instance's `docker-cases.json`. The instance filter is what stops a beta reaping prod's containers (the exact cross-instance hazard the project memory warns about).
|
||||
|
||||
AVAILABILITY PROBE (`docker-hosts.ts`, timeout-bounded like `checkRemoteTmuxAvailable`'s 15s, `IS_TEST_MODE` no-op):
|
||||
|
||||
```
|
||||
docker info --format '{{json .}}' # server up, CgroupVersion, rootless, OS (Desktop detect), cap-delegation
|
||||
docker image inspect <image> --format '{{.Id}}' # image PRESENT (no auto-pull)
|
||||
docker run --rm --pull=never <image> sh -lc 'command -v tmux' # tmux-in-image gate (hard prerequisite), only if image present
|
||||
```
|
||||
|
||||
`checkDockerAvailable()` returns `{ ok, engine, rootless, isDesktop, cgroupV2, capsEnforced }` (parse `SecurityOptions` for `name=rootless`, `CgroupVersion`, delegation, and Server OS for Desktop). `checkDockerTmuxAvailable(host)` returns a structured result with a user-facing error and correct install hint (NOT `npm install -g`; the hint is "build/pull the base image" for a missing image and "install docker or podman" for a missing engine).
|
||||
|
||||
IN-CONTAINER CLI VERSION (fixing the #154 wheel-forwarding regression): the raw plan skipped the LOCAL `cliVersion` probe for docker (correct, since it reports the HOST claude) but left `cliVersion` undefined, which disables trackpad wheel-forwarding. Instead, for docker sessions Codeman runs an IN-CONTAINER probe `docker exec <container> claude --version` (bounded, `IS_TEST_MODE` no-op) and feeds THAT into `cliVersion`. This also means a stale baked CLI is visible; combined with the rebuild-cadence in user-decision 2, agents are not silently pinned to an old claude.
|
||||
|
||||
## 5. Export / Import
|
||||
|
||||
EXPORT is a concurrency-bounded job (reuse `runWithConversionLimit` from `document-conversion-limiter.ts` so N simultaneous exports cannot fork-bomb the host). Route `POST /api/docker-cases/:name/export`.
|
||||
|
||||
Preconditions (the consistency and leak risks the critic caught):
|
||||
- Sealed guard: if `mountCredentials:false`, full-image export is REFUSED unless the caller explicitly opts into the pre-commit scrub (Key decision 2). Workspace-only export is always allowed.
|
||||
- Quiesce + free-space: require the session idle, then `docker pause` the container spanning BOTH the workspace tar AND the commit so the two artifacts are mutually consistent (the raw plan paused only the commit, leaving the bind-mount tar to run against a mid-write agent). Before any heavy step, precheck free space in the exports dir and in `/var/lib/docker`; if below `DOCKER_EXPORT_MIN_FREE_BYTES`, refuse with a clear error (a full `/var/lib/docker` wedges the daemon and breaks EVERY session on the host).
|
||||
|
||||
Steps (all cleanup in try/finally so a mid-way failure never orphans an intermediate image or leaves the container paused):
|
||||
|
||||
1. `docker commit -c 'LABEL codeman.exported=1' codeman-case-<slug> codeman/export-<slug>:<ts>` (unique tag per export defeats the stale-image trap). Optional pre-commit scrub in sealed mode as above; also blank instance-specific committed env (`-c 'ENV CODEMAN_API_URL='` etc.) so the image carries no stale host references.
|
||||
2. `docker save codeman/export-<slug>:<ts> | gzip` streamed in fixed 8192-byte chunks to `~/.codeman/docker-exports/<slug>-<ts>.image.tar.gz`. Uses `docker save` (layers + repo:tag + CMD), never `docker export` (flat rootfs), so restore is a trivial `docker load`.
|
||||
3. `tar --numeric-owner -C <hostWorkspacePath> -czf <slug>-<ts>.workspace.tar.gz .` while paused (the bind-mounted workspace is NOT in the image, so it travels separately and consistently).
|
||||
4. Write `manifest.json`: schema version, caseName, image tag, engine, containerWorkdir, resource/network config, codeman version, base-image digest, createdAt, per-member sha256, `mountCredentials`, and `secretFree` (true only for convenient-mode or scrubbed-sealed exports).
|
||||
5. `docker rmi codeman/export-<slug>:<ts>` in the `finally` (delete the intermediate committed image regardless of success), then `docker unpause`.
|
||||
|
||||
The three files are wrapped in one bundle `<slug>-<ts>.codeman-container.tgz` and offered as a downloadable artifact through the existing file-routes streaming + attachment-registry handoff.
|
||||
|
||||
Retention / disk budget (user-decision 3): `docker-exports/` is capped at `DOCKER_EXPORT_KEEP` most-recent bundles with an auto-prune on each new export, plus the free-space precheck above. Workspace scrub: the WORKSPACE tar gets a scan/warn pass for agent-created `.env` / `.git/credentials` (a distinct leak channel from container creds). A lighter "workspace-only" export (just the workspace tar, no commit/save) is the fast default for 24h+ runs; full-image is the explicit heavier option (user-decision 7 in the original list, now decision on the default button below).
|
||||
|
||||
What travels: the baked toolchain image plus any in-image writes, and the workspace tar. What does NOT travel: bind-mounted credentials (physically excluded from commit) and anything that lived only in a bind mount. Secret-free by construction in convenient mode, and enforced (refuse-or-scrub) in sealed mode.
|
||||
|
||||
IMPORT `POST /api/docker-cases/import` (untrusted-bundle containment, the traversal/overwrite risk): stream the uploaded bundle, validate every manifest checksum BEFORE any extraction or load. Extract the workspace tar with `tar --no-absolute-names -C <fresh dir>` PLUS per-entry validation rejecting any member whose normalized path escapes the destination (leading `/` or `..` components). `gunzip | docker load` the image, then RE-TAG the loaded image id into a quarantined namespace `codeman/imported-<slug>:<ts>` and NEVER allow the load to overwrite `codeman/agent:base` or any pre-existing tag (capture the loaded id, ignore the bundle's repo:tag). Create a NEW `DockerCase` pointing at the quarantined image with THIS host's mounts/creds and the manifest's resource/network config, and recreate the container hardened (cap-drop ALL, no-new-privileges, non-root, `--pull=never`, CMD overridden to `sleep infinity`). The destination supplies its own login, so credentials never cross machines. Plus `GET /api/docker-exports` (list) and `DELETE /api/docker-exports/:filename`, all behind Codeman's existing auth / loopback-default / host-guard / Origin-CSRF stack.
|
||||
|
||||
## 6. Codeman integration (file-by-file, mirroring the remote-SSH feature)
|
||||
|
||||
- `src/types/session.ts`: add `DockerCommandMode`, `DockerEngine`, `DockerNetworkMode`, `DockerResourceLimits`, `DockerHost`, `DockerCase`, `SessionDocker` (Section 3). Add `docker?: SessionDocker` to `SessionState` after line 219. SessionMode (line 44) UNCHANGED.
|
||||
- `src/mux-interface.ts`: add `docker?: SessionDocker` to `MuxSession` (38), `CreateSessionOptions` (81), `RespawnPaneOptions` (105).
|
||||
- `src/docker-hosts.ts` (NEW, direct mirror of `src/remote-hosts.ts`): `readDockerHosts`/`writeDockerHosts`/`readDockerCases`/`writeDockerCases` (via `dataPath`, including `lastClaudeSessionId` read/write), `defaultDockerCommandForMode` (mirror line 60), `dockerDisplayPath` (`container:/path`, mirror `remoteDisplayPath` at 205), `toSessionDocker(host, case)` (mirror `toSessionRemote` at 212), `buildDockerBaseArgs`/`buildDockerCreateArgs` (per-engine uid/userns branch), `hostGatewayAlias(engine)`, `containerApiUrl(processApiUrl, engine)` (scheme+port-preserving, unit-tested), `checkDockerAvailable`/`checkDockerTmuxAvailable`/`probeDockerCliVersion` (15s-bounded, `IS_TEST_MODE` no-op), a config-hash helper for drift, its own POSIX `shellescape` copy (mirror line 83). `const IS_TEST_MODE = !!process.env.VITEST;` gates every real `docker` invocation.
|
||||
- `src/tmux-manager.ts`: add `DOCKER_TMUX_SOCKET`, `dockerTmuxSessionName`, `buildDockerLaunchCommand` (resume-aware, image-check, env-prime), `buildDockerKillCommand` (Section 4). Extend the two `fullCmd` ternaries (1276, 1524) and the two `launchCmd` cd-skips (1327, 1542). Add `killSession` Strategy 3c after 1719. Ensure `reconcileSessions` (~1800-1815) does NOT hard-delete docker sessions on local-tmux death (recovery relaunch path).
|
||||
- `src/session.ts`: add `_docker?: SessionDocker` field (mirror `_remote` at 403), constructor arg (477), assignment (550). Thread `docker: this._docker` and `resumeSessionId: this._claudeSessionId` into BOTH `createSessionOptions` and `respawnPaneOptions` in `startInteractive` (1352/1370) and the second path (1740/1750). Emit `docker: this._docker` in `toState()` (1010). Replace the LOCAL cliVersion probe at 1320 for docker with the IN-CONTAINER `probeDockerCliVersion` (do not merely skip it). Extend `resolveMuxAttachCwd(workingDir, remote, docker)` (215) to return `/tmp` when `docker` is set. On claudeSessionId capture, persist it to the owning `DockerCase.lastClaudeSessionId`.
|
||||
- `src/web/server.ts`: in `restoreMuxSessions` (2160), add `docker: muxSession.docker ?? savedState?.docker` to the `new Session({...})` call (2195-2216), and skip docker in the same `isExternalCliMode`/Ralph recovery guards as remote. Register the instance-scoped boot reaper to run AFTER docker-cases load and AFTER `restoreMuxSessions`. Ensure `CODEMAN_API_URL` derivation reads the SAME `process.env.CODEMAN_API_URL` the server sets at ~2000.
|
||||
- `src/web/schemas.ts`: add `DockerHostSchema` and `DockerCaseLinkSchema` (below). The three mode enums (177/373/705) and `QuickStartSchema` (368) UNCHANGED (docker resolves by `caseName` lookup like remote).
|
||||
- `src/web/routes/session-routes.ts`: import the docker helpers from `../../docker-hosts.js`. Add a docker branch in `/api/quick-start` parallel to the remote branch (1686-1720): `readDockerCases` -> find by `caseName` -> `readDockerHosts` -> find by `hostId`; reject `envOverrides`/`effort`/`codexConfig`/`geminiConfig`/`openCodeConfig` (but ACCEPT `modelOverride`, which flows via scaffolded `settings.local.json`); run `checkDockerAvailable` + `checkDockerTmuxAvailable` (image-present, engine, caps-enforced); surface `capsEnforced:false` and Desktop notes; set `casePath = dockerCase.hostWorkspacePath` (REAL host dir), `docker = toSessionDocker(host, dockerCase)`, and seed `resumeSessionId` from `dockerCase.lastClaudeSessionId` when `resumeOnStart`. Extend the LOCAL-availability and local-spawn guards (around 1796/1810) to `!remote && !docker`, but DO NOT extend the workspace-scaffolding guard (~1776, `writeHooksConfig`/`updateCaseModel`), which MUST run for docker. Pass `docker` into `new Session` (1847); `autoConfigureRalph` (1853) gated on `!docker`. Add `docker: m.docker !== undefined ? true : undefined` to the unified harvest (2313).
|
||||
- `src/web/routes/case-routes.ts`: import the docker read/write/check helpers + schemas. Add a docker listing loop in `GET /api/cases` (mirror 94-119, `location: 'docker'`, `docker: {...}` via `dockerDisplayPath`). Add `/api/docker-hosts` GET/POST/PUT/DELETE (mirror 168-204) and `POST /api/cases/docker-link` (mirror 206-232; run `checkDockerAvailable`/`checkDockerTmuxAvailable` at link time; broadcast `CaseLinked` with `type: 'docker'`). Add a docker-unlink branch to `DELETE /api/cases/:name` (mirror 288-296; `docker rm -f`; broadcast `CaseDeleted` `type: 'docker-unlinked'`). Add the docker branch to single-case `GET` (mirror 358-368). Add `POST /api/docker-cases/:name/export`, `/import`, `GET/DELETE /api/docker-exports`, and a `POST /api/docker-cases/:name/recreate` (drift confirm) per Sections 4 and 5.
|
||||
- `src/web/sse-events.ts` + `src/web/public/constants.js`: reuse `CaseLinked`/`CaseDeleted` for CRUD. Add `docker:exportProgress`, `docker:exportComplete`, `docker:importComplete`, `docker:configDrift`, and `docker:containerError` to BOTH registries (kept in sync per CLAUDE.md).
|
||||
- Frontend `src/web/public/index.html` (~1831): add a Docker `modal-tab-btn` next to Remote; add a `#case-docker` panel mirroring `#case-remote` with `dockerCaseName`, `dockerHostWorkspacePath`, `dockerContainer`, `dockerImage`, `dockerHostId`, and an Advanced `<details>` for network mode, resource caps, `mountCredentials`, `resumeOnStart`, and remote daemon. Surface a "scaffolds .claude into this host dir" note (user-decision 4) and a "resource caps advisory on this engine" warning when `capsEnforced:false`.
|
||||
- Frontend `src/web/public/session-ui.js`: `formatCasePickerLabel` (48) + `buildCasePickerOptions` (71-73) handle `location === 'docker'` (`name @ container`, add container/image to the search haystack); `resetCaseModalFields` (~1514) add a `dockerFields` array; `switchCaseModalTab` (1573/1580/1597) handle `'case-docker'`; `submitCaseModal` add the docker branch; new `linkDockerCase()` (mirror `linkRemoteCase` at 1689) POSTing `/api/docker-hosts` then `/api/cases/docker-link`, sending omitted optionals as `undefined` (spread `...(x ? {x} : {})`, never `null`, per the Zod `.optional()`-rejects-null gotcha); `runClaude` (520) / `runShell` (702) extend the `location === 'remote'` routing to also match `'docker'`; `runOpenCode`/`runCodex`/`runGemini` (792/846/900) make the `isRemote` checks `isRemoteOrDocker` so local status probes are skipped. In the session-options Summary tab, note that `effort` is inert for docker (rejected) while `model` IS honored via `settings.local.json`.
|
||||
- Frontend `src/web/public/panels-ui.js` (425-426): add `caseItem?.docker?.path`/`container` to the case-search fields.
|
||||
|
||||
Schemas (`src/web/schemas.ts`), mirroring `RemoteHostSchema` (299) / `RemoteCaseLinkSchema` (351):
|
||||
|
||||
```ts
|
||||
export const DockerHostSchema = z.object({
|
||||
id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
label: z.string().min(1).max(100),
|
||||
engine: z.enum(['docker', 'podman']).optional(),
|
||||
image: z.string().min(1).max(512).regex(/^[a-zA-Z0-9][\w./:@-]*$/, 'Invalid image ref').regex(NO_SHELL_META),
|
||||
daemonHost: z.string().max(512).regex(NO_SHELL_META, 'Invalid daemon host').optional(),
|
||||
context: z.string().max(128).regex(/^[a-zA-Z0-9._-]+$/, 'Invalid context').optional(),
|
||||
network: z.enum(['bridge', 'none', 'custom']).optional(),
|
||||
networkName: z.string().max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/).optional(),
|
||||
resources: z.object({
|
||||
memory: z.string().regex(/^\d+[bkmg]?$/i).optional(),
|
||||
cpus: z.string().regex(/^\d+(\.\d+)?$/).optional(),
|
||||
pidsLimit: z.number().int().positive().max(100000).optional(),
|
||||
nofile: z.string().regex(/^\d+:\d+$/).optional(),
|
||||
shmSize: z.string().regex(/^\d+[bkmg]?$/i).optional(),
|
||||
}).strict().optional(),
|
||||
mountCredentials: z.boolean().optional(),
|
||||
hooksEnabled: z.boolean().optional(),
|
||||
resumeOnStart: z.boolean().optional(),
|
||||
commands: RemoteCommandOverridesSchema, // reuse the shared shape
|
||||
extraCreateArgs: z.array(z.string().min(1).max(1024).regex(NO_SHELL_INJECTION).refine(noCommandSubstitution)).max(32).optional(),
|
||||
extraExecArgs: z.array(z.string().min(1).max(1024).regex(NO_SHELL_INJECTION).refine(noCommandSubstitution)).max(32).optional(),
|
||||
});
|
||||
|
||||
export const DockerCaseLinkSchema = z.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
hostWorkspacePath: z.string().min(1).max(2000).regex(/^\//, 'Path must be absolute').regex(NO_SHELL_META, 'Invalid characters in workspace path'),
|
||||
containerWorkdir: z.string().min(1).max(2000).regex(/^\//).regex(NO_SHELL_META).optional(),
|
||||
container: z.string().min(2).max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name').optional(),
|
||||
});
|
||||
```
|
||||
|
||||
`NO_SHELL_META` (rejects `$`/backtick, schemas.ts:297) is REQUIRED on `image`, `hostWorkspacePath`, `containerWorkdir`, and `container`, because all four reach the outer `bash -c "..."` double-quote layer where `$(...)`/backtick re-expose, exactly the reason `remotePath`/`identityFile` use it. `--privileged` and any `-v /var/run/docker.sock` are structurally unrepresentable (never emitted by the builder, never accepted by the schema).
|
||||
|
||||
## 7. Security model
|
||||
|
||||
- Hardening flags on every create: `--cap-drop ALL`, `--security-opt no-new-privileges` (NOT auto-set by rootless Docker or Podman, so always explicit), the uid/userns branch of Key decision 6 (never container-root; workspace files stay host-owned and HOME stays writable via GID 0), `--pids-limit` (fork-bomb guard), `--memory` with `--memory-swap == --memory` (real OOM cap), `--ulimit nofile`, `--init`, `--pull=never`. NEVER `--privileged`, NEVER mount the docker socket into the agent container. `--storage-opt size=` is emitted ONLY after the probe confirms overlay2-on-xfs-pquota or btrfs (the AICE-class silently-ignored trap); otherwise it is omitted and the UI does not advertise a size cap. Resource caps are advertised as ENFORCED only when the probe reports `capsEnforced:true`; under non-delegated rootless they are labeled advisory (user-decision 6).
|
||||
- Engine: prefer whichever the probe finds, Podman-rootless first for security (a container-root breakout lands as an unprivileged host user). Rootless bind-mount ownership uses `--userns=keep-id` (Podman) vs `--user <hostUid>:0` (Docker), so real per-engine branching lives in `buildDockerCreateArgs`. Docker Desktop takes its own uid path (Key decision 6).
|
||||
- Blast radius (the combined-posture the critic asked to surface, user-decision 5): the default convenient profile mounts an arbitrary host workspace dir RW (host-owned, mirrored path) AND host `~/.claude`/`~/.codex`/`~/.gemini`/`~/.config/gcloud`/`~/.config/opencode` RW into a NETWORK-ENABLED container. Container-run agent code can therefore read/modify those host trees and reach the network simultaneously. This is still a strict improvement over today's on-host skip-permissions execution, but the user must accept the combined posture explicitly; the sealed profile plus `network:none` is the mitigation for genuinely untrusted work.
|
||||
- Secret handling: creds arrive ONLY as bind-mounted files (default) or exec-time NAME-ONLY `--env` (codex/gemini keys), NEVER as create-time `-e` and NEVER as an image layer. Sealed-mode export is refuse-or-scrub (Section 5), closing the sealed-leak inversion.
|
||||
- CLAUDE.md "Multi-CLI prefix discipline": the exec-time name-only env is restricted to the CLI-specific keys per mode (Claude: none with OAuth mount; Codex: `OPENAI_API_KEY`/`CODEX_API_KEY`; Gemini: `GEMINI_API_KEY`/`GOOGLE_*`), never a blanket forward. `envOverrides` is rejected for docker, so the `ALLOWED_ENV_PREFIXES` allowlist is not widened.
|
||||
- hook-secret: bind-mounted read-only, referenced via `CODEMAN_HOOK_SECRET_FILE` (a path, non-secret); the secret bytes never enter env or the image. Both `host.docker.internal` and `host.containers.internal` are added to the host-guard allowlist so the in-container hook curl's Host header passes on either engine.
|
||||
- Host guard / instance isolation: the in-container tmux socket (`codeman-docker`) and name (`codeman-dkr-<id8>`) deliberately FAIL a container-internal Codeman's `SAFE_MUX_NAME_PATTERN`, so a nested Codeman never adopts our session (unit-asserted). The boot reaper is instance-scoped by the `codeman.instance` label so a beta never reaps prod. Any remote-daemon (`-H`/`--context`) mode is host-root-equivalent and stays strictly behind the existing auth/loopback/host-guard/Origin-CSRF stack.
|
||||
- Import containment: untrusted bundles are checksum-validated, extracted with traversal guards, and loaded into a quarantined image namespace (never overwriting the base image), then run with the same hardening.
|
||||
|
||||
## 8. Phased implementation (branch: `feat/docker-session-mode`)
|
||||
|
||||
Each phase is independently testable; per CLAUDE.md, end-to-end test in the real env before COM. All new docker IO paths carry `const IS_TEST_MODE = !!process.env.VITEST;` and no-op under it; the pure command builders are tested directly.
|
||||
|
||||
- Phase 0: base image + engine probe. Author `docker/agent.Dockerfile` (OpenShift arbitrary-uid HOME) and `scripts/build-agent-image.mjs` (build or pull the base image; digest recorded). Add `checkDockerAvailable`/`checkDockerTmuxAvailable`/`containerApiUrl`/`hostGatewayAlias` (IS_TEST_MODE no-op) and `GET /api/docker/status`. Test: probe stub returns available/caps/Desktop flags under VITEST; `containerApiUrl` preserves scheme+port and swaps host per engine; status route returns the envelope.
|
||||
- Phase 1: types + storage + schemas. Add all types (Section 3), `src/docker-hosts.ts`, `DockerHostSchema`/`DockerCaseLinkSchema`. Test: `docker-hosts.test.ts` (round-trip incl. `lastClaudeSessionId`, display path, config-hash stability); `docker-exec-options.test.ts` (schema rejects `$`/backtick in image/workdir/container).
|
||||
- Phase 2: tmux-manager builders. Add `DOCKER_TMUX_SOCKET`, `dockerTmuxSessionName`, `buildDockerLaunchCommand` (resume-aware, image-check, env-prime), `buildDockerKillCommand`; wire the two ternaries + two cd-skips + Strategy 3c; harden `reconcileSessions` against docker hard-delete. Test (pure strings): adopt-proof name fails `SAFE_MUX_NAME_PATTERN`; image-check precedes create; `new-session -A` idempotent; resume flag present only when a resume id is passed; `--pull=never` present; instance label present; escaping survives `bash -c` -> `docker exec` -> `sh -lc` -> tmux WITH a host workspace path containing spaces.
|
||||
- Phase 3: session.ts + mux + recovery. Add `_docker` + `resumeSessionId` threading, in-container cliVersion probe, `resolveMuxAttachCwd`, mux-interface fields, `restoreMuxSessions` passthrough, instance-scoped reaper wiring, claudeSessionId -> `DockerCase.lastClaudeSessionId` persistence, unified flag. Test: `toState()` emits docker; a persisted docker session round-trips through mux/state; a relaunch injects the persisted resume id (mock mux); reaper only targets this instance's orphaned containers.
|
||||
- Phase 4: routes + first real e2e. case-routes CRUD + listing + drift-recreate; session-routes quick-start branch (scaffolding RUNS, local-availability guards skip, model accepted, effort/config rejected). Manual e2e on a real docker host: docker-host create -> docker-link -> quick-start; confirm the pane runs `claude` in the container, files land host-owned, a Codeman restart reattaches the SAME live agent, and a `docker stop` followed by relaunch RESUMES the conversation.
|
||||
- Phase 5: hooks connectivity + installation. host-gateway (per engine), derived `CODEMAN_API_URL`, hook-secret mount, `CODEMAN_SESSION_ID`/`CODEMAN_MUX` exec-env + tmux setenv, host-guard allowlist, and the scaffolding write into the real workspace. Manual e2e: trigger a permission prompt from inside the container and confirm it surfaces; verify hook payloads carry the right session id. If deferred, ship docker as explicitly hook-degraded and verify output-based idle detection through the docker-exec PTY.
|
||||
- Phase 6: export/import + GC + disk safety. quiesce+pause span, free-space precheck, commit+save+gzip + workspace tar + manifest + streaming download; sealed-mode refuse-or-scrub; retention/auto-prune; import with checksum validation + traversal guard + quarantined re-tag; drift-recreate; boot reaper; `runWithConversionLimit` cap; `docker rmi` in finally. Manual e2e: export, `docker load` on a second machine (or fresh case), import, confirm toolchain + workspace restored and NO creds present; attempt a sealed full-image export and confirm it is refused-or-scrubbed; attempt a `../` bundle and confirm it is rejected.
|
||||
- Phase 7: frontend. Docker tab, `linkDockerCase`, run wiring, case-picker labels, panels search, caps-advisory + scaffold-warning + effort-inert notes. Verify with Playwright (`waitUntil: 'domcontentloaded'`, 3-4s settle) that the Docker tab renders and a linked docker case appears in the picker.
|
||||
- Phase 8: docs + COM. Update CLAUDE.md (a "Docker cases" Key Pattern paragraph mirroring remote-SSH, plus the new state files, routes counts, and the resume/durability model), `docs/docker-cases.md`, then COM per the standard flow.
|
||||
|
||||
## 9. Test plan
|
||||
|
||||
- Unit (pure, CI-safe, mirror `test/remote-hosts.test.ts` / `test/remote-ssh-options.test.ts`):
|
||||
- `test/docker-hosts.test.ts`: storage round-trip (incl. `lastClaudeSessionId`), `dockerDisplayPath`, `defaultDockerCommandForMode`, `toSessionDocker`, `containerApiUrl` (http/https, custom port, docker vs podman gateway), config-hash stability/drift, `buildDockerCreateArgs` flag ordering (cap-drop/no-new-privileges/memory==memory-swap/instance-label/`--pull=never` present; host/privileged/socket absent; per-engine uid vs `--userns=keep-id`).
|
||||
- `test/docker-exec-options.test.ts`: `buildDockerLaunchCommand`/`buildDockerKillCommand` string shape and escaping through `bash -c` -> `docker exec` -> `sh -lc` -> tmux, including a workspace path with spaces; resume flag present only with a resume id; image-presence check precedes create; `dockerTmuxSessionName` fails `SAFE_MUX_NAME_PATTERN`; schema rejects `$`/backtick in image/workdir/container/name; `linkDockerCase`-shaped bodies with omitted optionals validate (no `null` on the wire).
|
||||
- Probe no-op: `checkDockerAvailable`/`checkDockerTmuxAvailable`/`probeDockerCliVersion` return canned values under VITEST and never spawn.
|
||||
- Integration (route tests via `app.inject()`, docker no-op'd): `/api/docker-hosts` CRUD; `/api/cases/docker-link` dup-check + broadcast; `GET /api/cases` includes the docker case with `location: 'docker'`; `/api/quick-start` docker branch rejects `envOverrides`/`effort`/config but ACCEPTS `modelOverride`, runs the workspace-scaffolding path, and constructs a session with `docker` set + seeded resume id; `DELETE /api/cases/:name` docker-unlink; export refuse-or-scrub for sealed; import traversal rejection; reaper instance-scoping (label filter). Pick a unique port only if a live-server test is added (search `const PORT =`; 3150+).
|
||||
- Manual end-to-end (real docker daemon, the mandatory "always end-to-end test" gate): build the base image; link a docker case; quick-start `claude`; verify OAuth via the mounted `~/.claude`, transcript correlation (subagent/workflow watchers show the session), host-owned files, and a working permission-prompt hook; reattach after a Codeman PROCESS restart (SAME live agent); `docker stop` then relaunch and confirm conversation RESUME; reboot-equivalent (daemon restart) and confirm boot recovery recreates+resumes; change the host's memory/image and confirm the drift-recreate prompt fires; export (convenient) and confirm the tar `docker load`s with no creds; attempt a sealed full-image export and confirm refuse-or-scrub; import into a fresh case; delete the case and confirm `docker rm -f` plus instance-scoped reaper GC; confirm a docker-down state surfaces a docker-specific error and does NOT trip the generic PTY-exit breaker.
|
||||
|
||||
## 10. Open decisions for the user
|
||||
|
||||
1. Credential + blast-radius posture (combined). Convenient default bind-mounts host `~/.claude` etc. RW AND an arbitrary host workspace RW into a network-enabled container, so container-run agent code can read/modify those host trees and reach the network at the same time. Recommended: convenient default plus a per-host SEALED opt-in (`mountCredentials:false` + `network:none`) for untrusted work. Please confirm you accept the combined arbitrary-workspace-plus-egress-plus-host-creds posture for the default profile (it is still a net improvement over today's on-host skip-permissions execution).
|
||||
2. Base image ownership, registry, and freshness. The `codeman/agent:base` placeholder implies a Docker Hub org the project may not own. Pick the real registry/namespace (GHCR under the repo is the natural fit), decide digest pinning, and set a REBUILD CADENCE so agents are not stuck on a stale baked `claude` (the in-container version probe surfaces staleness, but something must trigger rebuilds). Choose: pull a pinned published image, build locally on first use via `scripts/build-agent-image.mjs`, or both.
|
||||
3. Container CWD strategy. Mirror the host workspace path inside the container (recommended: makes transcript projHash correlate, file features and resume capture work) vs a fixed `/workspace` (simpler mount, breaks watcher correlation). Please confirm the mirror approach.
|
||||
4. Hooks in the MVP AND workspace scaffolding. Making docker hooks fire requires WRITING `.claude/settings.local.json` (and the CLAUDE.md scaffold) into the user's REAL linked host directory, a behavioral shift from "link a dir" to "link and scaffold a dir." Choose: wire hooks + scaffolding now (Phase 5, recommended, and it also enables the model picker), or ship docker as explicitly hook-degraded (no permission prompts / hook-idle) for v1 and add later. Confirm you are OK with Codeman mutating the linked host workspace.
|
||||
5. Session-kill teardown and RESUME (reframed honestly). `docker stop` on session kill is not merely "free RAM vs instant reattach": it destroys the in-container live agent, and the conversation survives ONLY because the next launch runs `--resume` from the bind-mounted transcript. Choose: keep the container running (costs RAM, preserves the exact live in-flight agent) vs stop and rely on `--resume` (frees RAM, may lose uncommitted in-flight tool state). Case-delete always `docker rm -f`.
|
||||
6. Rootless enforcement posture. Under rootless without cgroup-v2 systemd delegation, `--memory`/`--cpus`/`--pids-limit` are SILENTLY ignored. Choose: REQUIRE delegation (refuse to link a host that cannot enforce caps) or ship-with-warning ("resource caps are advisory on your engine"). The probe reports `capsEnforced` either way.
|
||||
7. Default resume behavior. Should a re-linked or re-run docker case default to resuming its last conversation (`resumeOnStart:true`, using `DockerCase.lastClaudeSessionId`) rather than starting clean? This is the crux of making the durability story real and is the recommended default, but it changes user-visible behavior (a new session in an existing case continues the prior conversation).
|
||||
8. Export defaults and disk budget. Default export button: workspace-only (fast, small, files-only, recommended for 24h+ runs) vs full-image (reproducible env, multi-GB). Also set the retention cap (max retained exports), the auto-prune policy, and the free-space threshold below which export is refused (a full `/var/lib/docker` breaks EVERY session on the host, not just docker ones).
|
||||
9. Remote docker daemon (`-H ssh://...` / `--context`). Support in the MVP (composes with remote hosts, adds host-root trust surface) or local-daemon-only first.
|
||||
10. Podman parity depth. Full `--userns=keep-id` plus Quadlet boot-persistence, or Docker-first with Podman as best-effort and boot-persistence via Codeman's idempotent create-if-missing only. Note the podman host alias is `host.containers.internal`, already handled per engine.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Docker cases
|
||||
|
||||
Run a case inside an **isolated Docker container** instead of directly on the host. Any number of Codeman sessions can share one container (it is scoped to the case, not the session), so a whole project lives in a sandbox with its own network, resource caps, and filesystem, and you can **export the container to move it to another machine**.
|
||||
|
||||
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` all work inside the container.
|
||||
|
||||
## One-time setup: build the base image
|
||||
|
||||
The container needs a base image with the agent toolchain (node, the CLIs, git, tmux). Build it locally once:
|
||||
|
||||
```bash
|
||||
node scripts/build-agent-image.mjs # builds codeman/agent:base
|
||||
# options: --engine docker|podman --image <ref> --no-cache
|
||||
```
|
||||
|
||||
The image is **secret-free**: credentials are delivered at runtime (bind mounts or `docker exec --env`), never baked in, so exports never leak them.
|
||||
|
||||
## Quickest path: one-click "Run in Docker"
|
||||
|
||||
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
|
||||
|
||||
Click the checkbox's **Container settings** to optionally tweak the predefined defaults, including a **Template** picker:
|
||||
|
||||
| Template | Memory | CPUs | GPUs |
|
||||
|----------|--------|------|------|
|
||||
| Small | 2 GB | 1 | none |
|
||||
| Medium (default) | 4 GB | 2 | none |
|
||||
| Large | 8 GB | 4 | none |
|
||||
| GPU | 8 GB | 4 | all (needs the NVIDIA container toolkit) |
|
||||
|
||||
**Disk is elastic** — the container's storage grows automatically as data flows in; there is no fixed cap (bounded only by host disk). Any tweaked setting creates a dedicated per-case host so it never changes the shared `default`.
|
||||
|
||||
## Create a docker case (full control)
|
||||
|
||||
App → **New case → Docker** tab:
|
||||
|
||||
- **Case Name** / **Workspace Path**: the workspace is a real HOST directory bind-mounted into the container at the same path. Codeman scaffolds `CLAUDE.md` + `.claude/settings.local.json` (hooks) into it, and file previews / attachments work on the real bytes.
|
||||
- **Host ID**: a reusable docker host profile (image, network, resources). Reuse the same ID across cases to share settings.
|
||||
- **Network**: `bridge` (internet on, default), `none` (fully isolated), or a `custom` bridge.
|
||||
- **Advanced**: memory / CPU caps, **Mount host credentials** (on = your existing `~/.claude` login just works; off = a sealed sandbox you log into inside the container), **Resume last conversation on relaunch**.
|
||||
|
||||
Then run it like any case (Run Claude / Run Shell / …). The first launch creates the container (`codeman-case-<name>`); subsequent sessions attach to the same one.
|
||||
|
||||
Equivalent API:
|
||||
|
||||
```bash
|
||||
curl -X POST localhost:3000/api/docker-hosts -d '{"id":"local","label":"Local","image":"codeman/agent:base"}'
|
||||
curl -X POST localhost:3000/api/cases/docker-link -d '{"name":"sandbox","hostId":"local","hostWorkspacePath":"/home/you/projects/sandbox"}'
|
||||
curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"claude"}'
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives).
|
||||
- **Container stop / host reboot** recreates the container and, when a resume id was captured, **resumes** the last conversation from the bind-mounted transcript.
|
||||
- **Killing one session** only kills that session's in-container tmux session; the shared container stays up for sibling sessions.
|
||||
- **Deleting the case** `docker rm -f`s the container (the bind-mounted workspace on the host survives). An instance-scoped boot reaper removes containers whose case is gone.
|
||||
|
||||
## Isolation & security
|
||||
|
||||
Every container runs hardened: `--cap-drop ALL`, `--security-opt no-new-privileges`, non-root (`--user <hostUid>:0` so workspace files stay host-owned), `--pids-limit`, `--memory` == `--memory-swap`, `--init`. Never `--privileged`, never the docker socket. The default **convenient** profile bind-mounts host credential dirs read-write so the common login just works (creds stay on the host, never captured by `docker commit`); the **sealed** profile (`mountCredentials:false` + `network:none`) is the opt-in for genuinely untrusted work.
|
||||
|
||||
Rootless engines without cgroup-v2 systemd delegation cannot enforce resource caps; linking such a host warns that caps are advisory.
|
||||
|
||||
## Export / Import (move to another machine)
|
||||
|
||||
**Export** (from the Docker tab, or `POST /api/docker-cases/:name/export`): choose
|
||||
|
||||
- **Full image + workspace**: `docker commit` the container to an image, `docker save` it, tar the workspace, and a manifest, all into one portable `<case>-<ts>.codeman-container.tgz` (the whole toolchain, installed packages, and files). Runs in the background; you are notified when the bundle is ready.
|
||||
- **Workspace only**: just the project files (fast, small).
|
||||
|
||||
The container is paused across the capture so the image and workspace are consistent; a full `/var/lib/docker` is guarded against with a free-space precheck; the intermediate image is always cleaned up.
|
||||
|
||||
**Import** (`POST /api/docker-cases/import`, or the Manage tab): copy the `.tgz` onto the new machine's `~/.codeman/docker-exports/`, then import it into a new case. The manifest and per-member SHA-256 checksums are validated, the workspace tar is extracted with a path-traversal guard, and the image is `docker load`ed and **re-tagged into a quarantined namespace** (`codeman/imported-<case>:<ts>`) so it never overwrites a local tag. The destination supplies its own credentials, so nothing secret crosses machines.
|
||||
|
||||
`GET /api/docker-exports` lists bundles; `GET /api/docker-exports/:filename` downloads one; `DELETE` removes one.
|
||||
|
||||
## Hooks require the server to be reachable from the container
|
||||
|
||||
In-container hooks (permission events, hook-based idle/stop/task notifications) POST to `CODEMAN_API_URL`, which is derived as `https://host.docker.internal:<port>` (`host.docker.internal` → the docker bridge gateway, e.g. `172.17.0.1`, via `--add-host …:host-gateway`). For that callback to succeed, the Codeman server must be **listening on an interface the container can reach**.
|
||||
|
||||
- If Codeman binds **loopback-only** (`127.0.0.1`, the default and the production systemd config), a container reaching `172.17.0.1:<port>` cannot connect, so by default **in-container hooks do not fire**. The session still works fully: idle/stop detection falls back to **output-based** detection through the `docker exec` PTY (which always works), and claude runs with `--dangerously-skip-permissions` so there are no permission prompts to forward anyway.
|
||||
- **To enable in-container hooks on a loopback-only server, set `CODEMAN_DOCKER_BRIDGE_HOOKS=1`** (env). Codeman then starts a SECOND listener bound to the docker bridge gateway (`172.17.0.1`, auto-detected; override with `CODEMAN_DOCKER_BRIDGE_HOST`) that serves **only the hook endpoints** (`/api/hook-event`, `/api/status-telemetry`) and delegates them into the same secret-gated pipeline. The bridge is host-internal (containers + host, not the LAN), and every other path returns `403`, so this does not widen your network exposure. Add `Environment=CODEMAN_DOCKER_BRIDGE_HOOKS=1` to the systemd unit and restart.
|
||||
- Alternatively, bind `0.0.0.0` **with `CODEMAN_PASSWORD` set** (exposes on the LAN too).
|
||||
|
||||
The host-gateway mapping, `CODEMAN_API_URL` derivation, host-guard allowlist, and hook-secret mount are all wired correctly; `CODEMAN_DOCKER_BRIDGE_HOOKS` closes the last gap for loopback-only servers.
|
||||
|
||||
## Notes & limits
|
||||
|
||||
- Requires Docker (or Podman) with a reachable daemon; tmux must be present in the base image (a hard prerequisite, probed at link time).
|
||||
- Per-session `envOverrides` / `effort` / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead.
|
||||
- macOS Docker Desktop takes a dedicated uid path (the baked image uid; memory caps are subject to the VM ceiling).
|
||||
|
||||
Design + rationale: [`docker-cases-plan.md`](./docker-cases-plan.md).
|
||||
@@ -0,0 +1,282 @@
|
||||
# Multi-User Mode: Design Plan
|
||||
|
||||
Status: **IMPLEMENTED on `feat/multiuser-mode`** (phases 1-5; opt-in, off by default). Target: opt-in multi-user support behind a `--multiuser` flag, with per-user case spaces and an admin panel for user management.
|
||||
|
||||
Shipped by phase:
|
||||
|
||||
- **Phase 1** (user store + mode plumbing + CLI): `src/user-store.ts` (scrypt, atomic 0600 writes, last-admin invariants, serialized read-modify-write), `src/config/multiuser.ts`, `codeman users add|passwd|list|rm`, `--multiuser` flag, bootstrap-on-first-boot. Tests: `test/user-store.test.ts`.
|
||||
- **Phase 2** (multi-user auth): parallel async auth branch (`src/web/middleware/auth.ts`), `req.authUser`, per-username rate bucket, `mustChangePassword` lockbox, `GET /api/me` + `POST /api/me/password`, QR identity-bound minting, network-bind + tunnel exemptions, new error codes. Tests: `test/multiuser-auth.test.ts`.
|
||||
- **Phase 3** (ownership threading): `Session.owner` at every create path + recovery mirror; `findSessionOrFail` owner check + list filtering; §6.3 permission policy (`resolveClaudeModeForUser` at all spawn sites incl. one-shots via `buildPromptArgs`; shell/launchCommand grant); per-user case spaces (`resolveCasesDir`) + owner-scoped case list + admin-only host CRUD; `workingDir` confinement; `sessionCapacityState` per-user cap. Tests: `test/ownership-scoping.test.ts`.
|
||||
- **Phase 4** (event fan-out): WS owner gate; SSE per-client identity + `broadcast`/terminal-batch routing (`deriveSseHint`, fail-closed); `getLightState` per-identity filtering; file-route preview/thumbnail/history + `GET /api/search` scoping.
|
||||
- **Phase 5** (admin API + frontend): `src/web/routes/admin-routes.ts` (user CRUD, one-time passwords, last-admin guards, session revoke/kill) + `src/web/admin-audit.ts`; `public/admin-ui.js` (identity boot, change-password modal + interceptor, admin Users tab). Tests: `test/admin-routes.test.ts`, `test/admin-ui.test.ts`.
|
||||
|
||||
Deferred follow-ups (documented, non-blocking): away-digest + subagent/workflow REST-list scoping, push-subscription identity/routing, per-user screenshot subdirs, `linked-cases.json` v2 owner field, `ScheduledRun.owner`, plan-orchestrator internal one-shot mode resolution, and a Playwright browser pass. Phase 6 (login form replacing Basic) remains out of scope.
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Today Codeman is strictly single-user: one optional credential pair (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`), one shared `~/codeman-cases` folder, one global session list, and a global SSE/WS fan-out. This plan adds an opt-in **multi-user mode**:
|
||||
|
||||
- **Off by default.** Without the flag, behavior stays byte-identical to today (same auth path, same paths, same payloads). All new code is gated behind `isMultiUserMode()`.
|
||||
- **`codeman web --multiuser`** (or `CODEMAN_MULTIUSER=1`) enables named users with individually hashed passwords stored in `~/.codeman/users.json`.
|
||||
- **Each user gets their own space**: `~/codeman-users/<username>/cases/<case>` replaces the shared `~/codeman-cases` for that user. Sessions, cases, attachments, search, digests, and SSE events are scoped to their owner.
|
||||
- **Admin panel** (App Settings, admin-only "Users" tab): create/delete users, change/reset passwords, enable/disable accounts, delete a user's space, see per-user live sessions and disk usage, force logout.
|
||||
|
||||
## 2. Threat Model (read first, be honest about this)
|
||||
|
||||
Multi-user mode is **workspace separation for a trusted team, NOT security isolation between mutually distrusting users**:
|
||||
|
||||
- Every session still runs as the **same OS account** with `claude --dangerously-skip-permissions`. Any user can ask their agent to `cat /home/<host>/codeman-users/otheruser/...`. The web layer enforces scoping; the agent layer cannot.
|
||||
- **Shell sessions and custom launch commands are the bluntest holes**: `SessionMode = 'shell'` hands out a raw shell as the host account, and a cron job's `launchCommand` runs an arbitrary command; no Claude permission classifier is involved in either. These must be gated behind the same grant as bypass (section 6.3), otherwise the `auto`-mode mitigation below is theater.
|
||||
- All sessions share one tmux socket (`-L codeman`), one `~/.claude` (transcripts, credentials, plan usage), one Claude subscription.
|
||||
- Mitigation for stronger isolation: pair a user's cases with **Docker cases** (container per case, `docs/docker-cases.md`), or run separate Codeman instances per user (`CODEMAN_INSTANCE`, separate OS accounts). True per-user OS isolation is explicitly **out of scope** for this feature.
|
||||
- Partial mitigation at the agent layer: non-admin users default to Claude's `auto` permission mode (section 6.3), whose safety classifier blocks destructive actions and credential exfiltration. That reduces, but does not eliminate, cross-user snooping; the `canBypassPermissions` grant reopens it and should be given deliberately.
|
||||
|
||||
This must be stated loudly in `docs/security-architecture.md`, the README section, and the admin panel UI ("Users share the host account; this separates workspaces, it does not sandbox users from each other").
|
||||
|
||||
Also note the flip side: multi-user mode strictly _improves_ today's network posture, because it removes the single shared password and gives every person their own revocable credential.
|
||||
|
||||
## 3. Activation and Mode Rules
|
||||
|
||||
| Condition | Behavior |
|
||||
| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| No flag (default) | Exactly today's behavior. `users.json` is never read. Single-user auth via `CODEMAN_PASSWORD` if set. |
|
||||
| `--multiuser` / `CODEMAN_MULTIUSER=1`, `users.json` has users | Multi-user auth active. `CODEMAN_PASSWORD` is ignored for login (warn if set). |
|
||||
| `--multiuser`, no `users.json` (first boot) | Bootstrap: if `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` are set, create that user as the initial admin and continue. Otherwise refuse to start with instructions to run `codeman users add <name> --admin`. Never start multi-user with zero users (there would be no way in). |
|
||||
| `--multiuser` on a non-loopback bind | Allowed without `CODEMAN_PASSWORD`: `server.ts start()` treats "multi-user with >= 1 enabled user" as satisfying the auth requirement in the loud-warning check (wire into the existing `isLoopbackBindHost()` branch). |
|
||||
| Flag later removed | Single-user mode again. Sessions/state that carry `owner` fields keep working (owner is simply ignored); user spaces remain on disk untouched. |
|
||||
|
||||
Plumbing: flag in `src/cli.ts` (web command), env in a new `src/config/multiuser.ts` exporting `isMultiUserMode()`. Per-instance like everything else: a beta instance (`CODEMAN_INSTANCE=beta`) has its own `users.json` via `dataPath()`.
|
||||
|
||||
## 4. Data Model and Disk Layout
|
||||
|
||||
### 4.1 `~/.codeman/users.json` (via `dataPath('users.json')`, mode 0600, atomic write: tmp + rename)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"users": [
|
||||
{
|
||||
"username": "alice", // canonical lowercase slug
|
||||
"role": "admin", // "admin" | "user"
|
||||
"password": {
|
||||
"algo": "scrypt", // node:crypto scrypt, no new deps
|
||||
"N": 16384,
|
||||
"r": 8,
|
||||
"p": 1,
|
||||
"salt": "<hex 32B>",
|
||||
"hash": "<hex 64B>",
|
||||
},
|
||||
"disabled": false,
|
||||
"mustChangePassword": false, // set by admin reset; gates all API access until changed
|
||||
"canBypassPermissions": false, // permission-mode grant, see section 6.3; false for new users
|
||||
"createdAt": 1752900000000,
|
||||
"lastLoginAt": 1752900000000,
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
- **Username rules**: `^[a-z0-9][a-z0-9_-]{1,31}$` (it becomes a folder name), stored lowercase, unique case-insensitively. Reserve `admin`? No: any name can be admin; role is a field, not a name.
|
||||
- **Hashing**: `scrypt` from `node:crypto` with per-user salt, compared via `timingSafeEqual`. Params stored per record so they can be raised later; verify tolerates old params and rehashes on next successful login.
|
||||
- New module `src/user-store.ts` (mirrors the `remote-hosts.ts` / `docker-hosts.ts` pattern): `readUsers()`, `writeUsers()`, `verifyPassword()`, `createUser()`, `setPassword()`, `deleteUser()`, plus pure helpers (`isValidUsername`, `hashPassword`) that are unit-testable without IO. In-process cache with short TTL like `readSettings`, invalidated on every write; the short TTL also covers the CLI (section 10) editing `users.json` while the server runs (cross-process changes picked up within the TTL).
|
||||
|
||||
### 4.2 User spaces
|
||||
|
||||
```
|
||||
~/codeman-users/
|
||||
alice/
|
||||
cases/
|
||||
my-project/ <- same layout as today's ~/codeman-cases/<case>
|
||||
bob/
|
||||
cases/
|
||||
```
|
||||
|
||||
- New helper in `route-helpers.ts`:
|
||||
`resolveCasesDir(user?: AuthUser): string`
|
||||
single-user mode: returns `CASES_DIR` (today's `~/codeman-cases`); multi-user: returns `join(USER_SPACES_DIR, user.username, 'cases')`, creating it lazily on first use.
|
||||
- `CASES_DIR` stays exported for single-user code paths, but every route usage (see 6) switches to the resolver.
|
||||
- The **user folder** (`~/codeman-users/<username>/`) is the deletion unit for "delete user + space" and leaves room for future per-user extras (uploads, exports) beside `cases/`.
|
||||
- Legacy `~/codeman-cases` in multi-user mode: surfaces to admins only, as a read-only "Unassigned (legacy)" group in the case list, with an admin action `POST /api/admin/cases/assign { case, username }` that `fs.rename`s the folder into a user's space (same-filesystem move, cheap). No automatic migration.
|
||||
|
||||
## 5. Auth Pipeline Changes (`src/web/middleware/auth.ts`)
|
||||
|
||||
Keep the existing single-user branch untouched. Add a parallel multi-user branch selected once at registration time:
|
||||
|
||||
1. **Credential check**: Basic header parsed into `username:password`, verified against the user store (scrypt + `timingSafeEqual`). Disabled users fail closed.
|
||||
2. **Cookie sessions**: same `codeman_session` cookie and `StaleExpirationMap`, but `AuthSessionRecord` gains `username` and `role`. All existing TTL/sliding/eviction logic reused. Eviction cap becomes per-user aware (evict oldest _of that user_ first) so one user cannot flush everyone's sessions by logging in 100 times.
|
||||
3. **Request identity**: decorate `req.authUser = { username, role }` (Fastify decorateRequest). In single-user mode `req.authUser` is `{ username: 'admin', role: 'admin' }` when auth is on, and a synthetic admin when auth is off, so downstream code has ONE code path.
|
||||
4. **Rate limiting**: keep the per-IP bucket; add a per-username failure bucket (same `StaleExpirationMap` pattern) so a botnet cannot brute-force one account across IPs, and one flaky user behind a NAT cannot lock out the rest.
|
||||
5. **`mustChangePassword` gate**: when set, every API request except `GET /api/me`, `POST /api/me/password`, and static assets returns 403 with `errorCode: 'PASSWORD_CHANGE_REQUIRED'`; the frontend intercepts that code and shows the change-password modal.
|
||||
6. **Password change vs Basic-auth caching**: browsers cache Basic credentials. After a password change we revoke all of that user's cookie sessions; the next request falls to Basic with stale creds, gets 401, and the browser re-prompts. Acceptable for v1; a proper login form is Phase 6 (see 15).
|
||||
7. **Unchanged**: hook-secret loopback bypass (hooks authenticate the _instance_, not a user; the event maps to a session which has an owner), host guard, Origin/CSRF guard, security headers.
|
||||
8. **WS upgrade identity** (`ws-routes.ts`): the global auth `onRequest` hook does run on the upgrade request (`@fastify/websocket` v11 runs hooks before the handshake; browsers send the session cookie), but the route handler itself only checks Host/Origin and never learns WHO authenticated. Multi-user: the handler reads the decorated `req.authUser` and closes 4003 unless owner or admin (section 6.4; identity plumbing lands in Phase 2, the owner check in Phase 4 once sessions have owners). Add a regression test that an upgrade with no credentials is rejected while auth is active: the handler-level Host/Origin gate alone must never be mistaken for auth.
|
||||
9. **QR auth** (`/q/:code` redemption in `system-routes.ts`, minting in `tunnel-manager.ts`): today there is ONE global token, auto-rotated every 60s with a 90s grace window. A globally-rotating token cannot carry an identity (every logged-in user sees the same code), so multi-user mode replaces rotation with **on-demand minting**: an authenticated `POST /api/tunnel/qr` mints a single-use, short-TTL token bound to `req.authUser.username` (field on `QrTokenRecord`); redemption creates a cookie session for that user. Existing rate-limit buckets (`qrAuthFailures`, global `QR_RATE_LIMIT_MAX`) apply unchanged. Single-user mode keeps the rotating token.
|
||||
|
||||
New error codes in `src/types/api.ts`: `FORBIDDEN`, `PASSWORD_CHANGE_REQUIRED`, `USER_EXISTS`, `USER_NOT_FOUND`, `LAST_ADMIN`.
|
||||
|
||||
Role guard helper in `route-helpers.ts`: `requireAdmin(req, reply): boolean` used as the first line of every admin handler (403 `FORBIDDEN`), plus `requireOwnerOrAdmin(req, session)`.
|
||||
|
||||
## 6. Ownership Threading (the big refactor)
|
||||
|
||||
### 6.1 Sessions
|
||||
|
||||
- `Session` gains `owner?: string` (constructor option), persisted in `SessionState.owner`, included in `toState()`, round-tripped through recovery (`mux-sessions.json` entries carry it, `restoreMuxSessions` passes it back, exactly like `remote`/`docker`).
|
||||
- Every session-creating path stamps the owner from `req.authUser`. Verified inventory of `new Session(...)` call sites: `POST /api/sessions` (session-routes.ts:444), `POST /api/quick-start` (:1956), `POST /api/run` one-shot (:1652), Ralph start (ralph-routes.ts:327), **cron** (cron-service.ts:352; `CronJob` gains `owner`, stamped at job create, launched as the job's owner), legacy `ScheduledRun` loop (server.ts:1603), plan generation + plan-orchestrator agents (plan-routes.ts:128, plan-orchestrator.ts:422/578; owner = requesting user), and recovery (server.ts:2225, next bullet). Two non-paths, also verified: **respawn never constructs a new Session** (it re-spawns the PTY on the same object, so `owner` survives automatically; no inheritance logic needed), and **orchestrator-loop creates no sessions** (it schedules work onto existing idle sessions via the task queue; its scoping requirement is different: it must only pick idle sessions owned by the goal's creator).
|
||||
- Recovery: `owner` must ALSO be mirrored on `MuxSession` (mux-sessions.json) and read back mux-first like `remote`/`docker` (`muxSession.owner ?? savedState?.owner`, the server.ts:2246-2250 pattern), or a reboot erases ownership on the next persist.
|
||||
- Every session-reading/mutating route filters: non-admin users only see and act on `session.owner === req.authUser.username`. Centralize in `findSessionOrFail` (route-helpers.ts:87; the owner check there covers the 6 route files that use it: system/session/respawn/ralph/file/plan-routes) and in the list endpoints (`GET /api/sessions`, `GET /api/sessions/unified`, `GET /api/status`). The Phase 3 audit must grep for BOTH `sessionManager.getSession` AND direct map access (`ctx.sessions.get(` / `.has(`): ws-routes and hook-event-routes reach sessions that way and bypass `findSessionOrFail`.
|
||||
- Admins see everything; every session row carries `owner` so the UI can badge it.
|
||||
|
||||
### 6.2 Cases
|
||||
|
||||
- All `CASES_DIR` call sites switch to `resolveCasesDir(req.authUser)`: `case-routes.ts` (list/create/delete/CLAUDE.md scaffolding, name-collision checks, docker quickcreate), `session-routes.ts` (quick-start case resolution, the workingDir-inside-cases env-strip check), `ralph-routes.ts` (case path resolution), and `plan-routes.ts:231` (easy to miss). Case-name-to-path resolution is currently DUPLICATED (`resolveCasePath` in case-routes.ts:82 and an inline copy in quick-start, session-routes.ts:1846-1863); consolidate into one owner-aware resolver as part of this refactor instead of patching both copies.
|
||||
- Registries that map case names to metadata become owner-scoped. `remote-cases.json`/`docker-cases.json` are arrays of objects, so entries simply gain `owner?: string` (absent = legacy: admin-only). `linked-cases.json` is a flat `Record<caseName, path>` with no room for a field: it needs a v2 shape (`{ "version": 2, "cases": { "<name>": { "path": "...", "owner": "..." } } }`) with read-time migration of the v1 form; it is read in two places (case-routes AND inline in quick-start), both must move to the new reader. Case names only need to be unique per user.
|
||||
- **Remote hosts and Docker hosts are machine-level resources**: CRUD on `/api/docker-hosts` and remote-host endpoints becomes admin-only in multi-user mode; regular users can _use_ hosts on their own cases but not define them. (Docker containers exec as the host account; letting any user define arbitrary `docker run` args is admin-equivalent.)
|
||||
- Case deletion, exports (`docker-exports/`), and imports check ownership; export filenames get an owner prefix to avoid collisions (fits the existing `^[a-zA-Z0-9._-]+\.tgz$` download guard).
|
||||
- **Workspace confinement for non-admins (the linchpin, do not skip)**: today `POST /api/sessions` accepts ANY host directory as `workingDir` (the only check is `statSync().isDirectory()`, session-routes.ts:305-318), and file-routes/attachments confine reads to `session.workingDir`. Without a new rule the whole scoping story is circular: a user points a session at `~/codeman-users/bob` (or `/home`) and the web layer itself serves that subtree, no agent needed. Rule: in multi-user mode a non-admin's `workingDir` must realpath-resolve inside their own space, enforced at `POST /api/sessions`, `POST /api/run`, cron job create AND fire time (the dir can change owners between the two), and Ralph auto-configure. Admins are unrestricted. This one rule is what makes the section 6.4 file-route line ("own space or own sessions' workingDirs") meaningful.
|
||||
|
||||
### 6.3 Per-user Claude permission-mode policy
|
||||
|
||||
Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab: `settings.claudeMode`, values `dangerously-skip-permissions` (default) | `auto` | `normal` | `allowedTools`; `auto` emits `--permission-mode auto`, Anthropic's classifier-guarded low-prompt mode). Multi-user mode layers a per-user policy on top of it:
|
||||
|
||||
- **Default for regular users: `auto` only.** A non-admin's Claude sessions are forced to `--permission-mode auto` regardless of the global `claudeMode` setting. `normal` and `allowedTools` are also permitted (they are strictly more restrictive than auto), but `dangerously-skip-permissions` is NOT.
|
||||
- **Bypass is an explicit admin grant**: `canBypassPermissions: true` on the user record (default `false`, section 4.1). Only with that grant does the global skip-permissions default (or a future per-user choice) apply to their sessions.
|
||||
- **Admins** are unrestricted; the global setting applies to them as-is.
|
||||
- **Single enforcement point**: a pure `resolveClaudeModeForUser(globalMode, user)` in `user-store.ts`, applied server-side at option-resolution time, BEFORE the Session constructor, so both downstream arg builders inherit it for free (`buildPermissionArgs` in session-cli-builder.ts for the direct-PTY path AND `buildClaudePermissionFlags` in tmux-manager.ts for tmux panes; there are two builders, not one). Call sites where `getClaudeModeConfig()` feeds a spawn: session-routes.ts:452/1964, ralph-routes.ts:334, cron-service.ts:360, and recovery (server.ts:2214/2233). Recovery re-reads the GLOBAL setting on reboot, so the resolver must run there with the RECOVERED owner, or a restart silently un-downgrades every restored session. Never resolved in the frontend, so it cannot be bypassed via payload.
|
||||
- **Downgrade, don't error**: a non-granted user whose effective mode would be bypass gets `auto` silently (logged + surfaced as a badge on the session), so shared presets keep working.
|
||||
- **Other CLIs' bypass equivalents** follow the same grant: Codex `--dangerously-bypass-approvals-and-sandbox` (`codexDangerouslyBypassApprovals`) and Gemini `--approval-mode yolo` are refused for non-granted users (Gemini falls back to `auto_edit`, Codex to its default sandbox). Whether this stays one grant or splits per-CLI is an open question (section 15).
|
||||
- **Shell mode and custom launch commands follow the grant too**: `mode: 'shell'` sessions and cron `launchCommand` are arbitrary command execution as the host account, strictly stronger than any bypass flag, and no permission-mode downgrade applies to them. Non-granted users get 403 `FORBIDDEN` on shell session/quick-start creation and on cron jobs carrying `launchCommand` (checked at create AND at fire time). Folding them under `canBypassPermissions` keeps the model one-bit; section 15 asks whether it should split.
|
||||
- **Admin UI**: a "Can skip permissions" toggle per user in the Users tab (PATCH field, section 8), with a warning echoing the section 2 threat model.
|
||||
- Revoking the grant takes effect on the user's NEXT session start; live sessions are listed so the admin can restart them.
|
||||
|
||||
### 6.4 Everything else that lists or streams
|
||||
|
||||
| Surface | Scoping rule |
|
||||
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| SSE `/api/events` | Per-connection filter (see 7) |
|
||||
| WS terminal (`ws-routes.ts`) | Handler reads `req.authUser` (section 5.8) and closes 4003 unless owner or admin; today it checks Host/Origin only and has no identity |
|
||||
| `GET /api/search` | `harvestSources()` only over owned sessions |
|
||||
| `GET /api/away-digest` | Aggregate only owned sessions/events |
|
||||
| `GET /api/subagents`, workflow runs | Filter by owning session (`claudeSessionId -> session -> owner`); agents not attributable to any session: admin-only |
|
||||
| Push (`push-routes.ts`) | Subscription records currently carry NO identity (keyed by endpoint only): `subscribe` stamps `username`. All 8 `PUSH_EVENT_MAP` events are session-scoped, so routing = resolve owner from `data.sessionId`, deliver to that owner's (plus admins') subscriptions. Legacy identity-less subscriptions: admin-only delivery |
|
||||
| Screenshots `/api/screenshots` | Per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
||||
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
||||
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
||||
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
|
||||
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only |
|
||||
| `getLightState` init snapshot | Filtered per connection. Actual contents to filter (verified): `sessions`, `scheduledRuns`, `respawnStatus`, `subagents`, `workflowRuns`, `planUsage` (host-plan telemetry: admin-only); `globalStats` stays coarse-global. Cron jobs are NOT in the snapshot (they have their own REST route; filter there). The snapshot is cached process-wide (`LIGHT_STATE_CACHE_TTL_MS`): either key the cache per role/user or filter AFTER the cache on each send |
|
||||
|
||||
## 7. SSE Event Filtering
|
||||
|
||||
`/api/events` currently broadcasts everything to everyone. Ground truth first (verified): `broadcast()` lives in `SseStreamManager` (`sse-stream-manager.ts`), not server.ts; clients are keyed by the raw Fastify reply (`sseClients: Map<FastifyReply, Set<string> | null>`, plus `sseClientsById` for live filter updates); the existing `?sessions=` filter is a bandwidth optimization applied ONLY to `session:terminal` batches in `flushSessionTerminalBatch()`, while `broadcast()` itself loops ALL clients unconditionally. The single-client delivery primitive already exists (`sendSSE`, used for the per-connection init snapshot). Plan:
|
||||
|
||||
- At connection time, resolve `req.authUser` and store `{ username, role }` with the client. Concretely: extend `addClient(reply, sessionFilter, isRemote, clientId)` to take the identity and change the `sseClients` map value to `{ filter, identity }` (or add a parallel `Map<reply, identity>`); there is no per-client record object today to hang it on.
|
||||
- `broadcast()` gains an optional routing hint: `broadcast(event, data, { sessionId?, adminOnly?, username? })`. Resolution order per client: admin sees all; `username` targets one user; `sessionId` resolves owner via SessionManager; `adminOnly` for machine-level events (docker image builds, tunnel, self-update); no hint = broadcast to all (connection status etc.).
|
||||
- **Enforce the identity check in BOTH `broadcast()` AND `flushSessionTerminalBatch()`**: the terminal batch path does not go through `broadcast()`, and it carries the highest-value payload (raw terminal bytes).
|
||||
- Sweep of the ~120 backend event constants in `sse-events.ts`: mechanically, everything `session:*`, `ralph:*`, `respawn:*`, `subagent:*`, `workflow:*`, `attachment:*`, `cron:*` (job owner) carries or can resolve a sessionId/owner; `docker:*`, `system:*`, tunnel and update events are adminOnly; a short tail needs case-by-case decisions during implementation.
|
||||
- The existing `?sessions=` filter and `/api/events/subscribe` compose with (never override) the ownership filter: the subscription filter can only narrow within what the identity allows.
|
||||
|
||||
## 8. Admin API (`src/web/routes/admin-routes.ts`, new module + `AdminPort`)
|
||||
|
||||
All handlers: multi-user mode only (404 otherwise), `requireAdmin`, Zod schemas in `schemas.ts`, `ApiResponse` envelope, audit-logged.
|
||||
|
||||
| Endpoint | Behavior |
|
||||
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET /api/admin/users` | List users + stats: role, disabled, createdAt, lastLoginAt, live session count, case count, space disk usage (best-effort async walk, cached 60s), active cookie-session count |
|
||||
| `POST /api/admin/users` | Create: `{ username, role, password? }`. No password given: generate a one-time password, return it ONCE in the response, set `mustChangePassword` |
|
||||
| `PATCH /api/admin/users/:username` | `{ role?, disabled?, canBypassPermissions? }`. Demoting/disabling the last enabled admin: 409 `LAST_ADMIN`. Disable also revokes cookie sessions. `canBypassPermissions` is the section 6.3 grant (default false) |
|
||||
| `POST /api/admin/users/:username/reset-password` | Generates one-time password (returned once), sets `mustChangePassword`, revokes cookie sessions |
|
||||
| `POST /api/admin/users/:username/logout` | Revoke all cookie sessions for that user. Honest limit under Basic auth: the browser silently re-sends cached credentials and gets a fresh cookie on the next request, so logout only truly ends QR-issued sessions; to actually lock someone out, disable the account or reset the password. Say so in the panel tooltip until Phase 6 |
|
||||
| `DELETE /api/admin/users/:username` | `{ deleteSpace?: boolean }` (default false). Refuses last admin. Kills the user's live sessions first (normal kill flow, incl. docker/remote teardown per case), revokes cookies, removes from store. With `deleteSpace`: guarded recursive delete of `~/codeman-users/<username>` (realpath must be inside `USER_SPACES_DIR`, top-level dir must not be a symlink), plus their registry entries and push subscriptions |
|
||||
| `POST /api/admin/cases/assign` | Move a legacy `~/codeman-cases/<case>` into a user's space (`fs.rename`) |
|
||||
| Self-service `GET /api/me` | `{ username, role, mustChangePassword }` (works in single-user mode too: synthetic admin; the frontend uses it to decide whether to render admin UI) |
|
||||
| Self-service `POST /api/me/password` | `{ currentPassword, newPassword }`, verifies current, min length 8, revokes other sessions, clears `mustChangePassword` |
|
||||
|
||||
**Audit log**: append-only `~/.codeman/admin-audit.jsonl` (same idiom as `session-lifecycle.jsonl`): timestamp, acting admin, action, target, request IP. User management without an audit trail is not acceptable even for a homelab tool.
|
||||
|
||||
SSE additions (both `sse-events.ts` and `constants.js`): `admin:usersChanged` (adminOnly; the panel re-fetches) and `auth:passwordChangeRequired` (targeted to the user).
|
||||
|
||||
## 9. Frontend
|
||||
|
||||
- **`GET /api/me` on boot** (app.js init): stores `window.__codemanUser`; everything below keys off it. Single-user mode returns the synthetic admin, so the UI needs no mode awareness beyond "am I admin".
|
||||
- **Admin panel**: new tab "Users" in the App Settings modal (settings-ui.js), rendered only for admins in multi-user mode. Table of users with actions (create, reset password showing the one-time password in a copy-to-clipboard reveal, enable/disable, role toggle, logout, delete with a typed-username confirm for the delete-space variant). No new header button (mobile header policy test stays green; the settings modal is already reachable everywhere).
|
||||
- **Change-password modal**: shown on `PASSWORD_CHANGE_REQUIRED` (fetch interceptor in api-client.js) and reachable from settings for self-service.
|
||||
- **Owner badges**: admin's session tabs and the session palette/manager show `owner` on foreign sessions; regular users see no change.
|
||||
- New module `admin-ui.js` if the settings-ui.js addition gets large (load order after settings-ui, before session-ui), else keep inside settings-ui.js. Follow the `@fileoverview` + `@loadorder` convention either way.
|
||||
|
||||
## 10. CLI Additions (`src/cli.ts`)
|
||||
|
||||
Headless bootstrap and recovery must not require the web UI:
|
||||
|
||||
```
|
||||
codeman users add <name> [--admin] # prompts for password (hidden input), or --password-stdin
|
||||
codeman users passwd <name> # reset password
|
||||
codeman users list
|
||||
codeman users rm <name> [--delete-space]
|
||||
```
|
||||
|
||||
These operate directly on `users.json` via `user-store.ts` (no server needed), honoring `CODEMAN_INSTANCE`. This is also the answer to "locked out: last admin forgot password".
|
||||
|
||||
## 11. Limits and Config
|
||||
|
||||
- New `src/config/multiuser.ts`: `isMultiUserMode()`, `USER_SPACES_DIR` (`~/codeman-users`, overridable via `CODEMAN_USER_SPACES_DIR` for tests), `MAX_USERS` (default 25), per-user session cap (default: global cap / 2, env `CODEMAN_MAX_SESSIONS_PER_USER`).
|
||||
- Cap enforcement is currently COPY-PASTED: the global `MAX_CONCURRENT_SESSIONS` (50, `config/map-limits.ts:25`) check appears at 6 independent sites (session-routes.ts:298/1622/1683, ralph-routes.ts:275, cron-service.ts:340, server.ts:1595). Do not add a 7th copy per site: extract one `assertSessionCapacity(ctx, owner?)` helper doing the global + per-user checks and use it everywhere, or the per-user cap WILL miss a path.
|
||||
- Global limits (50 sessions, SSE clients 100, terminal buffers) are unchanged and shared; the per-user session cap is the fairness lever.
|
||||
|
||||
## 12. Compatibility Matrix
|
||||
|
||||
| Concern | Guarantee |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Default (no flag) | No behavior change. No new file reads on the hot path. All new fields optional in state |
|
||||
| State round-trip | `SessionState.owner`, `MuxSession.owner`, `CronJob.owner`, registry `owner` fields are optional; old state loads clean; new state loaded by an old build ignores unknown fields (existing tolerant parsing) |
|
||||
| Instance isolation | `users.json`, audit log, screenshots subdirs all via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
|
||||
| API versioning | HTTP API is internal per `docs/versioning-policy.md`; still, all changes are additive. Ship as a **minor** version |
|
||||
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
|
||||
|
||||
## 13. Implementation Phases
|
||||
|
||||
Each phase is independently shippable behind the flag and ends with its tests green.
|
||||
|
||||
**Phase 1: user store + mode plumbing** (no behavior change yet)
|
||||
`src/user-store.ts`, `src/config/multiuser.ts`, CLI `users` subcommands, bootstrap-on-first-boot logic, `users.json` schema + atomic writes.
|
||||
Tests: `test/user-store.test.ts` (hashing, verify, params upgrade, username validation, atomic write, last-admin invariants; pure, no server).
|
||||
|
||||
**Phase 2: multi-user auth**
|
||||
Auth middleware branch, `req.authUser` decoration, cookie records with username/role, per-username rate bucket, `mustChangePassword` gate, WS upgrade identity plumbing + unauthenticated-upgrade regression test (section 5.8), QR on-demand minting + identity binding (section 5.9), `GET /api/me`, `POST /api/me/password`, error codes, network-bind check integration.
|
||||
Tests: `test/multiuser-auth.test.ts` (live server, unique port 3170+; wrong password, disabled user, cookie carries identity, per-user rate limit isolation, mustChangePassword lockbox, QR redemption identity). Reuse the `delete process.env.CODEMAN_PASSWORD` idiom from `test/setup.ts`.
|
||||
|
||||
**Phase 3: ownership threading**
|
||||
Session `owner` + persistence + `MuxSession` mirror + recovery; `resolveCasesDir()` refactor across case/session/ralph/plan routes (consolidating the duplicated case-path resolution); registry owner fields incl. the linked-cases v2 shape; `findSessionOrFail` owner check + the direct-`sessions.get` audit; list filtering; owner stamping across ALL create paths from 6.1; **non-admin workingDir confinement** (6.2); permission-mode/shell/launchCommand policy (6.3); `assertSessionCapacity` helper + per-user cap.
|
||||
Tests: `test/routes/ownership-scoping.test.ts` (inject-based: user A cannot read/kill/input user B's session, case lists are disjoint, admin sees both), extend `test/cron-service.test.ts` for owner stamping, recovery round-trip in the existing mux-recovery tests.
|
||||
|
||||
**Phase 4: event fan-out + remaining surfaces**
|
||||
SSE routing hints + client identity (enforced in BOTH `broadcast()` and the terminal-batch flush), WS owner gate (identity landed in Phase 2), search/digest/subagent/workflow scoping, push subscription identity + owner routing, screenshot subdirs, file-route scoping, `getLightState` filtering + per-identity caching, admin-only system ops.
|
||||
Tests: `test/sse-ownership.test.ts` (two SSE clients, event for A's session reaches only A + admin), WS upgrade rejection test, search/digest scoping tests.
|
||||
|
||||
**Phase 5: admin API + frontend**
|
||||
`admin-routes.ts` + `AdminPort` + schemas + audit log + `admin:usersChanged`; settings-ui Users tab, change-password modal, owner badges, api-client interceptor.
|
||||
Tests: `test/routes/admin-routes.test.ts` (CRUD, last-admin 409, one-time password flow, delete-space guard rails incl. symlink refusal), frontend vm-sandbox test following `test/run-mode-ui.test.ts` pattern, Playwright pass per the always-end-to-end rule before calling it done.
|
||||
|
||||
**Phase 6 (optional, later): login page**
|
||||
Replace Basic with a form + `POST /api/login` in multi-user mode only (fixes browser credential caching UX, enables logout button). Explicitly deferred; Basic works for v1.
|
||||
|
||||
**Docs**: update `docs/security-architecture.md` (new section: multi-user model + threat model from section 2), `README.md` (short opt-in section), `CLAUDE.md` (Key Patterns entry + State Files + route/SSE counts), this file gets a "shipped" status stamp per phase.
|
||||
|
||||
## 14. Key Risks / Decisions Made
|
||||
|
||||
1. **Not a security boundary at the agent layer** (section 2). Decided: ship with loud documentation; Docker cases are the isolation story.
|
||||
2. **`findSessionOrFail` as the single enforcement point** for ~30 session routes: any route that fetches sessions another way must be audited in Phase 3 (grep for `sessionManager.getSession` outside route-helpers).
|
||||
3. **SSE sweep is the riskiest surface**: a missed event leaks metadata (not terminal content, which is session-scoped, but names/paths). Phase 4 includes a checklist pass over all ~138 events with the default flipped to "owner-scoped unless explicitly global": fail closed.
|
||||
4. **Basic-auth password-change UX** is mediocre (browser re-prompt). Accepted for v1; Phase 6 fixes it properly.
|
||||
5. **Legacy case migration** is manual (admin assigns). No silent moves of user data.
|
||||
6. **Case-name uniqueness becomes per-user**; tmux session names already include the session id so no collision, but the `w<n>-<case>` tab naming and lifecycle-log rows should include the owner for disambiguation in admin views.
|
||||
7. **`workingDir` confinement (6.2) is the single most load-bearing rule**: every file-serving and agent-spawning surface downstream trusts `session.workingDir`. Review and test it as carefully as the auth branch (foreign-space path, symlink into a foreign space, `..` traversal, cron fire-time re-check).
|
||||
8. **The WS handler never sees identity today** (auth happens only in the global hook): the 5.8 wiring is new code on a security-sensitive path; cover unauthenticated, foreign-user, and admin upgrades with tests.
|
||||
|
||||
## 15. Open Questions (answer before Phase 3)
|
||||
|
||||
1. Should admins' own cases live in `~/codeman-users/<admin>/cases` (symmetric, proposed) or keep using legacy `~/codeman-cases`? Proposed: symmetric; legacy dir is a migration source only.
|
||||
2. Per-user settings (respawn presets, notification prefs): global-only in v1. Worth a `users/<name>/settings.json` overlay later?
|
||||
3. Should regular users be allowed to create Docker cases on admin-defined hosts (proposed: yes) or is Docker entirely admin-only?
|
||||
4. Session handoff: does an admin need "reassign session/case to another user"? (Cheap to add next to `cases/assign`; not in v1 scope.)
|
||||
5. Permission-mode grants (section 6.3): one `canBypassPermissions` flag covering Claude/Codex/Gemini bypass equivalents PLUS shell mode and cron `launchCommand` (proposed: one flag, keep it one-bit), or split into `canBypassPermissions` + `canRunArbitraryCommands`? And should admins be able to set a per-user DEFAULT mode (for example force `normal` for an intern) rather than just gating bypass?
|
||||
6. OpenCode has no single bypass flag (its permission config rides `OPENCODE_CONFIG_CONTENT`): decide what the grant means there before Phase 3, or exclude OpenCode mode for non-granted users in v1.
|
||||
@@ -30,7 +30,8 @@ an explicit, guided opt‑in.
|
||||
7. [Supply‑chain & build‑asset hardening](#7-supplychain--buildasset-hardening-cod28)
|
||||
8. [Multi‑instance isolation](#8-multiinstance-isolation)
|
||||
9. [Transport security headers](#9-transport-security-headers)
|
||||
10. [Quick reference](#10-quick-reference)
|
||||
10. [Docker container isolation](#10-docker-container-isolation)
|
||||
11. [Quick reference](#11-quick-reference)
|
||||
|
||||
---
|
||||
|
||||
@@ -471,7 +472,35 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick reference
|
||||
## 10. Docker container isolation
|
||||
|
||||
Docker cases (1.4.0) run a session inside a per‑case container instead of on the host. The security posture:
|
||||
|
||||
- **Hardened create flags, always** — `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit` (fork‑bomb guard), `--memory` == `--memory-swap` (a real OOM cap), `--init`, and non‑root: `--user <hostUid>:0` on Linux (host uid → workspace files stay host‑owned; GID 0 keeps `$HOME` writable), `--userns=keep-id` on rootless Podman. **Never** `--privileged`, and **never** the docker socket — the pure builder in `docker-hosts.ts` cannot emit them and the schema cannot represent them.
|
||||
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini`, `~/.config/{gcloud,opencode}`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
|
||||
- **Blast radius — accept it explicitly** — the convenient profile mounts an arbitrary host workspace RW plus the host credential dirs RW into a network‑enabled container, so container‑run agent code can read/modify those host trees and reach the network at once. Still a net improvement over today's on‑host `--dangerously-skip-permissions` execution; use the sealed profile for genuinely untrusted work.
|
||||
- **Import is untrusted‑bundle‑safe** — `/api/docker-cases/import` validates the manifest + per‑member SHA‑256 before extraction, rejects absolute / `..` tar members (traversal guard), and re‑tags the loaded image into a quarantined namespace so it can never overwrite `codeman/agent:base` or a pre‑existing tag.
|
||||
- **Host guard & the bridge‑hooks listener** — in‑container hook callbacks carry `Host: host.docker.internal` / `host.containers.internal`; both are on the always‑on host‑header allowlist (`DOCKER_HOST_GATEWAY_ALIASES`) and resolve to the host only from inside a container netns, so they are not a browser DNS‑rebinding surface. On a loopback‑only server, in‑container hooks are opt‑in via `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, which binds a SECOND listener on the docker bridge gateway serving **only** the hook endpoints (every other path → `403`) into the same hook‑secret‑gated pipeline. The bridge is host‑internal (containers + host), not the LAN, so it does not widen network exposure; the hook secret is bind‑mounted read‑only and referenced by path.
|
||||
- **Instance isolation** — every managed container is labeled `codeman.instance=<CODEMAN_INSTANCE>`; the boot reaper reaps orphans of its OWN instance only, so a beta never removes a prod container. The in‑container tmux socket (`-L codeman-docker`) + session name (`codeman-dkr-*`) deliberately fail a nested Codeman's discovery pattern.
|
||||
|
||||
Full feature guide: [`docker-cases.md`](docker-cases.md).
|
||||
|
||||
---
|
||||
|
||||
## 10a. Multi‑user mode (opt‑in)
|
||||
|
||||
`codeman web --multiuser` (or `CODEMAN_MULTIUSER=1`) turns on named users with individually scrypt‑hashed passwords in `~/.codeman/users.json` (mode 0600). OFF by default; when off, nothing here applies and behavior is byte‑identical to single‑user. Design + phase status: [`multi-user-plan.md`](multi-user-plan.md).
|
||||
|
||||
- **It is workspace separation, NOT a security boundary between users.** Every session still runs as the SAME OS account with agent code that can read the whole host. Any user can ask their agent to `cat` another user's files; the WEB layer enforces scoping, the AGENT layer cannot. Mitigations: give non‑admins the default `auto` permission mode (classifier‑guarded), pair users with **Docker cases** (container per case) for real isolation, or run separate Codeman instances under separate OS accounts. Stated loudly in the admin panel and the plan's threat model (section 2).
|
||||
- **It strictly improves network posture.** It removes the single shared `CODEMAN_PASSWORD` and gives each person a revocable credential; a non‑loopback bind and the tunnel‑enable guard are satisfied by "multi‑user with ≥1 enabled user" without a shared password.
|
||||
- **Auth is a parallel branch** (`middleware/auth.ts`) that leaves the single‑user path untouched: per‑user scrypt verify (`timingSafeEqual`, timing‑equalized against user enumeration), identity‑carrying cookies, a per‑username failure bucket (a botnet can't brute one account across IPs; one NATed user can't lock out the rest), and a `mustChangePassword` lockbox. The hook‑secret loopback bypass, host guard, and Origin/CSRF guard are unchanged (hooks authenticate the INSTANCE, not a user).
|
||||
- **Ownership is enforced server‑side only** and fails closed: `req.authUser` (a synthetic admin in single‑user), `findSessionOrFail` returns NOT_FOUND (never 403) for a foreign session, list/SSE/WS/file‑preview/search all filter by `session.owner`, and SSE routing defaults session‑scoped events to their owner (unresolved owner → withheld). The load‑bearing rule is **non‑admin `workingDir` confinement**: a non‑admin's session/one‑shot working dir must realpath‑resolve inside `~/codeman-users/<name>/cases`, checked BEFORE any disk write.
|
||||
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
|
||||
- **Admin actions are audited** append‑only to `~/.codeman/admin-audit.jsonl` (acting admin, action, target, IP). Passwords set by an admin create/reset are one‑time (returned once, force change). Under Basic auth, `logout` only truly ends QR‑issued sessions — to lock someone out, disable the account or reset the password (a proper login form is a deferred Phase 6).
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick reference
|
||||
|
||||
| Env / flag | Effect |
|
||||
|------------|--------|
|
||||
@@ -482,6 +511,8 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
| `--https` | Enable TLS (adds HSTS) |
|
||||
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
|
||||
| `CODEMAN_GESTURE=1` | Make the gesture overlay available (widens CSP) |
|
||||
| `CODEMAN_DOCKER_BRIDGE_HOOKS=1` | Serve the hook endpoints on the docker bridge gateway (host‑internal, hooks‑only, `403` elsewhere) so in‑container hooks reach a loopback‑bound server — see §10 |
|
||||
| `CODEMAN_DOCKER_BRIDGE_HOST` | Override the bridge gateway IP the hooks listener binds (default: auto‑detect) |
|
||||
|
||||
**Audit log:** session lifecycle and server start are recorded in
|
||||
`~/.codeman/session-lifecycle.jsonl`.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.3.5",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.3.5",
|
||||
"version": "1.5.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.3.5",
|
||||
"version": "1.5.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the Codeman agent base image locally (decision: "build locally on first
|
||||
* use", see docs/docker-cases-plan.md). No registry account required.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-agent-image.mjs [--engine docker|podman] [--image <ref>] [--no-cache]
|
||||
*
|
||||
* Defaults: engine=docker (falls back to podman if docker is absent),
|
||||
* image=codeman/agent:base
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = join(__dirname, '..');
|
||||
const DOCKERFILE = join(REPO_ROOT, 'docker', 'agent.Dockerfile');
|
||||
const DEFAULT_IMAGE = 'codeman/agent:base';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { image: DEFAULT_IMAGE, engine: undefined, noCache: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--image') args.image = argv[++i];
|
||||
else if (a === '--engine') args.engine = argv[++i];
|
||||
else if (a === '--no-cache') args.noCache = true;
|
||||
else if (a === '-h' || a === '--help') args.help = true;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function engineAvailable(engine) {
|
||||
const r = spawnSync(engine, ['--version'], { stdio: 'ignore' });
|
||||
return r.status === 0;
|
||||
}
|
||||
|
||||
function resolveEngine(preferred) {
|
||||
if (preferred) {
|
||||
if (!engineAvailable(preferred)) {
|
||||
console.error(`[build-agent-image] engine "${preferred}" not found on PATH`);
|
||||
process.exit(1);
|
||||
}
|
||||
return preferred;
|
||||
}
|
||||
if (engineAvailable('docker')) return 'docker';
|
||||
if (engineAvailable('podman')) return 'podman';
|
||||
console.error('[build-agent-image] neither docker nor podman found on PATH. Install one and retry.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log('Usage: node scripts/build-agent-image.mjs [--engine docker|podman] [--image <ref>] [--no-cache]');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const engine = resolveEngine(args.engine);
|
||||
const buildArgs = ['build', '-f', DOCKERFILE, '-t', args.image];
|
||||
if (args.noCache) buildArgs.push('--no-cache');
|
||||
buildArgs.push(REPO_ROOT);
|
||||
|
||||
console.log(`[build-agent-image] ${engine} ${buildArgs.join(' ')}`);
|
||||
const child = spawn(engine, buildArgs, { stdio: 'inherit' });
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
console.log(`\n[build-agent-image] built ${args.image}. Docker cases can now launch.`);
|
||||
} else {
|
||||
console.error(`\n[build-agent-image] build failed (exit ${code}).`);
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
@@ -0,0 +1,482 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* capture-readme-gifs.mjs
|
||||
*
|
||||
* Deterministic README GIFs — no real server, Claude CLI, or tmux. Reuses the
|
||||
* mock-injection pipeline from capture-readme-screenshots.mjs (static file
|
||||
* server + page.route mocks), drives a scripted timeline in the page, records
|
||||
* it with Playwright video, and converts to GIF via ffmpeg palette encoding.
|
||||
*
|
||||
* Scenes:
|
||||
* 1. subagent-demo.gif — terminal spawns 3 parallel agents; floating agent
|
||||
* windows open one by one and stream tool-call activity live (driven
|
||||
* through the real _onSubagentDiscovered/_onSubagentToolCall handlers).
|
||||
* 2. zerolag-demo.gif — side-by-side typing: instant local echo (zerolag)
|
||||
* vs bursty ~350 ms server echo, rendered with the vendored xterm.
|
||||
*
|
||||
* Usage: node scripts/capture-readme-gifs.mjs
|
||||
* SCREENSHOT_OUT_DIR=/path/to/review node scripts/capture-readme-gifs.mjs
|
||||
* Output: docs/images/ (or flat into SCREENSHOT_OUT_DIR)
|
||||
* Requires: ffmpeg
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { execSync } from 'child_process';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
PORT,
|
||||
SESSION_IDS,
|
||||
STANDARD_SESSIONS,
|
||||
buildInitPayload,
|
||||
startStaticServer,
|
||||
setupRoutes,
|
||||
injectState,
|
||||
outPath,
|
||||
RST, GRN, YEL, MAG, CYN, GRY, BOLD,
|
||||
} from './capture-readme-screenshots.mjs';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const GIF_COLORS = 192;
|
||||
|
||||
// ─── ffmpeg conversion (palette recipe from capture-subagent-gif.mjs) ────────
|
||||
|
||||
function webmToGif(videoPath, gifPath, { ss, duration, width, fps }) {
|
||||
// One GLOBAL palette (default stats_mode=full) + ordered dither: per-frame
|
||||
// palettes (stats_mode=single:new=1) make dirty rectangles visibly mismatch
|
||||
// on flat dark UI, and error-diffusion dither shimmers between frames.
|
||||
const filters = `fps=${fps},scale=${width}:-1:flags=lanczos`;
|
||||
execSync(
|
||||
`ffmpeg -y -loglevel error -ss ${ss.toFixed(2)} -t ${duration} -i "${videoPath}" ` +
|
||||
`-vf "${filters},split[s0][s1];[s0]palettegen=max_colors=${GIF_COLORS}:reserve_transparent=0[p];` +
|
||||
`[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" "${gifPath}"`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Scene 1: subagent demo ──────────────────────────────────────────────────
|
||||
|
||||
const SUBAGENT_VIEWPORT = { width: 1440, height: 810 };
|
||||
|
||||
// Terminal content visible before the agents spawn
|
||||
const TERMINAL_PRESPAWN = [
|
||||
'',
|
||||
`${GRN}●${RST} Working on ${CYN}/home/arkon/codeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`,
|
||||
'',
|
||||
`${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`,
|
||||
` ${GRY}░${RST} Read ${BOLD}127${RST} lines ${GRY}│${RST} ${CYN}1.2KB${RST}`,
|
||||
'',
|
||||
`${GRN}●${RST} ${BOLD}Bash${RST}(find . -name "*.ts" -not -path "*/node_modules/*" | head -20)`,
|
||||
` ${GRY}░${RST} ./src/index.ts`,
|
||||
` ${GRY}░${RST} ./src/session.ts`,
|
||||
` ${GRY}░${RST} ./src/web/server.ts`,
|
||||
` ${GRY}░${RST} ${GRY}... (17 more)${RST}`,
|
||||
'',
|
||||
`${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`,
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
function makeAgent(agentId, description, startedOffsetMs) {
|
||||
return {
|
||||
agentId,
|
||||
sessionId: 'claude-sess-w1-0001',
|
||||
projectHash: 'abc123',
|
||||
filePath: `/tmp/${agentId}.jsonl`,
|
||||
startedAt: new Date(Date.now() - startedOffsetMs).toISOString(),
|
||||
lastActivityAt: Date.now(),
|
||||
status: 'active',
|
||||
toolCallCount: 0,
|
||||
entryCount: 0,
|
||||
fileSize: 4000,
|
||||
description,
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
modelShort: 'haiku',
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
parentSessionId: SESSION_IDS.w1,
|
||||
};
|
||||
}
|
||||
|
||||
// Timeline events: t (ms from scene start) + kind
|
||||
// term — write raw data to the session terminal
|
||||
// discover — register subagent + open + position its floating window
|
||||
// tool — stream a tool call into an agent window
|
||||
// msg — stream an assistant message into an agent window
|
||||
// complete — flip an agent to completed
|
||||
function buildSubagentTimeline() {
|
||||
const T = (lines) => lines.join('\r\n') + '\r\n';
|
||||
const tool = (t, agentId, name, input) => ({ t, kind: 'tool', agentId, tool: name, input });
|
||||
const msg = (t, agentId, text) => ({ t, kind: 'msg', agentId, text });
|
||||
|
||||
return [
|
||||
{
|
||||
t: 600,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
{
|
||||
t: 1000,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-001', 'Find and document all API endpoints in src/', 2000),
|
||||
x: 440, y: 45,
|
||||
},
|
||||
tool(1500, 'agent-001', 'Glob', { pattern: 'src/**/*.ts' }),
|
||||
{
|
||||
t: 2000,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(2200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/server.ts' }),
|
||||
{
|
||||
t: 2500,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-002', 'Explore and understand test structure in test/', 1200),
|
||||
x: 880, y: 45,
|
||||
},
|
||||
tool(3000, 'agent-002', 'Glob', { pattern: 'test/**/*.test.ts' }),
|
||||
{
|
||||
t: 3300,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(3500, 'agent-001', 'Grep', { pattern: 'app\\.get|app\\.post|app\\.delete', path: 'src/' }),
|
||||
{
|
||||
t: 3800,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-003', 'Analyze TypeScript type definitions in src/types.ts', 400),
|
||||
x: 660, y: 400,
|
||||
},
|
||||
tool(4100, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }),
|
||||
{
|
||||
t: 4500,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(4700, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types.ts' }),
|
||||
tool(5200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/schemas.ts' }),
|
||||
tool(5600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/config/vitest.config.ts' }),
|
||||
tool(6100, 'agent-003', 'Grep', { pattern: 'export (interface|type)', path: 'src/types/' }),
|
||||
msg(6700, 'agent-001', 'Found 47 API endpoints across server.ts. Documenting REST paths...'),
|
||||
tool(7100, 'agent-002', 'Grep', { pattern: 'const PORT =', path: 'test/' }),
|
||||
msg(7700, 'agent-002', 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...'),
|
||||
tool(8100, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types/index.ts' }),
|
||||
msg(8700, 'agent-003', 'Mapped 38 exported interfaces across 15 domain files. Building summary...'),
|
||||
{
|
||||
t: 9300,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls — Glob, Read(server.ts), Grep(endpoints)...${RST}`,
|
||||
`${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls — Glob, Read(test-utils), Read(vitest.config)...${RST}`,
|
||||
`${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}7 tool calls — Read(types.ts), Grep(interface)...${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(10100, 'agent-001', 'Glob', { pattern: 'src/web/routes/*.ts' }),
|
||||
tool(10600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/setup.ts' }),
|
||||
tool(11100, 'agent-003', 'Grep', { pattern: 'assertNever', path: 'src/' }),
|
||||
{
|
||||
t: 11600,
|
||||
kind: 'term',
|
||||
data: T([`${GRN}●${RST} ${GRY}171.8k, 13s${RST} ${GRY}│${RST} ${GRY}1.7k tokens${RST} ${GRY}│${RST} ${GRY}thinking${RST}`, '']),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const SUBAGENT_TAIL_HOLD = 2500; // hold the final frame
|
||||
|
||||
async function recordSubagentScene(browser, videoDir) {
|
||||
console.log('\n1/2 Recording subagent-demo...');
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: SUBAGENT_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
recordVideo: { dir: videoDir, size: SUBAGENT_VIEWPORT },
|
||||
});
|
||||
const recStart = Date.now();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
// Start with NO subagents — they appear during the recording
|
||||
const initPayload = buildInitPayload(STANDARD_SESSIONS);
|
||||
await setupRoutes(page, initPayload, TERMINAL_PRESPAWN);
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await injectState(page, initPayload, TERMINAL_PRESPAWN, SESSION_IDS.w1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
try { window.app?.fitAddon?.fit(); } catch {}
|
||||
window.app?.terminal?.scrollToBottom();
|
||||
});
|
||||
await sleep(500);
|
||||
|
||||
const timeline = buildSubagentTimeline();
|
||||
const totalMs = Math.max(...timeline.map((e) => e.t)) + SUBAGENT_TAIL_HOLD;
|
||||
const sceneStart = Date.now();
|
||||
|
||||
// Run the whole timeline inside the page so events interleave naturally
|
||||
await page.evaluate((events) => {
|
||||
const app = window.app;
|
||||
for (const ev of events) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (ev.kind === 'term') {
|
||||
app.terminal.write(ev.data);
|
||||
app.terminal.scrollToBottom();
|
||||
} else if (ev.kind === 'discover') {
|
||||
app._onSubagentDiscovered(ev.agent);
|
||||
app.openSubagentWindow(ev.agent.agentId);
|
||||
// The spawn animation (400ms) lands on the auto-grid; glide to our tile after it
|
||||
setTimeout(() => {
|
||||
const win = app.subagentWindows.get(ev.agent.agentId);
|
||||
if (win?.element) {
|
||||
win.element.style.transition = 'left 0.25s ease, top 0.25s ease';
|
||||
win.element.style.left = `${ev.x}px`;
|
||||
win.element.style.top = `${ev.y}px`;
|
||||
}
|
||||
}, 520);
|
||||
setTimeout(() => {
|
||||
const win = app.subagentWindows.get(ev.agent.agentId);
|
||||
if (win?.element) win.element.style.transition = '';
|
||||
app.updateConnectionLines();
|
||||
}, 850);
|
||||
} else if (ev.kind === 'tool') {
|
||||
app._onSubagentToolCall({
|
||||
agentId: ev.agentId,
|
||||
tool: ev.tool,
|
||||
input: ev.input,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else if (ev.kind === 'msg') {
|
||||
app._onSubagentMessage({
|
||||
agentId: ev.agentId,
|
||||
role: 'assistant',
|
||||
text: ev.text,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else if (ev.kind === 'complete') {
|
||||
app._onSubagentCompleted({ agentId: ev.agentId, timestamp: new Date().toISOString() });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('timeline event failed', ev, err);
|
||||
}
|
||||
}, ev.t);
|
||||
}
|
||||
}, timeline);
|
||||
|
||||
await sleep(totalMs + 500);
|
||||
|
||||
await page.close();
|
||||
const videoPath = await page.video().path();
|
||||
await context.close();
|
||||
|
||||
return {
|
||||
videoPath,
|
||||
ss: (sceneStart - recStart) / 1000 - 0.4,
|
||||
duration: (totalMs + 400) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Scene 2: zerolag typing comparison ──────────────────────────────────────
|
||||
|
||||
const ZEROLAG_VIEWPORT = { width: 1280, height: 470 };
|
||||
const TYPED_TEXT = 'echo "zero lag typing from anywhere"';
|
||||
const TYPE_INTERVAL_MS = 110;
|
||||
const REMOTE_FLUSH_MS = 350; // server-echo pane flushes queued chars in bursts
|
||||
const ZEROLAG_TAIL_HOLD = 1800;
|
||||
|
||||
const ZEROLAG_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="http://localhost:${PORT}/vendor/xterm.css">
|
||||
<script src="http://localhost:${PORT}/vendor/xterm.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 1280px; height: 470px; background: #0a0a0c;
|
||||
display: flex; align-items: center; justify-content: center; gap: 48px;
|
||||
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.pane { width: 560px; }
|
||||
.card {
|
||||
background: #131316; border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
|
||||
}
|
||||
.card-head {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; align-self: center; }
|
||||
.title { font-size: 15px; font-weight: 600; color: #e8e8ea; }
|
||||
.sub { font-size: 12.5px; color: #8b8b92; }
|
||||
.term { padding: 16px 8px 12px 16px; height: 165px; }
|
||||
.good .dot { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,0.7); }
|
||||
.bad .dot { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,0.7); }
|
||||
.tag {
|
||||
margin-top: 14px; text-align: center; font-size: 14.5px; color: #7e7e86;
|
||||
}
|
||||
.tag b { color: #22c55e; font-weight: 600; }
|
||||
.bad-tag b { color: #ef4444; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pane">
|
||||
<div class="card good">
|
||||
<div class="card-head">
|
||||
<span class="dot"></span>
|
||||
<span class="title">With zerolag-input</span>
|
||||
<span class="sub">instant local echo</span>
|
||||
</div>
|
||||
<div class="term" id="termLeft"></div>
|
||||
</div>
|
||||
<div class="tag">keystrokes echo in <b>0 ms</b></div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="card bad">
|
||||
<div class="card-head">
|
||||
<span class="dot"></span>
|
||||
<span class="title">Without</span>
|
||||
<span class="sub">server round-trip echo</span>
|
||||
</div>
|
||||
<div class="term" id="termRight"></div>
|
||||
</div>
|
||||
<div class="tag bad-tag">keystrokes echo after <b>~350 ms</b></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
async function recordZerolagScene(browser, videoDir) {
|
||||
console.log('\n2/2 Recording zerolag-demo...');
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: ZEROLAG_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
recordVideo: { dir: videoDir, size: ZEROLAG_VIEWPORT },
|
||||
});
|
||||
const recStart = Date.now();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
await page.setContent(ZEROLAG_HTML, { waitUntil: 'load' });
|
||||
await page.waitForFunction(() => typeof Terminal !== 'undefined');
|
||||
|
||||
await page.evaluate(() => {
|
||||
const theme = {
|
||||
background: '#131316',
|
||||
foreground: '#e8e8ea',
|
||||
cursor: '#22c55e',
|
||||
cursorAccent: '#131316',
|
||||
};
|
||||
const mk = (id) => {
|
||||
const term = new Terminal({
|
||||
cols: 44,
|
||||
rows: 5,
|
||||
fontSize: 20,
|
||||
fontFamily: "'SF Mono', 'Cascadia Code', Menlo, monospace",
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
theme,
|
||||
});
|
||||
term.open(document.getElementById(id));
|
||||
term.write('\x1b[32m❯\x1b[0m ');
|
||||
return term;
|
||||
};
|
||||
window.termLeft = mk('termLeft');
|
||||
window.termRight = mk('termRight');
|
||||
});
|
||||
await sleep(600);
|
||||
|
||||
const sceneStart = Date.now();
|
||||
const typingMs = TYPED_TEXT.length * TYPE_INTERVAL_MS;
|
||||
const totalMs = typingMs + REMOTE_FLUSH_MS + ZEROLAG_TAIL_HOLD;
|
||||
|
||||
await page.evaluate(
|
||||
({ text, interval, flushEvery }) => {
|
||||
let i = 0;
|
||||
const remoteQueue = [];
|
||||
const typer = setInterval(() => {
|
||||
if (i >= text.length) { clearInterval(typer); return; }
|
||||
const ch = text[i++];
|
||||
window.termLeft.write(ch); // local echo: instant
|
||||
remoteQueue.push(ch); // server echo: waits for the round-trip
|
||||
}, interval);
|
||||
const flusher = setInterval(() => {
|
||||
if (remoteQueue.length) window.termRight.write(remoteQueue.splice(0).join(''));
|
||||
if (i >= text.length && remoteQueue.length === 0) clearInterval(flusher);
|
||||
}, flushEvery);
|
||||
},
|
||||
{ text: TYPED_TEXT, interval: TYPE_INTERVAL_MS, flushEvery: REMOTE_FLUSH_MS }
|
||||
);
|
||||
|
||||
await sleep(totalMs + 400);
|
||||
|
||||
await page.close();
|
||||
const videoPath = await page.video().path();
|
||||
await context.close();
|
||||
|
||||
return {
|
||||
videoPath,
|
||||
ss: (sceneStart - recStart) / 1000 - 0.6, // small lead-in with idle cursors
|
||||
duration: (totalMs + 600) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log('='.repeat(60));
|
||||
console.log('Codeman README GIF Capture');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const server = await startStaticServer();
|
||||
const videoDir = mkdtempSync(join(tmpdir(), 'codeman-gifs-'));
|
||||
let browser;
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||
});
|
||||
|
||||
const sub = await recordSubagentScene(browser, videoDir);
|
||||
const subGif = outPath('images', 'subagent-demo.gif');
|
||||
webmToGif(sub.videoPath, subGif, { ss: Math.max(0, sub.ss), duration: sub.duration, width: 960, fps: 8 });
|
||||
console.log(` Saved: ${subGif}`);
|
||||
|
||||
const zl = await recordZerolagScene(browser, videoDir);
|
||||
const zlGif = outPath('images', 'zerolag-demo.gif');
|
||||
webmToGif(zl.videoPath, zlGif, { ss: Math.max(0, zl.ss), duration: zl.duration, width: 900, fps: 10 });
|
||||
console.log(` Saved: ${zlGif}`);
|
||||
|
||||
console.log('\nDone.');
|
||||
} catch (err) {
|
||||
console.error('\nFatal error:', err.message);
|
||||
console.error(err.stack);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
server.close();
|
||||
rmSync(videoDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => process.exit(1));
|
||||
|
||||
main();
|
||||
@@ -51,6 +51,9 @@ async function newCtx(browser) {
|
||||
localStorage.setItem('codeman:skin', skin);
|
||||
localStorage.setItem('codeman-font-size', String(font));
|
||||
const blob = { skin, showFileBrowser: false, showProjectInsights: false };
|
||||
// Don't auto-hide subagent windows that belong to a non-active tab — the
|
||||
// subagent scene re-homes agents and needs both windows visible at once.
|
||||
blob.subagentActiveTabOnly = false;
|
||||
if (planUsage) blob.showPlanUsageLimits = true;
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
|
||||
} catch {
|
||||
@@ -136,9 +139,9 @@ async function sceneSubagent(browser) {
|
||||
const sessions = await listSessions(page);
|
||||
const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id;
|
||||
if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId);
|
||||
// Wait (up to ~25s) for live subagents to arrive via SSE into app.subagents.
|
||||
// Wait (up to ~45s) for live subagents to arrive via SSE into app.subagents.
|
||||
let agents = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
for (let i = 0; i < 45; i++) {
|
||||
agents = await page.evaluate(() =>
|
||||
Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' }))
|
||||
);
|
||||
@@ -151,6 +154,44 @@ async function sceneSubagent(browser) {
|
||||
await context.close();
|
||||
return;
|
||||
}
|
||||
// The window body renders from app.subagentActivity, which fills ONLY from live
|
||||
// SSE tool-call/progress events — a fresh client never gets past activity replayed.
|
||||
// So sit connected and wait for live activity to accumulate, then open the two
|
||||
// agents that actually have content (otherwise the windows read "No activity yet").
|
||||
let active = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
active = await page.evaluate(() =>
|
||||
Array.from(window.app.subagentActivity?.entries?.() || [])
|
||||
.filter(([, arr]) => Array.isArray(arr) && arr.length >= 1)
|
||||
.map(([id, arr]) => ({ id, n: arr.length }))
|
||||
.sort((a, b) => b.n - a.n)
|
||||
);
|
||||
if (active.length >= 2) break;
|
||||
// xhigh-effort agents churn in bursts between long thinking pauses, so be
|
||||
// patient (~150s); accept a single populated window after ~45s if that's all.
|
||||
if (i >= 30 && active.length >= 1) break;
|
||||
await sleep(1500);
|
||||
}
|
||||
console.log(' agents with live activity:', JSON.stringify(active));
|
||||
const openIds = (active.length ? active : agents).map((a) => a.id);
|
||||
// Capture-only DOM nudge: on fresh dev sessions, a tab's claudeSessionId stays the
|
||||
// Codeman id and never becomes the real Claude conversation UUID, so the window
|
||||
// open-gate (claudeSessionId === agent.sessionId) + the activeTabOnly hide rule both
|
||||
// fail. Re-home the chosen agents onto the active tab and align its claudeSessionId
|
||||
// to the agents' (shared) sessionId so the windows open AND show their live activity.
|
||||
await page.evaluate(
|
||||
(ids) => {
|
||||
const activeId = window.app.activeSessionId;
|
||||
const tab = window.app.sessions.get(activeId);
|
||||
ids.slice(0, 2).forEach((id) => {
|
||||
const a = window.app.subagents.get(id);
|
||||
if (!a) return;
|
||||
a.parentSessionId = activeId;
|
||||
if (tab && a.sessionId) tab.claudeSessionId = a.sessionId;
|
||||
});
|
||||
},
|
||||
openIds
|
||||
);
|
||||
await page.evaluate(
|
||||
(ids) => {
|
||||
ids.slice(0, 2).forEach((id) => {
|
||||
@@ -159,22 +200,33 @@ async function sceneSubagent(browser) {
|
||||
} catch {}
|
||||
});
|
||||
},
|
||||
agents.map((a) => a.id)
|
||||
openIds
|
||||
);
|
||||
await sleep(2000);
|
||||
await page.evaluate(() => {
|
||||
// Viewport-relative tiling: center two subagent windows over the terminal so
|
||||
// the layout adapts to whatever VW/VH the capture uses (e.g. the HQ 1100×650
|
||||
// recipe) instead of overflowing at narrower widths.
|
||||
const wins = Array.from(window.app.subagentWindows.values());
|
||||
const place = [
|
||||
{ left: 360, top: 60, w: 430, h: 330 },
|
||||
{ left: 810, top: 60, w: 430, h: 330 },
|
||||
];
|
||||
const W = window.innerWidth;
|
||||
const H = window.innerHeight;
|
||||
const winW = Math.min(440, Math.floor((W - 60) / 2 - 10));
|
||||
const winH = Math.min(360, Math.floor(H * 0.56));
|
||||
const top = Math.floor(H * 0.16);
|
||||
const gap = 16;
|
||||
const totalW = winW * 2 + gap;
|
||||
const startLeft = Math.max(16, Math.floor((W - totalW) / 2));
|
||||
wins.slice(0, 2).forEach((win, i) => {
|
||||
const el = win.element;
|
||||
const p = place[i];
|
||||
el.style.left = p.left + 'px';
|
||||
el.style.top = p.top + 'px';
|
||||
el.style.width = p.w + 'px';
|
||||
el.style.height = p.h + 'px';
|
||||
// Force visible: a freshly opened window may be hidden by the activeTabOnly
|
||||
// rule before we override it (we also seed subagentActiveTabOnly:false).
|
||||
win.hidden = false;
|
||||
win.minimized = false;
|
||||
el.style.display = 'flex';
|
||||
el.style.left = startLeft + i * (winW + gap) + 'px';
|
||||
el.style.top = top + 'px';
|
||||
el.style.width = winW + 'px';
|
||||
el.style.height = winH + 'px';
|
||||
});
|
||||
});
|
||||
await sleep(1500);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+166
@@ -584,7 +584,11 @@ program
|
||||
'--allow-unauthenticated-network',
|
||||
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
|
||||
)
|
||||
.option('--multiuser', 'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)')
|
||||
.action(async (options) => {
|
||||
// The flag is surfaced to the rest of the process via the env var so
|
||||
// isMultiUserMode() has a single source of truth (see config/multiuser.ts).
|
||||
if (options.multiuser) process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { startWebServer } = await import('./web/server.js');
|
||||
const host = options.host;
|
||||
const port = parseInt(options.port, 10);
|
||||
@@ -626,6 +630,168 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Multi-user Commands ============
|
||||
//
|
||||
// Operate directly on ~/.codeman/users.json (via user-store) with NO running
|
||||
// server, honoring CODEMAN_INSTANCE. This is the headless bootstrap path and the
|
||||
// recovery answer to "locked out: last admin forgot password".
|
||||
|
||||
/** Read a password from stdin without echoing. Falls back to plain read on non-TTY. */
|
||||
function promptHiddenPassword(question: string): Promise<string> {
|
||||
const stdin = process.stdin;
|
||||
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
||||
// Non-interactive: read a single line from stdin.
|
||||
return new Promise((resolve) => {
|
||||
let buf = '';
|
||||
stdin.setEncoding('utf8');
|
||||
stdin.on('data', (d) => (buf += d));
|
||||
stdin.on('end', () => resolve(buf.replace(/\r?\n$/, '')));
|
||||
});
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
process.stdout.write(question);
|
||||
let input = '';
|
||||
stdin.setRawMode(true);
|
||||
stdin.resume();
|
||||
stdin.setEncoding('utf8');
|
||||
const onData = (chunk: string) => {
|
||||
for (const c of chunk) {
|
||||
if (c === '\n' || c === '\r' || c === '\u0004') {
|
||||
stdin.setRawMode!(false);
|
||||
stdin.pause();
|
||||
stdin.removeListener('data', onData);
|
||||
process.stdout.write('\n');
|
||||
resolve(input);
|
||||
return;
|
||||
} else if (c === '\u0003') {
|
||||
process.stdout.write('\n');
|
||||
process.exit(1);
|
||||
} else if (c === '\u007f' || c === '\b') {
|
||||
input = input.slice(0, -1);
|
||||
} else {
|
||||
input += c;
|
||||
}
|
||||
}
|
||||
};
|
||||
stdin.on('data', onData);
|
||||
});
|
||||
}
|
||||
|
||||
function readAllStdin(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let buf = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (d) => (buf += d));
|
||||
process.stdin.on('end', () => resolve(buf.replace(/\r?\n$/, '')));
|
||||
});
|
||||
}
|
||||
|
||||
const usersCmd = program.command('users').description('Manage multi-user accounts (~/.codeman/users.json)');
|
||||
|
||||
usersCmd
|
||||
.command('add <name>')
|
||||
.description('Create a user (prompts for password; use --password-stdin for scripts)')
|
||||
.option('--admin', 'Create as an admin')
|
||||
.option('--password-stdin', 'Read the password from stdin instead of prompting')
|
||||
.action(async (name, options) => {
|
||||
const { createUser, isValidUsername } = await import('./user-store.js');
|
||||
if (!isValidUsername(name)) {
|
||||
console.error(chalk.red('✗ Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])'));
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
let password: string;
|
||||
if (options.passwordStdin) {
|
||||
password = await readAllStdin();
|
||||
} else {
|
||||
password = await promptHiddenPassword('New password: ');
|
||||
const confirm = await promptHiddenPassword('Confirm password: ');
|
||||
if (password !== confirm) {
|
||||
console.error(chalk.red('✗ Passwords do not match'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
console.error(chalk.red('✗ Password must be at least 8 characters'));
|
||||
process.exit(1);
|
||||
}
|
||||
const user = await createUser({ username: name, role: options.admin ? 'admin' : 'user', password });
|
||||
console.log(chalk.green(`✓ Created ${user.role} "${user.username}"`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
usersCmd
|
||||
.command('passwd <name>')
|
||||
.description('Reset a user password')
|
||||
.option('--password-stdin', 'Read the new password from stdin instead of prompting')
|
||||
.action(async (name, options) => {
|
||||
const { setPassword } = await import('./user-store.js');
|
||||
try {
|
||||
let password: string;
|
||||
if (options.passwordStdin) {
|
||||
password = await readAllStdin();
|
||||
} else {
|
||||
password = await promptHiddenPassword('New password: ');
|
||||
const confirm = await promptHiddenPassword('Confirm password: ');
|
||||
if (password !== confirm) {
|
||||
console.error(chalk.red('✗ Passwords do not match'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
await setPassword(name, password, { mustChangePassword: false });
|
||||
console.log(chalk.green(`✓ Password updated for "${name}"`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
usersCmd
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List all users')
|
||||
.action(async () => {
|
||||
const { readUsers } = await import('./user-store.js');
|
||||
const users = await readUsers(true);
|
||||
if (users.length === 0) {
|
||||
console.log(chalk.yellow('No users defined (run: codeman users add <name> --admin)'));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.bold('\nUsers:'));
|
||||
for (const u of users) {
|
||||
const role = u.role === 'admin' ? chalk.magenta('admin') : chalk.cyan('user ');
|
||||
const state = u.disabled ? chalk.red('disabled') : chalk.green('enabled ');
|
||||
const flags = [u.mustChangePassword ? 'must-change-pw' : '', u.canBypassPermissions ? 'can-bypass' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
console.log(` ${role} ${state} ${u.username}${flags ? chalk.gray(` [${flags}]`) : ''}`);
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
|
||||
usersCmd
|
||||
.command('rm <name>')
|
||||
.description('Delete a user')
|
||||
.option('--delete-space', "Also delete the user's ~/codeman-users/<name> space")
|
||||
.action(async (name, options) => {
|
||||
const { deleteUser, deleteUserSpace } = await import('./user-store.js');
|
||||
try {
|
||||
await deleteUser(name);
|
||||
if (options.deleteSpace) {
|
||||
await deleteUserSpace(name);
|
||||
console.log(chalk.green(`✓ Deleted user "${name}" and their space`));
|
||||
} else {
|
||||
console.log(chalk.green(`✓ Deleted user "${name}" (space left on disk)`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.alias('check-deps')
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @fileoverview Multi-user mode gating + limits (opt-in, off by default).
|
||||
*
|
||||
* Multi-user mode is enabled by `codeman web --multiuser` (which sets
|
||||
* `CODEMAN_MULTIUSER=1`) or the env var directly. When OFF, behavior is
|
||||
* byte-identical to today: `users.json` is never read and all ownership scoping
|
||||
* is bypassed. Everything here is per-instance like the rest of Codeman: a beta
|
||||
* instance (`CODEMAN_INSTANCE=beta`) has its own `users.json` via `dataPath()`,
|
||||
* and its user spaces live under the same shared `~/codeman-users` as prod (like
|
||||
* `~/codeman-cases`), unless `CODEMAN_USER_SPACES_DIR` overrides it.
|
||||
*
|
||||
* See `docs/multi-user-plan.md` sections 3, 4.2, and 11.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { MAX_CONCURRENT_SESSIONS } from './map-limits.js';
|
||||
|
||||
/**
|
||||
* Whether multi-user mode is active. Read from the environment each call so it is
|
||||
* stable for the process lifetime (env does not change after boot) and trivially
|
||||
* overridable in tests. Accepts `1` or `true`.
|
||||
*/
|
||||
export function isMultiUserMode(): boolean {
|
||||
const v = process.env.CODEMAN_MULTIUSER;
|
||||
return v === '1' || v === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Root of per-user spaces: `~/codeman-users` (sibling of `~/codeman-cases`).
|
||||
* Overridable via `CODEMAN_USER_SPACES_DIR` (used by tests). Resolved lazily so a
|
||||
* test can point it at a temp dir before the first call.
|
||||
*/
|
||||
export function getUserSpacesDir(): string {
|
||||
return process.env.CODEMAN_USER_SPACES_DIR || join(homedir(), 'codeman-users');
|
||||
}
|
||||
|
||||
/** Absolute path to a user's top-level space: `<USER_SPACES_DIR>/<username>[/segments]`. */
|
||||
export function userSpacePath(username: string, ...segments: string[]): string {
|
||||
return join(getUserSpacesDir(), username, ...segments);
|
||||
}
|
||||
|
||||
/** Absolute path to a user's cases dir: `<USER_SPACES_DIR>/<username>/cases`. */
|
||||
export function userCasesDir(username: string): string {
|
||||
return join(getUserSpacesDir(), username, 'cases');
|
||||
}
|
||||
|
||||
/** Maximum number of user accounts (default 25, env `CODEMAN_MAX_USERS`). */
|
||||
export function maxUsers(): number {
|
||||
const n = Number(process.env.CODEMAN_MAX_USERS);
|
||||
return Number.isInteger(n) && n > 0 ? n : 25;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user concurrent-session cap (the fairness lever). Defaults to half the
|
||||
* global cap; overridable via `CODEMAN_MAX_SESSIONS_PER_USER`. The global cap
|
||||
* (MAX_CONCURRENT_SESSIONS) still applies on top and is shared across users.
|
||||
*/
|
||||
export function maxSessionsPerUser(): number {
|
||||
const n = Number(process.env.CODEMAN_MAX_SESSIONS_PER_USER);
|
||||
if (Number.isInteger(n) && n > 0) return n;
|
||||
return Math.max(1, Math.floor(MAX_CONCURRENT_SESSIONS / 2));
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { SseEvent } from '../web/sse-events.js';
|
||||
import { CronJobSchema } from '../web/schemas.js';
|
||||
import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.js';
|
||||
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js';
|
||||
import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.js';
|
||||
import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
|
||||
import {
|
||||
DEFAULT_BLOCKED_TREES,
|
||||
@@ -25,6 +27,7 @@ import { validateSessionFilePath } from '../web/route-helpers.js';
|
||||
import { computeNextRunAt, dueKeyFor } from './cron-time.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.js';
|
||||
import type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js';
|
||||
import type { GeminiConfig } from '../types/session.js';
|
||||
import type { CronJobInput } from './cron-input.js';
|
||||
|
||||
/** The subset of the route context the cron depends on. */
|
||||
@@ -108,7 +111,7 @@ export class CronService {
|
||||
|
||||
// ──────────────────────────── Mutations ───────────────────────────
|
||||
|
||||
createJob(input: CronJobInput): CronJob {
|
||||
createJob(input: CronJobInput, owner?: string): CronJob {
|
||||
if (Object.keys(this.store.getCronJobs()).length >= MAX_CRON_JOBS) {
|
||||
throw this.badRequest(`Maximum number of cron jobs (${MAX_CRON_JOBS}) reached`);
|
||||
}
|
||||
@@ -117,6 +120,7 @@ export class CronService {
|
||||
const job: CronJob = {
|
||||
id: uuidv4(),
|
||||
name: input.name,
|
||||
owner,
|
||||
agentType: input.agentType,
|
||||
workingDir: input.workingDir,
|
||||
launchCommand: input.launchCommand,
|
||||
@@ -328,6 +332,12 @@ export class CronService {
|
||||
return this.failRun(job, run, 'workingDir does not exist');
|
||||
}
|
||||
|
||||
// Section 6.3: defense-in-depth workingDir confinement re-check at FIRE time against the
|
||||
// owner's CURRENT space (complements the create/update gate). No-op in single-user / unset owner.
|
||||
if (!(await isWorkingDirAllowedForUsername(job.owner, job.workingDir))) {
|
||||
return this.failRun(job, run, 'workingDir is outside the owner workspace');
|
||||
}
|
||||
|
||||
// Recurring jobs: close the still-open session created by this job's
|
||||
// previous run before launching the next (default ON, opt-out via
|
||||
// autoClosePreviousSession:false) — otherwise an unattended interval/daily
|
||||
@@ -336,10 +346,21 @@ export class CronService {
|
||||
await this.closePreviousRunSessions(job, run.id);
|
||||
}
|
||||
|
||||
// Respect the global session cap.
|
||||
if (this.deps.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
// Respect the global cap AND the owner's per-user cap (multi-user).
|
||||
const cap = sessionCapacityState(this.deps.sessions, job.owner);
|
||||
if (cap.atGlobalCap) {
|
||||
return this.failRun(job, run, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`);
|
||||
}
|
||||
if (cap.atUserCap) {
|
||||
return this.failRun(job, run, `Owner's per-user session limit reached`);
|
||||
}
|
||||
|
||||
// Section 6.3: re-resolve the owner's grant at FIRE time (it may have been revoked
|
||||
// since create). Gates shell/launchCommand AND clamps the external-CLI bypass below.
|
||||
const ownerGranted = await canUsernameRunPrivilegedCommands(job.owner);
|
||||
if ((job.agentType === 'shell' || job.launchCommand) && !ownerGranted) {
|
||||
return this.failRun(job, run, 'Owner lacks the can-bypass-permissions grant for shell/launchCommand jobs');
|
||||
}
|
||||
|
||||
// Create + start the session (mirrors the quick-start route flow).
|
||||
let session: Session;
|
||||
@@ -348,7 +369,15 @@ export class CronService {
|
||||
const globalNice = await this.deps.getGlobalNiceConfig();
|
||||
const modelConfig = await this.deps.getModelConfig();
|
||||
const claudeModeConfig = await this.deps.getClaudeModeConfig();
|
||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner);
|
||||
const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined;
|
||||
// Section 6.3: cron carries no per-CLI config, so buildGeminiCommand(undefined)
|
||||
// would default a non-granted owner to `--approval-mode yolo` (classifier-free) —
|
||||
// materialize auto_edit for a non-granted gemini owner, mirroring the route clamp
|
||||
// (#15). Granted/admin/single-user leave it undefined → yolo parity. Codex's absent
|
||||
// config already defaults to the safe sandbox, so no clamp is needed there.
|
||||
const geminiConfig: GeminiConfig | undefined =
|
||||
mode === 'gemini' && !ownerGranted ? { approvalMode: 'auto_edit' } : undefined;
|
||||
session = new Session({
|
||||
workingDir: job.workingDir,
|
||||
mode,
|
||||
@@ -357,8 +386,10 @@ export class CronService {
|
||||
useMux: true,
|
||||
niceConfig: globalNice,
|
||||
model,
|
||||
claudeMode: claudeModeConfig.claudeMode,
|
||||
claudeMode: effectiveClaudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
geminiConfig,
|
||||
owner: job.owner,
|
||||
});
|
||||
this.deps.addSession(session);
|
||||
this.store.incrementSessionsCreated();
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* @fileoverview Docker case export / import: move a container (toolchain + any
|
||||
* in-image changes) PLUS its workspace to another machine as one portable
|
||||
* `.codeman-container.tgz`, and restore it.
|
||||
*
|
||||
* A full-image export = `docker commit` the running container to an image ->
|
||||
* `docker save` that image -> tar the bind-mounted workspace -> a manifest, all
|
||||
* bundled into one gzip tarball. A workspace-only export skips the image (fast,
|
||||
* files-only). Import validates the manifest + per-member checksums, extracts the
|
||||
* workspace with a path-traversal guard, `docker load`s the image and RE-TAGS it
|
||||
* into a quarantined namespace (never overwriting a local tag), and hands the
|
||||
* caller enough to recreate a hardened case on the destination.
|
||||
*
|
||||
* Safety (all from the design critic): pause the container spanning the workspace
|
||||
* tar AND the commit so the two artifacts are mutually consistent; a free-space
|
||||
* precheck (a full docker graph wedges EVERY session on the host); `docker rmi`
|
||||
* the intermediate image in a finally; sealed containers refuse a full-image
|
||||
* export (an in-container login would ride the committed layer); import rejects
|
||||
* absolute / `..` tar members and checksum mismatches. Bounded by
|
||||
* runWithConversionLimit so N exports cannot fork-bomb the host.
|
||||
*
|
||||
* @module docker-export
|
||||
*/
|
||||
|
||||
import { createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, basename } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { DockerEngine, SessionDocker } from './types.js';
|
||||
import { runWithConversionLimit } from './document-conversion-limiter.js';
|
||||
|
||||
const IS_TEST_MODE = !!process.env.VITEST;
|
||||
|
||||
/** Refuse to export when the target filesystem has less than this free (a full graph wedges the daemon). */
|
||||
export const DOCKER_EXPORT_MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
|
||||
|
||||
/** Manifest schema version (bump on any breaking field change). */
|
||||
export const DOCKER_EXPORT_SCHEMA = 1;
|
||||
|
||||
export type DockerExportMode = 'full' | 'workspace';
|
||||
|
||||
export interface DockerExportManifest {
|
||||
schemaVersion: number;
|
||||
caseName: string;
|
||||
mode: DockerExportMode;
|
||||
engine: DockerEngine;
|
||||
image: string;
|
||||
containerWorkdir: string;
|
||||
network: string;
|
||||
createdAt: number;
|
||||
codemanVersion: string;
|
||||
mountCredentials: boolean;
|
||||
/** True when the bundle provably carries no credentials (convenient-mode workspace, or a full image whose creds were bind-mounted and thus never committed). */
|
||||
secretFree: boolean;
|
||||
/** sha256 of each bundle member that is present. */
|
||||
checksums: { image?: string; workspace?: string };
|
||||
}
|
||||
|
||||
// ========== Pure helpers (unit-tested) ==========
|
||||
|
||||
/** Raw argv prefix for the engine (NO shell escaping — used with spawn). */
|
||||
export function dockerArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
|
||||
const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
|
||||
if (docker.context) argv.push('--context', docker.context);
|
||||
if (docker.daemonHost) argv.push('-H', docker.daemonHost);
|
||||
return argv;
|
||||
}
|
||||
|
||||
/** Portable bundle filename for a case export. */
|
||||
export function exportBundleName(caseName: string, timestamp: number, mode: DockerExportMode): string {
|
||||
const suffix = mode === 'workspace' ? 'workspace' : 'container';
|
||||
return `${caseName}-${timestamp}.codeman-${suffix}.tgz`;
|
||||
}
|
||||
|
||||
/** Quarantined image tag for an imported bundle (never overwrites a local tag). */
|
||||
export function importedImageTag(caseName: string, timestamp: number): string {
|
||||
return `codeman/imported-${caseName}:${timestamp}`;
|
||||
}
|
||||
|
||||
/** Intermediate commit tag for a full-image export (unique per export, rmi'd in finally). */
|
||||
export function exportImageTag(caseName: string, timestamp: number): string {
|
||||
return `codeman/export-${caseName}:${timestamp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a tar member path that would escape the extraction root (absolute path
|
||||
* or a `..` component). The import-side traversal guard.
|
||||
*/
|
||||
export function isSafeTarMember(member: string): boolean {
|
||||
const trimmed = member.trim();
|
||||
if (!trimmed || trimmed === './') return true;
|
||||
if (trimmed.startsWith('/')) return false;
|
||||
// Normalize separators and check each component.
|
||||
return !trimmed.split('/').some((part) => part === '..');
|
||||
}
|
||||
|
||||
/** Parse the image id/ref from `docker load` output ("Loaded image: x" / "Loaded image ID: sha256:..."). */
|
||||
export function parseLoadedImageRef(loadOutput: string): string | null {
|
||||
const idMatch = loadOutput.match(/Loaded image ID:\s*(sha256:[0-9a-f]+)/i);
|
||||
if (idMatch) return idMatch[1];
|
||||
const refMatch = loadOutput.match(/Loaded image:\s*(\S+)/i);
|
||||
if (refMatch) return refMatch[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========== IO helpers ==========
|
||||
|
||||
function run(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: { timeout?: number } = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
if (opts.timeout) {
|
||||
timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error(`${cmd} timed out after ${opts.timeout}ms`));
|
||||
}, opts.timeout);
|
||||
}
|
||||
child.stdout.on('data', (d) => (stdout += d));
|
||||
child.stderr.on('data', (d) => (stderr += d));
|
||||
child.on('error', (err) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (code === 0) resolve({ stdout, stderr });
|
||||
else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}: ${stderr.trim()}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream `docker save <tag>` stdout to a raw tar file (no shell, no double-gzip).
|
||||
* Uses stream `pipeline` so completion means the write stream is FULLY flushed to
|
||||
* disk (a naive child 'close' resolves before the last chunks land, truncating the
|
||||
* file — a real bug caught in end-to-end testing), AND waits for a clean exit code.
|
||||
*/
|
||||
async function saveImageToTar(argv: string[], tag: string, outPath: string): Promise<void> {
|
||||
const child = spawn(argv[0], [...argv.slice(1), 'save', tag], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (d) => (stderr += d));
|
||||
const exited = new Promise<void>((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) =>
|
||||
code === 0 ? resolve() : reject(new Error(`docker save exited ${code}: ${stderr.trim()}`))
|
||||
);
|
||||
});
|
||||
// pipeline resolves only after the destination has fully flushed.
|
||||
await Promise.all([pipeline(child.stdout, createWriteStream(outPath)), exited]);
|
||||
}
|
||||
|
||||
async function sha256File(path: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash('sha256');
|
||||
const stream = createReadStream(path);
|
||||
stream.on('data', (d) => hash.update(d));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
async function freeBytes(path: string): Promise<number> {
|
||||
try {
|
||||
const stat = await fs.statfs(path);
|
||||
return Number(stat.bavail) * Number(stat.bsize);
|
||||
} catch {
|
||||
return Number.POSITIVE_INFINITY; // statfs unsupported — don't block
|
||||
}
|
||||
}
|
||||
|
||||
async function isContainerRunning(argv: string[], container: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await run(argv[0], [...argv.slice(1), 'inspect', '-f', '{{.State.Running}}', container], {
|
||||
timeout: 15_000,
|
||||
});
|
||||
return stdout.trim() === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
bundlePath: string;
|
||||
manifest: DockerExportManifest;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a docker case to a portable bundle. Bounded by runWithConversionLimit.
|
||||
* `full` mode commits + saves the image AND tars the workspace; `workspace` mode
|
||||
* tars just the workspace. The container is paused across the artifact capture so
|
||||
* image and workspace are mutually consistent.
|
||||
*/
|
||||
export async function exportDockerCase(params: {
|
||||
docker: SessionDocker;
|
||||
caseName: string;
|
||||
timestamp: number;
|
||||
exportsDir: string;
|
||||
mode: DockerExportMode;
|
||||
codemanVersion: string;
|
||||
}): Promise<ExportResult> {
|
||||
const { docker, caseName, timestamp, exportsDir, mode, codemanVersion } = params;
|
||||
|
||||
if (mode === 'full' && !docker.mountCredentials) {
|
||||
throw new Error(
|
||||
'full-image export is refused for a sealed (mountCredentials:false) container: an in-container login would ride the committed image layer. Use a workspace-only export.'
|
||||
);
|
||||
}
|
||||
|
||||
if (IS_TEST_MODE) {
|
||||
// No real docker/tar under vitest — return a deterministic stub.
|
||||
const manifest: DockerExportManifest = {
|
||||
schemaVersion: DOCKER_EXPORT_SCHEMA,
|
||||
caseName,
|
||||
mode,
|
||||
engine: docker.engine,
|
||||
image: docker.image,
|
||||
containerWorkdir: docker.containerWorkdir,
|
||||
network: docker.network,
|
||||
createdAt: timestamp,
|
||||
codemanVersion,
|
||||
mountCredentials: docker.mountCredentials,
|
||||
secretFree: true,
|
||||
checksums: {},
|
||||
};
|
||||
return { bundlePath: join(exportsDir, exportBundleName(caseName, timestamp, mode)), manifest, sizeBytes: 0 };
|
||||
}
|
||||
|
||||
return runWithConversionLimit(async () => {
|
||||
if (!existsSync(exportsDir)) mkdirSync(exportsDir, { recursive: true });
|
||||
|
||||
const free = await freeBytes(exportsDir);
|
||||
if (free < DOCKER_EXPORT_MIN_FREE_BYTES) {
|
||||
throw new Error(
|
||||
`not enough free space to export (need >= ${Math.round(DOCKER_EXPORT_MIN_FREE_BYTES / 1e9)}GB, have ${Math.round(free / 1e9)}GB). A full docker graph wedges every session on the host.`
|
||||
);
|
||||
}
|
||||
|
||||
const argv = dockerArgv(docker);
|
||||
const bundlePath = join(exportsDir, exportBundleName(caseName, timestamp, mode));
|
||||
const stageDir = join(exportsDir, `.stage-${caseName}-${timestamp}`);
|
||||
mkdirSync(stageDir, { recursive: true });
|
||||
const wasRunning = await isContainerRunning(argv, docker.containerName);
|
||||
let commitTag: string | undefined;
|
||||
|
||||
try {
|
||||
if (wasRunning) {
|
||||
await run(argv[0], [...argv.slice(1), 'pause', docker.containerName], { timeout: 30_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
const checksums: DockerExportManifest['checksums'] = {};
|
||||
|
||||
if (mode === 'full') {
|
||||
commitTag = exportImageTag(caseName, timestamp);
|
||||
// Blank instance-specific committed env so the image carries no stale host refs.
|
||||
await run(
|
||||
argv[0],
|
||||
[
|
||||
...argv.slice(1),
|
||||
'commit',
|
||||
'-c',
|
||||
'ENV CODEMAN_API_URL=',
|
||||
'-c',
|
||||
'ENV CODEMAN_HOOK_SECRET_FILE=',
|
||||
docker.containerName,
|
||||
commitTag,
|
||||
],
|
||||
{ timeout: 300_000 }
|
||||
);
|
||||
const imageTar = join(stageDir, 'image.tar');
|
||||
await saveImageToTar(argv, commitTag, imageTar);
|
||||
checksums.image = await sha256File(imageTar);
|
||||
}
|
||||
|
||||
const workspaceTar = join(stageDir, 'workspace.tar');
|
||||
await run('tar', ['-cf', workspaceTar, '-C', docker.hostWorkspacePath, '.'], { timeout: 300_000 });
|
||||
checksums.workspace = await sha256File(workspaceTar);
|
||||
|
||||
const manifest: DockerExportManifest = {
|
||||
schemaVersion: DOCKER_EXPORT_SCHEMA,
|
||||
caseName,
|
||||
mode,
|
||||
engine: docker.engine,
|
||||
image: docker.image,
|
||||
containerWorkdir: docker.containerWorkdir,
|
||||
network: docker.network,
|
||||
createdAt: timestamp,
|
||||
codemanVersion,
|
||||
mountCredentials: docker.mountCredentials,
|
||||
// Convenient mode keeps creds on bind mounts (never committed), so the bundle is secret-free.
|
||||
secretFree: docker.mountCredentials,
|
||||
checksums,
|
||||
};
|
||||
await fs.writeFile(join(stageDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
|
||||
const members =
|
||||
mode === 'full' ? ['manifest.json', 'image.tar', 'workspace.tar'] : ['manifest.json', 'workspace.tar'];
|
||||
await run('tar', ['-czf', bundlePath, '-C', stageDir, ...members], { timeout: 300_000 });
|
||||
|
||||
const stat = await fs.stat(bundlePath);
|
||||
return { bundlePath, manifest, sizeBytes: stat.size };
|
||||
} finally {
|
||||
// Always remove the intermediate image + stage dir, and unpause.
|
||||
if (commitTag) {
|
||||
await run(argv[0], [...argv.slice(1), 'rmi', commitTag], { timeout: 60_000 }).catch(() => {});
|
||||
}
|
||||
await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {});
|
||||
if (wasRunning) {
|
||||
await run(argv[0], [...argv.slice(1), 'unpause', docker.containerName], { timeout: 30_000 }).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
manifest: DockerExportManifest;
|
||||
/** Quarantined image ref the destination case should use (full mode only). */
|
||||
importedImage?: string;
|
||||
/** Directory the workspace was extracted into. */
|
||||
workspacePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a bundle produced by exportDockerCase: validate the manifest + per-member
|
||||
* checksums, extract the workspace (traversal-guarded) into destWorkspace, and, in
|
||||
* full mode, `docker load` the image and re-tag it into a quarantined namespace.
|
||||
*/
|
||||
export async function importDockerBundle(params: {
|
||||
bundlePath: string;
|
||||
destWorkspace: string;
|
||||
engine: DockerEngine;
|
||||
timestamp: number;
|
||||
}): Promise<ImportResult> {
|
||||
const { bundlePath, destWorkspace, engine, timestamp } = params;
|
||||
const argv: string[] = [engine === 'podman' ? 'podman' : 'docker'];
|
||||
|
||||
if (IS_TEST_MODE) {
|
||||
const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}');
|
||||
return { manifest: JSON.parse(raw) as DockerExportManifest, workspacePath: destWorkspace };
|
||||
}
|
||||
|
||||
const stageDir = `${destWorkspace}.import-stage-${timestamp}`;
|
||||
mkdirSync(stageDir, { recursive: true });
|
||||
try {
|
||||
await run('tar', ['-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 });
|
||||
|
||||
const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8');
|
||||
const manifest = JSON.parse(manifestRaw) as DockerExportManifest;
|
||||
if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) {
|
||||
throw new Error(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`);
|
||||
}
|
||||
|
||||
// Integrity: verify checksums before trusting any member.
|
||||
const workspaceTar = join(stageDir, 'workspace.tar');
|
||||
if (manifest.checksums.workspace) {
|
||||
const actual = await sha256File(workspaceTar);
|
||||
if (actual !== manifest.checksums.workspace)
|
||||
throw new Error('workspace checksum mismatch (corrupt or tampered bundle)');
|
||||
}
|
||||
|
||||
// Traversal guard: reject absolute / `..` members before extraction.
|
||||
const { stdout: memberList } = await run('tar', ['-tf', workspaceTar], { timeout: 60_000 });
|
||||
for (const member of memberList.split('\n').filter(Boolean)) {
|
||||
if (!isSafeTarMember(member)) throw new Error(`unsafe path in workspace archive: ${member}`);
|
||||
}
|
||||
mkdirSync(destWorkspace, { recursive: true });
|
||||
await run('tar', ['--no-same-owner', '-xf', workspaceTar, '-C', destWorkspace], { timeout: 300_000 });
|
||||
|
||||
let importedImage: string | undefined;
|
||||
if (manifest.mode === 'full') {
|
||||
const imageTar = join(stageDir, 'image.tar');
|
||||
if (manifest.checksums.image) {
|
||||
const actual = await sha256File(imageTar);
|
||||
if (actual !== manifest.checksums.image)
|
||||
throw new Error('image checksum mismatch (corrupt or tampered bundle)');
|
||||
}
|
||||
const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 });
|
||||
const loadedRef = parseLoadedImageRef(stdout);
|
||||
if (!loadedRef) throw new Error('could not determine loaded image ref');
|
||||
// Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original tag.
|
||||
importedImage = importedImageTag(manifest.caseName, timestamp);
|
||||
await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 });
|
||||
}
|
||||
|
||||
return { manifest, importedImage, workspacePath: destWorkspace };
|
||||
} finally {
|
||||
await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** List export bundles in the exports dir (newest first), with size + mtime. */
|
||||
export async function listDockerExports(
|
||||
exportsDir: string
|
||||
): Promise<Array<{ name: string; sizeBytes: number; mtimeMs: number }>> {
|
||||
if (!existsSync(exportsDir)) return [];
|
||||
const entries = await fs.readdir(exportsDir).catch(() => [] as string[]);
|
||||
const out: Array<{ name: string; sizeBytes: number; mtimeMs: number }> = [];
|
||||
for (const name of entries) {
|
||||
if (!name.endsWith('.tgz')) continue;
|
||||
try {
|
||||
const stat = await fs.stat(join(exportsDir, name));
|
||||
out.push({ name: basename(name), sizeBytes: stat.size, mtimeMs: stat.mtimeMs });
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
}
|
||||
@@ -0,0 +1,956 @@
|
||||
/**
|
||||
* @fileoverview Docker cases: storage, pure command-arg builders, and daemon probes.
|
||||
*
|
||||
* Docker mode is a LOCATION OVERLAY on cases (not a 6th SessionMode), the direct
|
||||
* analog of the remote-SSH feature in `remote-hosts.ts`. Instead of a local tmux
|
||||
* pane running `ssh host` into a durable remote tmux server, a local tmux pane
|
||||
* runs `docker exec -it` into a durable IN-CONTAINER tmux server. The container is
|
||||
* scoped to the CASE (`codeman-case-<name>`), so multiple sessions can `docker
|
||||
* exec` into the same long-lived container.
|
||||
*
|
||||
* This module mirrors `remote-hosts.ts`:
|
||||
* - JSON storage for hosts (`docker-hosts.json`) and cases (`docker-cases.json`)
|
||||
* - `toSessionDocker()` (mirror of `toSessionRemote`)
|
||||
* - `buildDockerBaseArgs()` / `buildDockerCreateArgs()` (mirror of `buildSshConnectionArgs`)
|
||||
* - `checkDockerAvailable()` / `checkDockerTmuxAvailable()` (mirror of `checkRemoteTmuxAvailable`)
|
||||
*
|
||||
* The launch/kill command orchestration (`buildDockerLaunchCommand`,
|
||||
* `buildDockerKillCommand`, `dockerTmuxSessionName`) lives in `tmux-manager.ts`,
|
||||
* mirroring where `buildRemoteLaunchCommand` lives.
|
||||
*
|
||||
* @module docker-hosts
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import type {
|
||||
DockerCase,
|
||||
DockerCommandMode,
|
||||
DockerEngine,
|
||||
DockerHost,
|
||||
DockerNetworkMode,
|
||||
DockerResourceLimits,
|
||||
SessionDocker,
|
||||
SessionMode,
|
||||
} from './types.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Under vitest, all real `docker` invocations no-op (mirror of tmux-manager's IS_TEST_MODE). */
|
||||
const IS_TEST_MODE = !!process.env.VITEST;
|
||||
|
||||
const DOCKER_HOSTS_FILE = 'docker-hosts.json';
|
||||
const DOCKER_CASES_FILE = 'docker-cases.json';
|
||||
|
||||
/** Locally-built base image (see scripts/build-agent-image.mjs). */
|
||||
export const DEFAULT_AGENT_IMAGE = 'codeman/agent:base';
|
||||
|
||||
/** HOME inside the base image (the `agent` user). Cred mounts + hook-secret land under it. */
|
||||
export const CONTAINER_HOME = '/home/agent';
|
||||
|
||||
/** Per-case container name prefix. The `case` letters deliberately do NOT matter to
|
||||
* tmux; this is a DOCKER name (`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`), and case names are
|
||||
* already validated `^[a-zA-Z0-9_-]+$`, so `codeman-case-<name>` is always valid. */
|
||||
const CONTAINER_NAME_PREFIX = 'codeman-case-';
|
||||
|
||||
/** Sensible resource defaults (all overridable per host). */
|
||||
export const DEFAULT_DOCKER_RESOURCES: DockerResourceLimits = {
|
||||
memory: '4g',
|
||||
cpus: '2',
|
||||
pidsLimit: 512,
|
||||
nofile: '4096:8192',
|
||||
};
|
||||
|
||||
// ========== Storage (mirror of remote-hosts.ts) ==========
|
||||
|
||||
export function dockerHostsPath(configDir: string): string {
|
||||
return join(configDir, DOCKER_HOSTS_FILE);
|
||||
}
|
||||
|
||||
export function dockerCasesPath(configDir: string): string {
|
||||
return join(configDir, DOCKER_CASES_FILE);
|
||||
}
|
||||
|
||||
async function readJsonArray<T>(path: string): Promise<T[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(path, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonArray<T>(configDir: string, path: string, value: T[]): Promise<void> {
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
await fs.writeFile(path, JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
export async function readDockerHosts(configDir: string): Promise<DockerHost[]> {
|
||||
return readJsonArray<DockerHost>(dockerHostsPath(configDir));
|
||||
}
|
||||
|
||||
export async function writeDockerHosts(configDir: string, hosts: DockerHost[]): Promise<void> {
|
||||
await writeJsonArray(configDir, dockerHostsPath(configDir), hosts);
|
||||
}
|
||||
|
||||
export async function readDockerCases(configDir: string): Promise<DockerCase[]> {
|
||||
return readJsonArray<DockerCase>(dockerCasesPath(configDir));
|
||||
}
|
||||
|
||||
export async function writeDockerCases(configDir: string, cases: DockerCase[]): Promise<void> {
|
||||
await writeJsonArray(configDir, dockerCasesPath(configDir), cases);
|
||||
}
|
||||
|
||||
// ========== Naming / display / defaults ==========
|
||||
|
||||
/** Per-case container name. Mirrors how remote derives a stable name from the case. */
|
||||
export function dockerContainerName(caseName: string): string {
|
||||
return `${CONTAINER_NAME_PREFIX}${caseName}`;
|
||||
}
|
||||
|
||||
/** Default pane command per CLI mode (mirror of defaultRemoteCommandForMode). */
|
||||
export function defaultDockerCommandForMode(mode: SessionMode): string {
|
||||
const commands: Record<DockerCommandMode, string> = {
|
||||
shell: 'exec bash -l',
|
||||
// Mirror the LOCAL claude default so the in-container agent runs non-interactively.
|
||||
claude: 'exec claude --dangerously-skip-permissions',
|
||||
opencode: 'exec opencode',
|
||||
codex: 'exec codex',
|
||||
gemini: 'exec gemini',
|
||||
};
|
||||
return commands[mode as DockerCommandMode] || commands.shell;
|
||||
}
|
||||
|
||||
/** `container:/workdir` display string (mirror of remoteDisplayPath's `user@host:path`). */
|
||||
export function dockerDisplayPath(
|
||||
docker: Pick<SessionDocker, 'containerName' | 'containerWorkdir'> | { container: string; path: string }
|
||||
): string {
|
||||
if ('containerName' in docker) return `${docker.containerName}:${docker.containerWorkdir}`;
|
||||
return `${docker.container}:${docker.path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host-callback gateway alias is ENGINE-SPECIFIC: Docker exposes the host as
|
||||
* `host.docker.internal`, Podman as `host.containers.internal`. Both are added to
|
||||
* the host-guard allowlist so a mixed fleet keeps working.
|
||||
*/
|
||||
export function hostGatewayAlias(engine: DockerEngine): string {
|
||||
return engine === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the server's own `CODEMAN_API_URL` to a container-reachable one by
|
||||
* swapping ONLY the hostname for the engine's host-gateway alias, preserving
|
||||
* scheme AND port (prod is HTTPS on 3000, so hardcoding http://…:3000 breaks
|
||||
* every hook). Falls back to `https://<alias>:3000` when the input is absent or
|
||||
* unparseable.
|
||||
*/
|
||||
export function containerApiUrl(processApiUrl: string | undefined, engine: DockerEngine): string {
|
||||
const alias = hostGatewayAlias(engine);
|
||||
if (!processApiUrl) return `https://${alias}:3000`;
|
||||
try {
|
||||
const url = new URL(processApiUrl);
|
||||
url.hostname = alias;
|
||||
// origin drops any trailing path/slash and keeps scheme + (non-default) port
|
||||
return url.origin;
|
||||
} catch {
|
||||
return `https://${alias}:3000`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable hash of the drift-relevant `docker create` inputs, stored on the
|
||||
* container as the `codeman.confighash` label. On launch, a mismatch between the
|
||||
* desired hash and the running container's label triggers the recreate-on-drift
|
||||
* prompt (host config edits actually take effect).
|
||||
*/
|
||||
export function dockerConfigHash(
|
||||
docker: Pick<
|
||||
SessionDocker,
|
||||
| 'engine'
|
||||
| 'image'
|
||||
| 'containerWorkdir'
|
||||
| 'network'
|
||||
| 'networkName'
|
||||
| 'resources'
|
||||
| 'gpus'
|
||||
| 'mountCredentials'
|
||||
| 'extraCreateArgs'
|
||||
>
|
||||
): string {
|
||||
const normalized = JSON.stringify({
|
||||
engine: docker.engine,
|
||||
image: docker.image,
|
||||
containerWorkdir: docker.containerWorkdir,
|
||||
network: docker.network,
|
||||
networkName: docker.networkName ?? null,
|
||||
resources: docker.resources ?? null,
|
||||
gpus: docker.gpus ?? null,
|
||||
mountCredentials: docker.mountCredentials,
|
||||
extraCreateArgs: docker.extraCreateArgs ?? null,
|
||||
});
|
||||
return createHash('sha256').update(normalized).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the flattened per-session Docker metadata from a host profile + a case,
|
||||
* resolving every default (mirror of toSessionRemote). The `configHash` is
|
||||
* computed last over the resolved values.
|
||||
*/
|
||||
export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): SessionDocker {
|
||||
const engine: DockerEngine = host.engine ?? 'docker';
|
||||
const containerWorkdir = dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath;
|
||||
const base: Omit<SessionDocker, 'configHash'> = {
|
||||
hostId: host.id,
|
||||
label: host.label,
|
||||
engine,
|
||||
image: host.image || DEFAULT_AGENT_IMAGE,
|
||||
containerName: dockerCase.container ?? dockerContainerName(dockerCase.name),
|
||||
hostWorkspacePath: dockerCase.hostWorkspacePath,
|
||||
containerWorkdir,
|
||||
network: host.network ?? 'bridge',
|
||||
networkName: host.networkName,
|
||||
resources: host.resources ?? DEFAULT_DOCKER_RESOURCES,
|
||||
gpus: host.gpus,
|
||||
mountCredentials: host.mountCredentials ?? true,
|
||||
hooksEnabled: host.hooksEnabled ?? true,
|
||||
resumeOnStart: host.resumeOnStart ?? true,
|
||||
daemonHost: host.daemonHost,
|
||||
context: host.context,
|
||||
commands: host.commands,
|
||||
extraCreateArgs: host.extraCreateArgs,
|
||||
extraExecArgs: host.extraExecArgs,
|
||||
};
|
||||
return { ...base, configHash: dockerConfigHash(base) };
|
||||
}
|
||||
|
||||
// ========== Shell escaping ==========
|
||||
|
||||
/**
|
||||
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
|
||||
* Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value
|
||||
* interpolated into the outer `bash -c "..."` launch layer is escaped through
|
||||
* this so a path with spaces stays a single shell token. Operator-entered fields
|
||||
* are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth.
|
||||
*/
|
||||
export function shellescape(str: string): string {
|
||||
return "'" + str.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
// ========== Pure command-arg builders ==========
|
||||
|
||||
/** A resolved bind mount (source existence already checked by the caller). */
|
||||
export interface DockerMount {
|
||||
src: string;
|
||||
dst: string;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved, IO-free context for buildDockerCreateArgs. The caller (tmux-manager)
|
||||
* resolves the environment-dependent bits (host uid, existing cred mounts, the
|
||||
* derived api url, Desktop detection) so this builder stays pure and unit-testable.
|
||||
*/
|
||||
export interface DockerCreateContext {
|
||||
docker: SessionDocker;
|
||||
/** Codeman session id (only the first 8 chars are used, for the codeman.session label). */
|
||||
sessionId: string;
|
||||
/** CODEMAN_INSTANCE ('' for prod) — scopes the boot reaper so a beta never reaps prod. */
|
||||
instance: string;
|
||||
/** Pre-resolved uid/userns tokens: ['--user','1000:0'] | ['--userns','keep-id'] | []. */
|
||||
userArgs: string[];
|
||||
/** Existing host credential bind mounts (convenient mode). Empty in sealed mode. */
|
||||
credentialMounts: DockerMount[];
|
||||
/** Extra bind mounts (e.g. the read-only hook-secret file). */
|
||||
extraMounts: DockerMount[];
|
||||
/** Create-time env (NON-secret, committed-safe): HOME, TERM, COLORTERM, CODEMAN_API_URL, CODEMAN_HOOK_SECRET_FILE. */
|
||||
envCreate: Record<string, string>;
|
||||
/** Whether to add `--add-host <alias>:host-gateway` (skipped on Docker Desktop, where the alias is native). */
|
||||
addHostGateway: boolean;
|
||||
/** Engine host-gateway alias (host.docker.internal / host.containers.internal). */
|
||||
gatewayAlias: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Engine prefix tokens shared by every docker invocation (mirror of
|
||||
* buildSshConnectionArgs). Returns e.g. ['docker'] or ['podman','--context','ctx'].
|
||||
*/
|
||||
export function buildDockerBaseArgs(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
|
||||
const parts: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
|
||||
if (docker.context) parts.push('--context', shellescape(docker.context));
|
||||
if (docker.daemonHost) parts.push('-H', shellescape(docker.daemonHost));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function mountSpec(m: DockerMount): string {
|
||||
return `type=bind,src=${m.src},dst=${m.dst}${m.readonly ? ',readonly' : ''}`;
|
||||
}
|
||||
|
||||
function resourceFlags(resources?: DockerResourceLimits): string[] {
|
||||
if (!resources) return [];
|
||||
const flags: string[] = [];
|
||||
if (resources.memory) {
|
||||
// memory-swap == memory disables swap, making --memory a REAL OOM cap.
|
||||
flags.push('--memory', resources.memory, '--memory-swap', resources.memory);
|
||||
}
|
||||
if (resources.cpus) flags.push('--cpus', resources.cpus);
|
||||
if (resources.pidsLimit) flags.push('--pids-limit', String(resources.pidsLimit));
|
||||
if (resources.nofile) flags.push('--ulimit', `nofile=${resources.nofile}`);
|
||||
if (resources.shmSize) flags.push('--shm-size', resources.shmSize);
|
||||
return flags;
|
||||
}
|
||||
|
||||
function networkArg(network: DockerNetworkMode, networkName?: string): string {
|
||||
if (network === 'custom' && networkName) return networkName;
|
||||
return network; // 'bridge' | 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `docker create` token list (from `create` through the `sleep
|
||||
* infinity` CMD) for a long-lived, hardened, per-case container. PURE: every
|
||||
* dynamic value is shellescaped; the caller joins with spaces into the launch
|
||||
* string. Security invariants baked in: --cap-drop ALL, --security-opt
|
||||
* no-new-privileges, --pids-limit, --memory==--memory-swap, --init,
|
||||
* --pull=never, --restart no, NEVER --privileged, NEVER the docker socket.
|
||||
*/
|
||||
export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] {
|
||||
const {
|
||||
docker,
|
||||
sessionId,
|
||||
instance,
|
||||
userArgs,
|
||||
credentialMounts,
|
||||
extraMounts,
|
||||
envCreate,
|
||||
addHostGateway,
|
||||
gatewayAlias,
|
||||
} = ctx;
|
||||
|
||||
const args: string[] = [
|
||||
'create',
|
||||
'--name',
|
||||
shellescape(docker.containerName),
|
||||
'--label',
|
||||
'codeman.managed=1',
|
||||
'--label',
|
||||
shellescape(`codeman.instance=${instance}`),
|
||||
'--label',
|
||||
shellescape(`codeman.session=${sessionId.slice(0, 8)}`),
|
||||
'--label',
|
||||
shellescape(`codeman.confighash=${docker.configHash ?? dockerConfigHash(docker)}`),
|
||||
'--pull=never',
|
||||
'--init',
|
||||
'--restart',
|
||||
'no',
|
||||
...userArgs,
|
||||
'--workdir',
|
||||
shellescape(docker.containerWorkdir),
|
||||
// Workspace bind: mirror the host path inside the container so the transcript
|
||||
// projHash correlates and file features read real host bytes.
|
||||
'--mount',
|
||||
shellescape(mountSpec({ src: docker.hostWorkspacePath, dst: docker.containerWorkdir })),
|
||||
...credentialMounts.flatMap((m) => ['--mount', shellescape(mountSpec(m))]),
|
||||
...extraMounts.flatMap((m) => ['--mount', shellescape(mountSpec(m))]),
|
||||
];
|
||||
|
||||
if (addHostGateway) args.push('--add-host', `${gatewayAlias}:host-gateway`);
|
||||
|
||||
args.push(
|
||||
...resourceFlags(docker.resources),
|
||||
// GPU passthrough (needs the NVIDIA container toolkit on the host). No storage
|
||||
// cap is set, so the container's writable layer + volumes grow elastically as
|
||||
// data flows in (bounded only by host disk).
|
||||
...(docker.gpus ? ['--gpus', shellescape(docker.gpus)] : []),
|
||||
'--cap-drop',
|
||||
'ALL',
|
||||
'--security-opt',
|
||||
'no-new-privileges',
|
||||
'--network',
|
||||
networkArg(docker.network, docker.networkName)
|
||||
);
|
||||
|
||||
for (const [key, value] of Object.entries(envCreate)) {
|
||||
args.push('--env', shellescape(`${key}=${value}`));
|
||||
}
|
||||
|
||||
// Operator escape-hatch args (schema-validated NO_SHELL_INJECTION), escaped again here.
|
||||
for (const extra of docker.extraCreateArgs ?? []) {
|
||||
args.push(shellescape(extra));
|
||||
}
|
||||
|
||||
args.push(shellescape(docker.image), 'sleep', 'infinity');
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE argv for building the agent base image locally (the programmatic mirror of
|
||||
* scripts/build-agent-image.mjs): `build -f <dockerfile> -t <image> [--no-cache]
|
||||
* <contextDir>`. Kept pure + unit-testable; the caller prepends the engine binary.
|
||||
*/
|
||||
export function agentImageBuildArgs(dockerfile: string, image: string, contextDir: string, noCache = false): string[] {
|
||||
return ['build', '-f', dockerfile, '-t', image, ...(noCache ? ['--no-cache'] : []), contextDir];
|
||||
}
|
||||
|
||||
// ========== Credential mount resolution (IO) ==========
|
||||
|
||||
/** Container Claude config dir (created gid-0 writable in the image). */
|
||||
export const CONTAINER_CLAUDE_DIR = `${CONTAINER_HOME}/.claude`;
|
||||
/** In-container path of the seeded (writable) `~/.claude.json`. */
|
||||
export const CLAUDE_JSON_HOME = `${CONTAINER_HOME}/.claude.json`;
|
||||
/** In-container path of the read-only host-seeded `~/.claude.json` (copied into HOME at launch). */
|
||||
export const CLAUDE_JSON_SEED = `${CONTAINER_HOME}/.codeman/claude.seed.json`;
|
||||
/** Read-only seed paths for the files copied into the container's `.claude`. */
|
||||
const CLAUDE_CREDS_SEED = `${CONTAINER_HOME}/.codeman/claude-creds.seed.json`;
|
||||
const CLAUDE_SETTINGS_SEED = `${CONTAINER_HOME}/.codeman/claude-settings.seed.json`;
|
||||
const CLAUDE_STATS_SEED = `${CONTAINER_HOME}/.codeman/claude-stats.seed.json`;
|
||||
/** Staging root for read-only host-cred seed mounts (codex/gemini/gcloud/opencode). */
|
||||
const CRED_SEED_DIR = `${CONTAINER_HOME}/.codeman/cred-seeds`;
|
||||
|
||||
/**
|
||||
* PURE: merge the host `~/.claude.json` into a config that makes an
|
||||
* already-authenticated Claude skip its INTERACTIVE onboarding inside the container
|
||||
* (the host file itself lacks these flags — the host install is grandfathered, so a
|
||||
* verbatim copy still triggers the theme picker + login wizard + folder-trust
|
||||
* prompt). Forces `hasCompletedOnboarding`, a `theme` (so the theme picker is
|
||||
* skipped), and marks the workspace project trusted + onboarded. Auth still comes
|
||||
* from the copied `oauthAccount` + the dir-mounted `~/.claude/.credentials.json`.
|
||||
*/
|
||||
export function buildSeamlessClaudeConfig(
|
||||
hostConfig: Record<string, unknown>,
|
||||
workspacePath: string,
|
||||
theme = 'dark'
|
||||
): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = { ...hostConfig };
|
||||
merged.hasCompletedOnboarding = true;
|
||||
if (typeof merged.theme !== 'string') merged.theme = theme;
|
||||
const projects = { ...((merged.projects as Record<string, Record<string, unknown>> | undefined) ?? {}) };
|
||||
const existing = (projects[workspacePath] as Record<string, unknown> | undefined) ?? {};
|
||||
const seenCount = existing.projectOnboardingSeenCount;
|
||||
projects[workspacePath] = {
|
||||
...existing,
|
||||
hasTrustDialogAccepted: true,
|
||||
hasCompletedProjectOnboarding: true,
|
||||
projectOnboardingSeenCount: typeof seenCount === 'number' && seenCount > 0 ? seenCount : 1,
|
||||
};
|
||||
merged.projects = projects;
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Best-effort read of the host `~/.claude/settings.json` theme (drives the seed's theme). */
|
||||
function readHostClaudeTheme(home: string): string | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf-8')) as { theme?: unknown };
|
||||
return typeof parsed.theme === 'string' ? parsed.theme : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the read-only seed mount for `~/.claude.json`. Reads the host file, merges
|
||||
* in the seamless-onboarding flags + workspace trust (buildSeamlessClaudeConfig),
|
||||
* writes the result to a per-container seed file under `~/.codeman/docker-seeds/`,
|
||||
* and returns its mount. The launch chain copies it to `~/.claude.json` inside HOME
|
||||
* once — giving Claude a NORMAL writable, already-onboarded config (no atomic-rename
|
||||
* EBUSY, no re-auth, no theme/trust prompts). Falls back to the RAW host file when
|
||||
* parse/write fails (auth still works; the wizard may show). Returns null when the
|
||||
* host has no `~/.claude.json`. IO; under VITEST returns the raw mount (no write).
|
||||
*/
|
||||
export function resolveClaudeJsonSeedMount(
|
||||
home: string = homedir(),
|
||||
containerName?: string,
|
||||
workspacePath?: string
|
||||
): DockerMount | null {
|
||||
const src = join(home, '.claude.json');
|
||||
if (!existsSync(src)) return null;
|
||||
const rawMount: DockerMount = { src, dst: CLAUDE_JSON_SEED, readonly: true };
|
||||
if (IS_TEST_MODE || !containerName || !workspacePath) return rawMount;
|
||||
try {
|
||||
const hostConfig = JSON.parse(readFileSync(src, 'utf-8')) as Record<string, unknown>;
|
||||
const merged = buildSeamlessClaudeConfig(hostConfig, workspacePath, readHostClaudeTheme(home) ?? 'dark');
|
||||
const seedsDir = dataPath('docker-seeds');
|
||||
if (!existsSync(seedsDir)) mkdirSync(seedsDir, { recursive: true });
|
||||
const seedFile = join(seedsDir, `${containerName}.json`);
|
||||
writeFileSync(seedFile, JSON.stringify(merged), { mode: 0o600 });
|
||||
return { src: seedFile, dst: CLAUDE_JSON_SEED, readonly: true };
|
||||
} catch {
|
||||
return rawMount; // partial host write / unreadable — auth still carries, wizard may show
|
||||
}
|
||||
}
|
||||
|
||||
/** A file (or dir, when `recursive`) copied into the container HOME once at launch
|
||||
* (`[ -e to ] || cp [-a] from to`). */
|
||||
export interface DockerSeedCopy {
|
||||
from: string;
|
||||
to: string;
|
||||
/** `cp -a` for whole-directory credential seeds (gemini/gcloud/opencode). */
|
||||
recursive?: boolean;
|
||||
}
|
||||
|
||||
export interface DockerClaudeArtifacts {
|
||||
/** Bind mounts to add: the shared `projects/` transcripts (RW) + read-only seed files. */
|
||||
mounts: DockerMount[];
|
||||
/** Files copied into the container's writable HOME/.claude (+ HOME/.claude.json) at launch. */
|
||||
seedCopies: DockerSeedCopy[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ISOLATED Claude artifacts for a docker session (replaces the old
|
||||
* whole-`~/.claude` RW mount that polluted the host). Shares ONLY what must cross
|
||||
* the boundary and seeds the rest as writable copies:
|
||||
* - `~/.claude/projects` → RW dir mount (transcripts: host watchers + `--resume`).
|
||||
* - `~/.claude.json` → merged onboarding seed, copied to HOME (no re-auth/wizard).
|
||||
* - `~/.claude/.credentials.json` + `~/.claude/settings.json` → read-only seeds
|
||||
* copied into the container's own `~/.claude` (token + global prefs carry in;
|
||||
* the container refreshes its own copy and never writes back to the host).
|
||||
* Everything else Claude writes (backups, tasks, teams, session-env, history) stays
|
||||
* container-local. IO (reads host files, writes the merged `.claude.json` seed).
|
||||
*/
|
||||
export function resolveDockerClaudeArtifacts(
|
||||
home: string,
|
||||
containerName: string,
|
||||
workspacePath: string
|
||||
): DockerClaudeArtifacts {
|
||||
const mounts: DockerMount[] = [];
|
||||
const seedCopies: DockerSeedCopy[] = [];
|
||||
|
||||
// The ONE genuinely-shared part: conversation transcripts (dir mount → renames work).
|
||||
const projectsSrc = join(home, '.claude', 'projects');
|
||||
if (existsSync(projectsSrc)) {
|
||||
mounts.push({ src: projectsSrc, dst: `${CONTAINER_CLAUDE_DIR}/projects` });
|
||||
}
|
||||
|
||||
// ~/.claude.json → merged, onboarding-complete seed at HOME root.
|
||||
const jsonSeed = resolveClaudeJsonSeedMount(home, containerName, workspacePath);
|
||||
if (jsonSeed) {
|
||||
mounts.push(jsonSeed);
|
||||
seedCopies.push({ from: CLAUDE_JSON_SEED, to: CLAUDE_JSON_HOME });
|
||||
}
|
||||
|
||||
// credentials (token) + settings (theme/model/effort/permissions) + stats-cache
|
||||
// (drives the model/effort status indicator) → writable copies inside the
|
||||
// container's own ~/.claude (never a wholesale mount → no host pollution).
|
||||
const files: Array<[rel: string, seed: string, dest: string]> = [
|
||||
['.credentials.json', CLAUDE_CREDS_SEED, `${CONTAINER_CLAUDE_DIR}/.credentials.json`],
|
||||
['settings.json', CLAUDE_SETTINGS_SEED, `${CONTAINER_CLAUDE_DIR}/settings.json`],
|
||||
['stats-cache.json', CLAUDE_STATS_SEED, `${CONTAINER_CLAUDE_DIR}/stats-cache.json`],
|
||||
];
|
||||
for (const [rel, seed, dest] of files) {
|
||||
const src = join(home, '.claude', rel);
|
||||
if (existsSync(src)) {
|
||||
mounts.push({ src, dst: seed, readonly: true });
|
||||
seedCopies.push({ from: seed, to: dest });
|
||||
}
|
||||
}
|
||||
|
||||
return { mounts, seedCopies };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-CLI credential-store isolation policy (the codex/gemini/gcloud/opencode analog
|
||||
* of resolveDockerClaudeArtifacts). Codex is the direct Claude-analog: its
|
||||
* `sessions/` rollouts + `history.jsonl` are read HOST-SIDE (response-viewer +
|
||||
* `codex resume`), so they are SHARED (RW), while `auth.json`/`config.toml` are
|
||||
* seeded. The other three have no host-read/resume dependency and are fully
|
||||
* seed-copied (writable copy in the container, no write-back to the host).
|
||||
*/
|
||||
interface CredStorePolicy {
|
||||
/** Path relative to HOME (host + container), e.g. '.codex' or '.config/gcloud'. */
|
||||
rel: string;
|
||||
/** Subdirs bind-mounted RW (shared: resume + host reads). */
|
||||
shareDirs?: string[];
|
||||
/** Files bind-mounted RW (append-only, e.g. codex history.jsonl — never renamed). */
|
||||
shareFiles?: string[];
|
||||
/** Files seeded (RO mount → cp) into the container's own copy. */
|
||||
seedFiles?: string[];
|
||||
/** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */
|
||||
seedWhole?: boolean;
|
||||
}
|
||||
|
||||
const CRED_STORES: CredStorePolicy[] = [
|
||||
{ rel: '.codex', shareDirs: ['sessions'], shareFiles: ['history.jsonl'], seedFiles: ['auth.json', 'config.toml'] },
|
||||
{ rel: '.gemini', seedWhole: true },
|
||||
{ rel: '.config/gcloud', seedWhole: true },
|
||||
{ rel: '.config/opencode', seedWhole: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve the ISOLATED codex/gemini/gcloud/opencode artifacts (replaces the old
|
||||
* whole-dir RW mounts that let each in-container CLI write its refreshed tokens +
|
||||
* session state back into the host). Every path is existsSync-gated (on most hosts
|
||||
* only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs).
|
||||
*/
|
||||
export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts {
|
||||
const mounts: DockerMount[] = [];
|
||||
const seedCopies: DockerSeedCopy[] = [];
|
||||
for (const store of CRED_STORES) {
|
||||
const hostBase = join(home, store.rel);
|
||||
if (!existsSync(hostBase)) continue;
|
||||
const containerBase = `${CONTAINER_HOME}/${store.rel}`;
|
||||
const seedName = store.rel.replace(/\//g, '-'); // '.config/gcloud' → '.config-gcloud'
|
||||
if (store.seedWhole) {
|
||||
const seed = `${CRED_SEED_DIR}/${seedName}`;
|
||||
mounts.push({ src: hostBase, dst: seed, readonly: true });
|
||||
seedCopies.push({ from: seed, to: containerBase, recursive: true });
|
||||
continue;
|
||||
}
|
||||
for (const sub of store.shareDirs ?? []) {
|
||||
const src = join(hostBase, sub);
|
||||
if (existsSync(src)) mounts.push({ src, dst: `${containerBase}/${sub}` });
|
||||
}
|
||||
for (const file of store.shareFiles ?? []) {
|
||||
const src = join(hostBase, file);
|
||||
if (existsSync(src)) mounts.push({ src, dst: `${containerBase}/${file}` });
|
||||
}
|
||||
for (const file of store.seedFiles ?? []) {
|
||||
const src = join(hostBase, file);
|
||||
if (existsSync(src)) {
|
||||
const seed = `${CRED_SEED_DIR}/${seedName}-${file}`;
|
||||
mounts.push({ src, dst: seed, readonly: true });
|
||||
seedCopies.push({ from: seed, to: `${containerBase}/${file}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { mounts, seedCopies };
|
||||
}
|
||||
|
||||
// ========== Daemon probes (IO; no-op under VITEST) ==========
|
||||
|
||||
export interface DockerAvailability {
|
||||
ok: boolean;
|
||||
engine: DockerEngine;
|
||||
rootless: boolean;
|
||||
isDesktop: boolean;
|
||||
cgroupV2: boolean;
|
||||
/** Best-effort: are --memory/--cpus/--pids-limit actually enforced on this engine? */
|
||||
capsEnforced: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const DOCKER_PROBE_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface DockerInfoJson {
|
||||
ServerVersion?: string;
|
||||
CgroupVersion?: string;
|
||||
SecurityOptions?: string[];
|
||||
OperatingSystem?: string;
|
||||
OSType?: string;
|
||||
Name?: string;
|
||||
}
|
||||
|
||||
async function runDockerInfo(engine: DockerEngine): Promise<DockerInfoJson | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(engine, ['info', '--format', '{{json .}}'], {
|
||||
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
return JSON.parse(stdout) as DockerInfoJson;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function classifyDockerInfo(engine: DockerEngine, info: DockerInfoJson): DockerAvailability {
|
||||
const security = info.SecurityOptions ?? [];
|
||||
const rootless = security.some((opt) => opt.includes('rootless'));
|
||||
const cgroupV2 = info.CgroupVersion === '2';
|
||||
const os = `${info.OperatingSystem ?? ''}`.toLowerCase();
|
||||
const isDesktop = os.includes('docker desktop') || os.includes('desktop');
|
||||
// Under rootless, resource caps are only reliably enforced with cgroup v2 +
|
||||
// systemd delegation. We can't detect delegation from `docker info`, so we
|
||||
// treat rootless+cgroupv2 as "likely enforced" and rootless+cgroupv1 as not.
|
||||
const capsEnforced = !rootless || cgroupV2;
|
||||
return { ok: true, engine, rootless, isDesktop, cgroupV2, capsEnforced };
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the container engine: server up, cgroup version, rootless, Desktop, and
|
||||
* whether resource caps are enforceable. Auto-detects docker then podman when no
|
||||
* engine is given. No-op canned value under VITEST.
|
||||
*/
|
||||
export async function checkDockerAvailable(engine?: DockerEngine): Promise<DockerAvailability> {
|
||||
if (IS_TEST_MODE) {
|
||||
return {
|
||||
ok: true,
|
||||
engine: engine ?? 'docker',
|
||||
rootless: false,
|
||||
isDesktop: false,
|
||||
cgroupV2: true,
|
||||
capsEnforced: true,
|
||||
};
|
||||
}
|
||||
const candidates: DockerEngine[] = engine ? [engine] : ['docker', 'podman'];
|
||||
for (const candidate of candidates) {
|
||||
const info = await runDockerInfo(candidate);
|
||||
if (info) return classifyDockerInfo(candidate, info);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
engine: engine ?? 'docker',
|
||||
rootless: false,
|
||||
isDesktop: false,
|
||||
cgroupV2: false,
|
||||
capsEnforced: false,
|
||||
error: 'Docker/Podman not available. Install docker (or podman) and ensure the daemon is running.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Is the base image present locally? (never triggers an auto-pull). */
|
||||
export async function checkDockerImagePresent(engine: DockerEngine, image: string): Promise<boolean> {
|
||||
if (IS_TEST_MODE) return true;
|
||||
try {
|
||||
await execFileAsync(engine, ['image', 'inspect', '--format', '{{.Id}}', image], {
|
||||
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface EnsureImageResult {
|
||||
ok: boolean;
|
||||
/** true when this call actually ran a build (vs. the image already existing). */
|
||||
built: boolean;
|
||||
alreadyPresent: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** In-flight builds keyed by `engine:image`, so concurrent callers share ONE build. */
|
||||
const inFlightImageBuilds = new Map<string, Promise<EnsureImageResult>>();
|
||||
|
||||
/**
|
||||
* Resolve the repo's Dockerfile + build context. Works from BOTH src (dev/tsx) and
|
||||
* dist/index.js (esbuild prod: dist sits at repo root), since both are one level
|
||||
* under the repo root. Returns null when the Dockerfile is absent (npm-global
|
||||
* installs don't ship docker/ — Docker cases are a git-clone feature).
|
||||
*/
|
||||
function resolveAgentDockerfile(): { dockerfile: string; contextDir: string } | null {
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dockerfile = join(repoRoot, 'docker', 'agent.Dockerfile');
|
||||
return existsSync(dockerfile) ? { dockerfile, contextDir: repoRoot } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the agent base image exists, BUILDING it locally on first use so a missing
|
||||
* image is never a hard blocker (decision: "build locally on first use",
|
||||
* docs/docker-cases-plan.md). Idempotent, concurrency-safe (one build per
|
||||
* engine:image shared by concurrent callers), and a no-op under VITEST. Only the
|
||||
* DEFAULT image is auto-built — we can never build a user's custom ref, and the
|
||||
* `--pull=never` invariant forbids pulling. `onProgress` receives build output
|
||||
* lines for SSE surfacing.
|
||||
*/
|
||||
export async function ensureAgentBaseImage(
|
||||
engine: DockerEngine,
|
||||
image: string,
|
||||
opts: { onProgress?: (line: string) => void; noCache?: boolean } = {}
|
||||
): Promise<EnsureImageResult> {
|
||||
if (IS_TEST_MODE) return { ok: true, built: false, alreadyPresent: true };
|
||||
if (await checkDockerImagePresent(engine, image)) {
|
||||
return { ok: true, built: false, alreadyPresent: true };
|
||||
}
|
||||
if (image !== DEFAULT_AGENT_IMAGE) {
|
||||
return {
|
||||
ok: false,
|
||||
built: false,
|
||||
alreadyPresent: false,
|
||||
error: `image ${image} is not present and only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull ${image} yourself.`,
|
||||
};
|
||||
}
|
||||
const key = `${engine}:${image}`;
|
||||
const existing = inFlightImageBuilds.get(key);
|
||||
if (existing) return existing;
|
||||
const build = buildAgentImage(engine, image, opts).finally(() => inFlightImageBuilds.delete(key));
|
||||
inFlightImageBuilds.set(key, build);
|
||||
return build;
|
||||
}
|
||||
|
||||
function buildAgentImage(
|
||||
engine: DockerEngine,
|
||||
image: string,
|
||||
opts: { onProgress?: (line: string) => void; noCache?: boolean }
|
||||
): Promise<EnsureImageResult> {
|
||||
const resolved = resolveAgentDockerfile();
|
||||
if (!resolved) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
built: false,
|
||||
alreadyPresent: false,
|
||||
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
|
||||
});
|
||||
}
|
||||
const args = agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache);
|
||||
return new Promise<EnsureImageResult>((resolve) => {
|
||||
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
|
||||
const child = spawn(engine, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const forward = (buf: Buffer) => {
|
||||
for (const line of buf.toString('utf-8').split('\n')) {
|
||||
const trimmed = line.trimEnd();
|
||||
if (trimmed) opts.onProgress?.(trimmed);
|
||||
}
|
||||
};
|
||||
child.stdout?.on('data', forward);
|
||||
child.stderr?.on('data', forward);
|
||||
child.on('error', (err) => {
|
||||
resolve({
|
||||
ok: false,
|
||||
built: false,
|
||||
alreadyPresent: false,
|
||||
error: `could not spawn ${engine} build: ${err.message}`,
|
||||
});
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolve({ ok: true, built: true, alreadyPresent: false });
|
||||
else resolve({ ok: false, built: false, alreadyPresent: false, error: `${engine} build failed (exit ${code})` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface DockerTmuxCheckResult {
|
||||
ok: boolean;
|
||||
tmuxPath?: string;
|
||||
/** Distinguishes "image missing" (build it) from "tmux missing in image" (rebuild it). */
|
||||
imageMissing?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the base image is present AND contains tmux (a HARD prerequisite: the
|
||||
* in-container tmux is what makes reconnect durable). Never triggers a pull
|
||||
* (`--pull=never`). No-op under VITEST. Mirror of checkRemoteTmuxAvailable.
|
||||
*/
|
||||
export async function checkDockerTmuxAvailable(
|
||||
docker: Pick<SessionDocker, 'engine' | 'image'>
|
||||
): Promise<DockerTmuxCheckResult> {
|
||||
if (IS_TEST_MODE) return { ok: true, tmuxPath: '/usr/bin/tmux' };
|
||||
const engine = docker.engine;
|
||||
if (!(await checkDockerImagePresent(engine, docker.image))) {
|
||||
return {
|
||||
ok: false,
|
||||
imageMissing: true,
|
||||
error: `image ${docker.image} not present (the default image is auto-built on first use; a custom image must be built or pulled first)`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
engine,
|
||||
['run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'],
|
||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||
);
|
||||
const tmuxPath = stdout.trim();
|
||||
if (!tmuxPath) {
|
||||
return { ok: false, error: `base image ${docker.image} is missing tmux (required for durable sessions)` };
|
||||
}
|
||||
return { ok: true, tmuxPath };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, error: `could not verify tmux in ${docker.image}: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the host's IP on the default docker bridge (the address a container
|
||||
* reaches as `host.docker.internal`), so the server can bind a hooks-only listener
|
||||
* there and in-container hooks can call back. Defaults to the conventional
|
||||
* 172.17.0.1 when the inspect fails but docker is up; null when docker is absent.
|
||||
* No-op canned value under VITEST.
|
||||
*/
|
||||
export async function detectDockerBridgeGateway(engine: DockerEngine = 'docker'): Promise<string | null> {
|
||||
if (IS_TEST_MODE) return '172.17.0.1';
|
||||
const bin = engine === 'podman' ? 'podman' : 'docker';
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
bin,
|
||||
['network', 'inspect', 'bridge', '--format', '{{(index .IPAM.Config 0).Gateway}}'],
|
||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||
);
|
||||
const ip = stdout.trim();
|
||||
return /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) ? ip : '172.17.0.1';
|
||||
} catch {
|
||||
return null; // docker not available — nothing to bind
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance-scoped boot reaper: `docker rm -f` any MANAGED container that belongs
|
||||
* to THIS instance (by the `codeman.instance` label) but whose case is no longer
|
||||
* in `docker-cases.json`. The instance scoping is what stops a beta from reaping
|
||||
* prod's containers (the cross-instance hazard). No-op under VITEST. Best-effort.
|
||||
*/
|
||||
export async function reapOrphanedDockerContainers(
|
||||
configDir: string,
|
||||
instance: string,
|
||||
engine: DockerEngine = 'docker'
|
||||
): Promise<string[]> {
|
||||
if (IS_TEST_MODE) return [];
|
||||
const bin = engine === 'podman' ? 'podman' : 'docker';
|
||||
let rows: Array<{ name: string; inst: string }> = [];
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
bin,
|
||||
[
|
||||
'ps',
|
||||
'-a',
|
||||
'--filter',
|
||||
'label=codeman.managed=1',
|
||||
'--format',
|
||||
'{{.Names}}\t{{index .Labels "codeman.instance"}}',
|
||||
],
|
||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||
);
|
||||
rows = stdout
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [name, inst = ''] = line.split('\t');
|
||||
return { name, inst };
|
||||
});
|
||||
} catch {
|
||||
return []; // daemon down / engine absent — nothing to reap
|
||||
}
|
||||
const cases = await readDockerCases(configDir);
|
||||
const expected = new Set(cases.map((c) => c.container ?? dockerContainerName(c.name)));
|
||||
const reaped: string[] = [];
|
||||
for (const { name, inst } of rows) {
|
||||
if (inst !== instance) continue; // only THIS instance's containers
|
||||
if (expected.has(name)) continue; // still referenced by a live case
|
||||
try {
|
||||
await execFileAsync(bin, ['rm', '-f', name], { timeout: DOCKER_PROBE_TIMEOUT_MS });
|
||||
reaped.push(name);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
return reaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the IN-CONTAINER Claude CLI version (`docker exec <container> claude
|
||||
* --version`). Feeds Session.cliVersion for docker sessions (the LOCAL claude
|
||||
* would report the wrong version and disable trackpad wheel-forwarding, #154).
|
||||
* Returns undefined on any failure. No-op under VITEST.
|
||||
*/
|
||||
export async function probeDockerCliVersion(
|
||||
docker: Pick<SessionDocker, 'engine' | 'containerName'>,
|
||||
mode: SessionMode
|
||||
): Promise<string | undefined> {
|
||||
if (IS_TEST_MODE) return undefined;
|
||||
const bin = mode === 'shell' ? null : mode;
|
||||
if (!bin) return undefined;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(docker.engine, ['exec', docker.containerName, bin, '--version'], {
|
||||
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
const match = stdout.trim().match(/\d+\.\d+\.\d+/);
|
||||
return match ? match[0] : stdout.trim() || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
EffortLevel,
|
||||
GeminiConfig,
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
@@ -36,6 +37,10 @@ export interface MuxSession {
|
||||
workingDir: string;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username in multi-user mode (round-tripped through recovery like remote/docker) */
|
||||
owner?: string;
|
||||
/** Session mode */
|
||||
mode: SessionMode;
|
||||
/** Whether webserver is attached to this session */
|
||||
@@ -79,6 +84,10 @@ export interface CreateSessionOptions {
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username in multi-user mode; persisted for recovery. */
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
/** Options for respawning a dead pane. */
|
||||
@@ -103,6 +112,10 @@ export interface RespawnPaneOptions {
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username (multi-user); redundant on respawn since the Session object survives, kept for shape parity. */
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
/** Options for pane buffer capture (COD-47 full-history mode). */
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { TerminalMultiplexer } from './mux-interface.js';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
|
||||
import { getErrorMessage, type PlanItem } from './types.js';
|
||||
import { getErrorMessage, type PlanItem, type ClaudeMode } from './types.js';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { PlanItem };
|
||||
@@ -130,18 +130,28 @@ export class PlanOrchestrator {
|
||||
private taskDescription = '';
|
||||
private researchModel: string;
|
||||
private plannerModel: string;
|
||||
// Multi-user permission threading: the resolved claudeMode/owner/allowedTools for the
|
||||
// internal research/planner one-shots. Left undefined = today's single-user behavior
|
||||
// (the caller threads the resolved global mode, byte-identical when !isMultiUserMode()).
|
||||
private claudeMode?: ClaudeMode;
|
||||
private owner?: string;
|
||||
private allowedTools?: string;
|
||||
|
||||
constructor(
|
||||
mux: TerminalMultiplexer,
|
||||
workingDir: string = process.cwd(),
|
||||
outputDir?: string,
|
||||
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> }
|
||||
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> },
|
||||
security?: { claudeMode?: ClaudeMode; owner?: string; allowedTools?: string }
|
||||
) {
|
||||
this.mux = mux;
|
||||
this.workingDir = workingDir;
|
||||
this.outputDir = outputDir;
|
||||
this.researchModel = modelConfig?.agentTypeOverrides?.explore || modelConfig?.defaultModel || DEFAULT_MODEL;
|
||||
this.plannerModel = modelConfig?.agentTypeOverrides?.review || modelConfig?.defaultModel || DEFAULT_MODEL;
|
||||
this.claudeMode = security?.claudeMode;
|
||||
this.owner = security?.owner;
|
||||
this.allowedTools = security?.allowedTools;
|
||||
}
|
||||
|
||||
private saveAgentOutput(agentType: string, prompt: string, result: unknown, durationMs: number): void {
|
||||
@@ -424,6 +434,12 @@ export class PlanOrchestrator {
|
||||
mux: this.mux,
|
||||
useMux: false,
|
||||
mode: 'claude',
|
||||
// Section 6.3: run this one-shot under the caller-resolved permission mode/owner so a
|
||||
// non-granted multi-user user cannot regain --dangerously-skip-permissions. Undefined
|
||||
// (single-user, not threaded) is byte-identical to today (Session keeps its default).
|
||||
claudeMode: this.claudeMode,
|
||||
allowedTools: this.allowedTools,
|
||||
owner: this.owner,
|
||||
});
|
||||
|
||||
this.runningSessions.add(session);
|
||||
@@ -580,6 +596,10 @@ export class PlanOrchestrator {
|
||||
mux: this.mux,
|
||||
useMux: false,
|
||||
mode: 'claude',
|
||||
// Section 6.3: same permission-mode/owner threading as the research one-shot above.
|
||||
claudeMode: this.claudeMode,
|
||||
allowedTools: this.allowedTools,
|
||||
owner: this.owner,
|
||||
});
|
||||
|
||||
this.runningSessions.add(session);
|
||||
|
||||
+25
-10
@@ -9,10 +9,23 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import webpush from 'web-push';
|
||||
import type { VapidKeys, PushSubscriptionRecord } from './types.js';
|
||||
import type { VapidKeys, PushSubscriptionRecord, UserRole } from './types.js';
|
||||
import { Debouncer } from './utils/index.js';
|
||||
import { getDataDir } from './config/instance.js';
|
||||
|
||||
/**
|
||||
* A push subscription plus the multi-user owner identity stamped at subscribe time.
|
||||
* `username`/`role` are undefined in single-user mode (and for legacy records saved
|
||||
* before this field existed). sendPushNotifications uses them to scope a
|
||||
* session-notification to its owner's devices (+ admins) instead of fanning out to
|
||||
* every user. Kept as a store-local widening of PushSubscriptionRecord so the shared
|
||||
* type stays untouched; the extra keys serialize/persist transparently.
|
||||
*/
|
||||
export type OwnedPushSubscriptionRecord = PushSubscriptionRecord & {
|
||||
username?: string;
|
||||
role?: UserRole;
|
||||
};
|
||||
|
||||
const DATA_DIR = getDataDir();
|
||||
const KEYS_FILE = join(DATA_DIR, 'push-keys.json');
|
||||
const SUBS_FILE = join(DATA_DIR, 'push-subscriptions.json');
|
||||
@@ -20,7 +33,7 @@ const SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
export class PushSubscriptionStore {
|
||||
private vapidKeys: VapidKeys | null = null;
|
||||
private subscriptions: Map<string, PushSubscriptionRecord> = new Map();
|
||||
private subscriptions: Map<string, OwnedPushSubscriptionRecord> = new Map();
|
||||
private saveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
|
||||
private _disposed = false;
|
||||
|
||||
@@ -67,17 +80,19 @@ export class PushSubscriptionStore {
|
||||
}
|
||||
|
||||
/** Register or update a push subscription (deduplicates by endpoint) */
|
||||
addSubscription(sub: Omit<PushSubscriptionRecord, 'lastUsedAt'>): PushSubscriptionRecord {
|
||||
addSubscription(sub: Omit<OwnedPushSubscriptionRecord, 'lastUsedAt'>): OwnedPushSubscriptionRecord {
|
||||
// Check for existing subscription with same endpoint
|
||||
for (const [existingId, existing] of this.subscriptions) {
|
||||
if (existing.endpoint === sub.endpoint) {
|
||||
// Update existing
|
||||
const updated: PushSubscriptionRecord = {
|
||||
// Update existing (re-stamp owner identity so it tracks the current caller)
|
||||
const updated: OwnedPushSubscriptionRecord = {
|
||||
...existing,
|
||||
keys: sub.keys,
|
||||
userAgent: sub.userAgent,
|
||||
lastUsedAt: Date.now(),
|
||||
pushPreferences: sub.pushPreferences,
|
||||
username: sub.username,
|
||||
role: sub.role,
|
||||
};
|
||||
this.subscriptions.set(existingId, updated);
|
||||
this.scheduleSave();
|
||||
@@ -86,7 +101,7 @@ export class PushSubscriptionStore {
|
||||
}
|
||||
|
||||
// New subscription
|
||||
const record: PushSubscriptionRecord = {
|
||||
const record: OwnedPushSubscriptionRecord = {
|
||||
...sub,
|
||||
lastUsedAt: Date.now(),
|
||||
};
|
||||
@@ -96,7 +111,7 @@ export class PushSubscriptionStore {
|
||||
}
|
||||
|
||||
/** Update push preferences for a subscription */
|
||||
updatePreferences(id: string, preferences: Record<string, boolean>): PushSubscriptionRecord | null {
|
||||
updatePreferences(id: string, preferences: Record<string, boolean>): OwnedPushSubscriptionRecord | null {
|
||||
const sub = this.subscriptions.get(id);
|
||||
if (!sub) return null;
|
||||
sub.pushPreferences = preferences;
|
||||
@@ -124,12 +139,12 @@ export class PushSubscriptionStore {
|
||||
}
|
||||
|
||||
/** Get all subscriptions */
|
||||
getAll(): PushSubscriptionRecord[] {
|
||||
getAll(): OwnedPushSubscriptionRecord[] {
|
||||
return Array.from(this.subscriptions.values());
|
||||
}
|
||||
|
||||
/** Get a single subscription by ID */
|
||||
get(id: string): PushSubscriptionRecord | null {
|
||||
get(id: string): OwnedPushSubscriptionRecord | null {
|
||||
return this.subscriptions.get(id) ?? null;
|
||||
}
|
||||
|
||||
@@ -138,7 +153,7 @@ export class PushSubscriptionStore {
|
||||
if (!existsSync(SUBS_FILE)) return;
|
||||
try {
|
||||
const raw = readFileSync(SUBS_FILE, 'utf-8');
|
||||
const arr = JSON.parse(raw) as PushSubscriptionRecord[];
|
||||
const arr = JSON.parse(raw) as OwnedPushSubscriptionRecord[];
|
||||
for (const sub of arr) {
|
||||
this.subscriptions.set(sub.id, sub);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): str
|
||||
switch (claudeMode) {
|
||||
case 'dangerously-skip-permissions':
|
||||
return ['--dangerously-skip-permissions'];
|
||||
case 'auto':
|
||||
return ['--permission-mode', 'auto'];
|
||||
case 'allowedTools':
|
||||
if (allowedTools) {
|
||||
return ['--allowedTools', allowedTools];
|
||||
@@ -80,8 +82,16 @@ export function buildInteractiveArgs(
|
||||
* @param model - Optional model override
|
||||
* @returns Array of CLI arguments
|
||||
*/
|
||||
export function buildPromptArgs(prompt: string, model?: string): string[] {
|
||||
const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json'];
|
||||
export function buildPromptArgs(
|
||||
prompt: string,
|
||||
model?: string,
|
||||
claudeMode: ClaudeMode = 'dangerously-skip-permissions',
|
||||
allowedTools?: string
|
||||
): string[] {
|
||||
// Respect the session's permission mode instead of always skipping, so a
|
||||
// multi-user non-granted user's one-shot runs classifier-guarded (auto) rather
|
||||
// than with full bypass. Defaults to skip-permissions (unchanged single-user).
|
||||
const args = ['-p', '--verbose', ...buildPermissionArgs(claudeMode, allowedTools), '--output-format', 'stream-json'];
|
||||
if (model) {
|
||||
args.push('--model', model);
|
||||
}
|
||||
|
||||
+76
-6
@@ -50,7 +50,9 @@ import {
|
||||
type EffortLevel,
|
||||
type GeminiConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
} from './types.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||
import { RalphTracker } from './ralph-tracker.js';
|
||||
@@ -178,6 +180,8 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
|
||||
const DEFAULT_PTY_COLS = 120;
|
||||
const DEFAULT_PTY_ROWS = 40;
|
||||
const TMUX_DISPLAY_TIMEOUT_MS = 2000;
|
||||
/** Delay before the in-container Claude CLI version probe (lets the container start). */
|
||||
const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000;
|
||||
|
||||
/**
|
||||
* Ask tmux for the current window geometry of `muxName` so a re-attaching PTY
|
||||
@@ -212,8 +216,10 @@ export function queryTmuxWindowSize(muxName: string, socket: string): { cols: nu
|
||||
return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS };
|
||||
}
|
||||
|
||||
export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote): string {
|
||||
return remote ? '/tmp' : workingDir;
|
||||
export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote, docker?: SessionDocker): string {
|
||||
// Remote and docker sessions run the CLI elsewhere (ssh / docker exec); the LOCAL
|
||||
// wrapper pane never needs the workspace as its cwd, so launch it in /tmp.
|
||||
return remote || docker ? '/tmp' : workingDir;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,6 +408,14 @@ export class Session extends EventEmitter {
|
||||
// Remote execution metadata, present when this session runs over SSH through local tmux.
|
||||
private readonly _remote?: SessionRemote;
|
||||
|
||||
// Docker execution metadata, present when this session runs inside a container via
|
||||
// local tmux + `docker exec`. The container is per-CASE (shared by sibling sessions).
|
||||
private readonly _docker?: SessionDocker;
|
||||
|
||||
// Owning username in multi-user mode (undefined in single-user). Stamped at create
|
||||
// from req.authUser and round-tripped through recovery like _remote/_docker.
|
||||
private _owner?: string;
|
||||
|
||||
// Session color for visual differentiation
|
||||
private _color: import('./types.js').SessionColor = 'default';
|
||||
|
||||
@@ -475,6 +489,10 @@ export class Session extends EventEmitter {
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for sessions launched inside a container via local tmux. */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username (multi-user mode); undefined in single-user. */
|
||||
owner?: string;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
@@ -548,6 +566,8 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
|
||||
this._remote = config.remote;
|
||||
this._docker = config.docker;
|
||||
this._owner = config.owner;
|
||||
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
||||
this.restoreAttachmentHistory(config.attachmentHistory);
|
||||
}
|
||||
@@ -649,6 +669,21 @@ export class Session extends EventEmitter {
|
||||
return this._claudeSessionId;
|
||||
}
|
||||
|
||||
/** Docker execution metadata when this session runs inside a container, else undefined. */
|
||||
get docker(): SessionDocker | undefined {
|
||||
return this._docker;
|
||||
}
|
||||
|
||||
/** Owning username in multi-user mode, else undefined. */
|
||||
get owner(): string | undefined {
|
||||
return this._owner;
|
||||
}
|
||||
|
||||
/** Set the owning username (used by recovery to restore ownership). */
|
||||
set owner(username: string | undefined) {
|
||||
this._owner = username;
|
||||
}
|
||||
|
||||
// Adopt a Claude conversation ID observed from an external source (e.g. hook
|
||||
// payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so
|
||||
// `_handleJsonMessage` never sees `session_id`; hooks are the only signal
|
||||
@@ -1008,6 +1043,8 @@ export class Session extends EventEmitter {
|
||||
status: this._status,
|
||||
workingDir: this.workingDir,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
currentTaskId: this._currentTaskId,
|
||||
createdAt: this.createdAt,
|
||||
lastActivityAt: this._lastActivityAt,
|
||||
@@ -1197,7 +1234,7 @@ export class Session extends EventEmitter {
|
||||
name: 'xterm-256color',
|
||||
cols: ptyCols,
|
||||
rows: ptyRows,
|
||||
cwd: resolveMuxAttachCwd(this.workingDir, this._remote),
|
||||
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
|
||||
// COD-75: codex/gemini get COLORTERM=truecolor — mirrors buildEnvExports()
|
||||
// in tmux-manager.ts so the attach client and the tmux session agree.
|
||||
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini'),
|
||||
@@ -1317,7 +1354,7 @@ export class Session extends EventEmitter {
|
||||
// repaint/alt-screen mode; issue #154). Remote sessions run claude on
|
||||
// another host, so a local probe wouldn't reflect their version — skip them
|
||||
// and let the banner scrape handle those. Cached process-wide, best-effort.
|
||||
if (this.mode === 'claude' && !this._remote && !this._cliVersion) {
|
||||
if (this.mode === 'claude' && !this._remote && !this._docker && !this._cliVersion) {
|
||||
const probedVersion = getClaudeCliVersion();
|
||||
if (probedVersion) {
|
||||
this._cliVersion = probedVersion;
|
||||
@@ -1330,6 +1367,31 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Docker sessions run claude INSIDE the container, so the local probe above
|
||||
// reports the HOST claude (wrong version, and leaving cliVersion undefined
|
||||
// silently disables wheel-forwarding, #154). Probe the IN-CONTAINER version
|
||||
// instead — deferred so the container is up after the mux attach below.
|
||||
if (this.mode === 'claude' && this._docker && !this._cliVersion) {
|
||||
const dockerMeta = this._docker;
|
||||
setTimeout(() => {
|
||||
if (this._isStopped || this._cliVersion) return;
|
||||
void probeDockerCliVersion(dockerMeta, this.mode)
|
||||
.then((version) => {
|
||||
if (!version || this._isStopped || this._cliVersion) return;
|
||||
this._cliVersion = version;
|
||||
this.emit('cliInfoUpdated', {
|
||||
version: this._cliVersion,
|
||||
model: this._cliModel,
|
||||
accountType: this._cliAccountType,
|
||||
latestVersion: this._cliLatestVersion,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
/* best-effort */
|
||||
});
|
||||
}, DOCKER_CLI_VERSION_PROBE_DELAY_MS);
|
||||
}
|
||||
|
||||
// If mux wrapping is enabled, create or attach to a mux session
|
||||
if (this._useMux && this._mux) {
|
||||
try {
|
||||
@@ -1350,6 +1412,8 @@ export class Session extends EventEmitter {
|
||||
effort: this._effort,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
@@ -1368,6 +1432,8 @@ export class Session extends EventEmitter {
|
||||
effort: this._effort,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
},
|
||||
spawnErrLabel: 'mux attachment',
|
||||
});
|
||||
@@ -1477,7 +1543,7 @@ export class Session extends EventEmitter {
|
||||
|
||||
// === Auto-accept workspace trust dialog ===
|
||||
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
|
||||
// Codeman sessions always use --dangerously-skip-permissions, so auto-accept.
|
||||
// Codeman sessions run permission-skipping or classifier-guarded (auto) modes, so auto-accept.
|
||||
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
|
||||
this._trustDialogAccepted = true;
|
||||
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
|
||||
@@ -1738,6 +1804,8 @@ export class Session extends EventEmitter {
|
||||
envOverrides: this._envOverrides,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
@@ -1748,6 +1816,8 @@ export class Session extends EventEmitter {
|
||||
envOverrides: this._envOverrides,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
},
|
||||
spawnErrLabel: 'shell mux attachment',
|
||||
});
|
||||
@@ -1875,7 +1945,7 @@ export class Session extends EventEmitter {
|
||||
model ? `(model: ${model})` : ''
|
||||
);
|
||||
|
||||
const args = buildPromptArgs(prompt, model);
|
||||
const args = buildPromptArgs(prompt, model, this._claudeMode, this._allowedTools);
|
||||
|
||||
try {
|
||||
this.ptyProcess = pty.spawn('claude', args, {
|
||||
|
||||
+327
-8
@@ -29,7 +29,8 @@ const execAsync = promisify(exec);
|
||||
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
import { writeFile, rename } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath, DEFAULT_TMUX_SOCKET } from './config/instance.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { dataPath, DEFAULT_TMUX_SOCKET, CODEMAN_INSTANCE } from './config/instance.js';
|
||||
import {
|
||||
ProcessStats,
|
||||
PersistedRespawnConfig,
|
||||
@@ -43,9 +44,24 @@ import {
|
||||
type EffortLevel,
|
||||
type GeminiConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
type DockerCommandMode,
|
||||
} from './types.js';
|
||||
import { buildEffortCliArgs } from './session-cli-builder.js';
|
||||
import { buildSshConnectionArgs, defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js';
|
||||
import {
|
||||
buildDockerBaseArgs,
|
||||
buildDockerCreateArgs,
|
||||
containerApiUrl,
|
||||
CONTAINER_HOME,
|
||||
defaultDockerCommandForMode,
|
||||
hostGatewayAlias,
|
||||
resolveDockerClaudeArtifacts,
|
||||
resolveDockerCredentialArtifacts,
|
||||
type DockerCreateContext,
|
||||
type DockerMount,
|
||||
type DockerSeedCopy,
|
||||
} from './docker-hosts.js';
|
||||
import {
|
||||
wrapWithNice,
|
||||
SAFE_PATH_PATTERN,
|
||||
@@ -547,6 +563,8 @@ function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: stri
|
||||
switch (mode) {
|
||||
case 'dangerously-skip-permissions':
|
||||
return ' --dangerously-skip-permissions';
|
||||
case 'auto':
|
||||
return ' --permission-mode auto';
|
||||
case 'allowedTools':
|
||||
if (allowedTools) {
|
||||
// Sanitize: allow tool names with patterns like Bash(git:*), space/comma-separated
|
||||
@@ -658,7 +676,7 @@ function buildEffortSettingsFlag(effort?: EffortLevel): string {
|
||||
return flag && value ? ` ${flag} '${value}'` : '';
|
||||
}
|
||||
|
||||
function buildSpawnCommand(options: {
|
||||
export function buildSpawnCommand(options: {
|
||||
mode: SessionMode;
|
||||
sessionId: string;
|
||||
model?: string;
|
||||
@@ -761,9 +779,22 @@ export function buildRemoteLaunchCommand(options: {
|
||||
mode: SessionMode;
|
||||
remote: SessionRemote;
|
||||
sessionId: string;
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
}): string {
|
||||
const { mode, remote, sessionId } = options;
|
||||
const modeCommand = remote.commands?.[mode] || defaultRemoteCommandForMode(mode);
|
||||
const { mode, remote, sessionId, claudeMode, allowedTools } = options;
|
||||
// §6.3: honor the session's EFFECTIVE claude permission mode on remote instead of
|
||||
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
|
||||
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
|
||||
// ignored claudeMode). A per-host `commands.claude` override stays authoritative
|
||||
// (admin's explicit choice). For the DEFAULT single-user config (skip), the emitted
|
||||
// command is byte-identical to before. Non-claude modes are unchanged.
|
||||
const override = remote.commands?.[mode];
|
||||
const modeCommand = override
|
||||
? override
|
||||
: mode === 'claude'
|
||||
? `exec claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`
|
||||
: defaultRemoteCommandForMode(mode);
|
||||
const remoteName = remoteTmuxSessionName(sessionId);
|
||||
|
||||
// Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
|
||||
@@ -812,6 +843,267 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session
|
||||
return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].join(' ');
|
||||
}
|
||||
|
||||
// ========== Docker cases (COD-Docker) ==========
|
||||
//
|
||||
// The docker analog of the remote-SSH launch above. Instead of a local tmux pane
|
||||
// running `ssh -t host 'tmux new-session …'`, it runs `docker exec -it <container>
|
||||
// sh -lc 'tmux new-session …'` into a DURABLE in-container tmux server. The
|
||||
// container is per-CASE, so many sessions `docker exec` into the same one. See
|
||||
// docs/docker-cases-plan.md.
|
||||
|
||||
/**
|
||||
* DEDICATED in-container tmux socket. A Codeman running INSIDE the container uses
|
||||
* `-L codeman`; ours is `-L codeman-docker` with a `codeman-dkr-*` session name
|
||||
* that deliberately FAILS SAFE_MUX_NAME_PATTERN, so an in-container Codeman never
|
||||
* adopts/resizes/respawns our session (same defence as the remote socket).
|
||||
*/
|
||||
const DOCKER_TMUX_SOCKET = 'codeman-docker';
|
||||
|
||||
/**
|
||||
* Deterministic, reattach-stable in-container tmux session name. Derived from the
|
||||
* same stable field the local muxName uses (first 8 chars of the sessionId), so a
|
||||
* reconnect re-issues the exact same `new-session -A` and lands back in the SAME
|
||||
* in-container session. The `dkr` letters make it fail SAFE_MUX_NAME_PATTERN.
|
||||
*/
|
||||
export function dockerTmuxSessionName(sessionId: string): string {
|
||||
return `codeman-dkr-${sessionId.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/** Resume ids are UUID-ish; reject anything with shell metacharacters (defensive). */
|
||||
const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
/**
|
||||
* Append the CLI-specific resume flag to a pane command. Only fires when the
|
||||
* in-container tmux is RE-CREATED (`new-session -A` makes the flag inert on a
|
||||
* live reattach), i.e. exactly when the previous live agent was lost and we want
|
||||
* to resume the conversation from the bind-mounted transcript.
|
||||
*/
|
||||
function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string {
|
||||
if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand;
|
||||
switch (mode) {
|
||||
case 'claude':
|
||||
case 'gemini':
|
||||
return `${modeCommand} --resume ${resumeId}`;
|
||||
case 'codex':
|
||||
return `${modeCommand} resume ${resumeId}`;
|
||||
default:
|
||||
return modeCommand; // shell / opencode: no resume
|
||||
}
|
||||
}
|
||||
|
||||
/** Fully-resolved inputs for buildDockerLaunchCommand (pure). */
|
||||
export interface DockerLaunchOptions {
|
||||
mode: SessionMode;
|
||||
docker: SessionDocker;
|
||||
sessionId: string;
|
||||
resumeSessionId?: string;
|
||||
createContext: DockerCreateContext;
|
||||
/** exec-time inline env (non-secret): TERM, COLORTERM, CODEMAN_SESSION_ID, CODEMAN_MUX */
|
||||
execEnv: Record<string, string>;
|
||||
/** exec-time NAME-ONLY env forwarded from Codeman's process env (codex/gemini keys) */
|
||||
execEnvNames: string[];
|
||||
/**
|
||||
* Files to copy from read-only seed mounts into the container's writable HOME once
|
||||
* before launch (guarded so reconnects never clobber). Isolates Claude state: the
|
||||
* merged `~/.claude.json`, plus `~/.claude/.credentials.json` + `settings.json`,
|
||||
* are writable copies (not host mounts), so the container never re-auths and never
|
||||
* writes its runtime state back into the host `~/.claude`.
|
||||
*/
|
||||
seedCopies?: DockerSeedCopy[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ONE `bash -c` launch string for a docker session: image-check ->
|
||||
* ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
|
||||
* in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
|
||||
* four layers: outer `bash -c "…"` (JSON.stringify at respawn-pane) -> the joined
|
||||
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
|
||||
*/
|
||||
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies } = opts;
|
||||
const base = buildDockerBaseArgs(docker).join(' ');
|
||||
const createArgs = buildDockerCreateArgs(createContext).join(' ');
|
||||
const name = shellescape(docker.containerName);
|
||||
const workdir = shellescape(docker.containerWorkdir);
|
||||
const image = shellescape(docker.image);
|
||||
const dkrName = dockerTmuxSessionName(sessionId);
|
||||
const sid = sessionId.slice(0, 8);
|
||||
|
||||
let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode);
|
||||
if (resumeSessionId) modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
|
||||
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
|
||||
// pane PID the agent itself.
|
||||
const paneCommand = `cd ${workdir} && ${modeCommand}`;
|
||||
|
||||
// `setenv -g` primes the session id so reattaches / newly-created panes inherit
|
||||
// it. `new-session -A` = attach-or-create (idempotent + resume-aware). Options
|
||||
// are scoped per-session (`set -t`) or server (`set -s`), never `-g`, so a shared
|
||||
// in-container tmux server's other sessions keep their own prefix/mouse.
|
||||
const tmuxInvocation = [
|
||||
`tmux -L ${DOCKER_TMUX_SOCKET} setenv -g CODEMAN_SESSION_ID ${shellescape(sid)}`,
|
||||
'setenv -g CODEMAN_MUX 1',
|
||||
`new-session -A -s ${dkrName} -c ${workdir} ${shellescape(paneCommand)}`,
|
||||
`set -t ${dkrName} status off`,
|
||||
`set -t ${dkrName} mouse off`,
|
||||
`set -t ${dkrName} prefix C-q`,
|
||||
'set -s escape-time 0',
|
||||
].join(' \\; ');
|
||||
|
||||
const execEnvFlags: string[] = [];
|
||||
for (const [k, v] of Object.entries(execEnv)) execEnvFlags.push('--env', shellescape(`${k}=${v}`));
|
||||
// NAME-ONLY forwards: docker reads the VALUE from Codeman's own process env, so
|
||||
// the secret never appears in argv (no `ps` leak) and is not committed.
|
||||
for (const n of execEnvNames) execEnvFlags.push('--env', n);
|
||||
for (const extra of docker.extraExecArgs ?? []) execEnvFlags.push(shellescape(extra));
|
||||
|
||||
const imageMissingMsg = shellescape(
|
||||
`Codeman: base image ${docker.image} not present (it is normally auto-built on first use)`
|
||||
);
|
||||
const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`);
|
||||
|
||||
const imageCheck = `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`;
|
||||
// create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain.
|
||||
const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`;
|
||||
const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
|
||||
// Seed writable credential config from read-only host mounts ONCE per container
|
||||
// (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for
|
||||
// whole-dir credential seeds). mkdir -p the parent so a file seed works even when
|
||||
// no sibling share-mount pre-created the dir. Paths are fixed CONTAINER_HOME
|
||||
// constants (no shell metachars), so the whole inner command is shell-quoted once.
|
||||
const seedSteps = (seedCopies ?? []).map((s) => {
|
||||
const cp = s.recursive ? 'cp -a' : 'cp';
|
||||
const parent = s.to.slice(0, s.to.lastIndexOf('/'));
|
||||
return `mkdir -p ${parent} 2>/dev/null; [ -e ${s.to} ] || ${cp} ${s.from} ${s.to} 2>/dev/null || true`;
|
||||
});
|
||||
const innerCmd = seedSteps.length ? `${seedSteps.join(' ; ')} ; ${tmuxInvocation}` : tmuxInvocation;
|
||||
const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(innerCmd)}`;
|
||||
|
||||
return [imageCheck, ensure, start, execCmd].join(' ; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill ONLY this session's in-container tmux session. The container is shared by
|
||||
* the case's other sessions, so this NEVER `docker stop`s it — stopping/removing
|
||||
* the container is an explicit teardown (buildDockerStopCommand) or case-delete
|
||||
* (buildDockerRemoveCommand). Fired best-effort on session kill.
|
||||
*/
|
||||
export function buildDockerKillCommand(options: { docker: SessionDocker; sessionId: string }): string {
|
||||
const { docker, sessionId } = options;
|
||||
const base = buildDockerBaseArgs(docker).join(' ');
|
||||
const dkrName = dockerTmuxSessionName(sessionId);
|
||||
return `${base} exec ${shellescape(docker.containerName)} tmux -L ${DOCKER_TMUX_SOCKET} kill-session -t ${shellescape(dkrName)}`;
|
||||
}
|
||||
|
||||
/** Explicit container stop (frees RAM/CPU; conversation resumes on next launch via --resume). */
|
||||
export function buildDockerStopCommand(docker: SessionDocker): string {
|
||||
return `${buildDockerBaseArgs(docker).join(' ')} stop -t 10 ${shellescape(docker.containerName)}`;
|
||||
}
|
||||
|
||||
/** Explicit container removal (case-delete). Destroys in-image state; bind mounts survive. */
|
||||
export function buildDockerRemoveCommand(docker: SessionDocker): string {
|
||||
return `${buildDockerBaseArgs(docker).join(' ')} rm -f ${shellescape(docker.containerName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the environment-dependent bits of a docker launch (host uid, existing
|
||||
* credential mounts, derived api url, hook-secret mount, Desktop detection) into
|
||||
* the pure buildDockerLaunchCommand inputs. IO; only ever called from the real
|
||||
* launch path (createSession/respawnPane no-op under VITEST).
|
||||
*/
|
||||
export function resolveDockerLaunchOptions(
|
||||
mode: SessionMode,
|
||||
docker: SessionDocker,
|
||||
sessionId: string,
|
||||
resumeSessionId?: string
|
||||
): DockerLaunchOptions {
|
||||
const home = homedir();
|
||||
const isDesktop = process.platform === 'darwin'; // Docker Desktop translates uids + native host.docker.internal
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : 1000;
|
||||
const userArgs: string[] =
|
||||
docker.engine === 'podman'
|
||||
? ['--userns=keep-id'] // rootless podman: map host uid to the image `agent` uid
|
||||
: isDesktop
|
||||
? [] // Desktop: run as the image's baked uid (a mac uid wouldn't own /home/agent)
|
||||
: ['--user', `${uid}:0`]; // Linux: host uid + GID 0 (OpenShift arbitrary-uid writable HOME)
|
||||
const gatewayAlias = hostGatewayAlias(docker.engine);
|
||||
|
||||
const credentialMounts: DockerMount[] = [];
|
||||
const extraMounts: DockerMount[] = [];
|
||||
// Isolated credential state (Claude + codex/gemini/gcloud/opencode): each store
|
||||
// shares ONLY what a host feature / --resume needs (Claude projects/, codex
|
||||
// sessions/+history) and seeds everything else (tokens, settings, configs) as
|
||||
// writable copies, so the container is authed WITHOUT re-auth and WITHOUT writing
|
||||
// its runtime state back into the host dirs. Only when credentials are mounted.
|
||||
let seedCopies: DockerSeedCopy[] = [];
|
||||
if (docker.mountCredentials) {
|
||||
const claudeArtifacts = resolveDockerClaudeArtifacts(home, docker.containerName, docker.containerWorkdir);
|
||||
const credArtifacts = resolveDockerCredentialArtifacts(home);
|
||||
extraMounts.push(...claudeArtifacts.mounts, ...credArtifacts.mounts);
|
||||
seedCopies = [...claudeArtifacts.seedCopies, ...credArtifacts.seedCopies];
|
||||
}
|
||||
const envCreate: Record<string, string> = {
|
||||
HOME: CONTAINER_HOME,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
// Force a UTF-8 locale (the base image defaults to POSIX/C). Without this, tmux
|
||||
// runs in non-UTF-8 mode and renders Claude's Unicode box-drawing (─│┌┐) as raw
|
||||
// VT100 ACS glyphs (`qqqq…`). `C.UTF-8` is built into glibc (no locale-gen).
|
||||
LANG: 'C.UTF-8',
|
||||
LC_ALL: 'C.UTF-8',
|
||||
// Give claude a temp dir it will own inside HOME. Its default `/tmp/claude-<uid>`
|
||||
// is refused when that path pre-exists root-owned — which happens when the
|
||||
// workspace bind-mount path traverses it (e.g. a workspace under /tmp/claude-<uid>).
|
||||
// A nonexistent HOME subpath is created+owned by the running uid, so this is robust
|
||||
// to any workspace location. Non-secret path, safe to be committed on export.
|
||||
CLAUDE_CODE_TMPDIR: `${CONTAINER_HOME}/.cache/codeman-claude-tmp`,
|
||||
};
|
||||
if (docker.hooksEnabled) {
|
||||
// Derive a container-reachable API url (scheme + port preserved; host swapped
|
||||
// for the engine gateway alias). Prod is HTTPS on 3000.
|
||||
envCreate.CODEMAN_API_URL = containerApiUrl(process.env.CODEMAN_API_URL, docker.engine);
|
||||
const hookSecretPath = dataPath('hook-secret');
|
||||
if (existsSync(hookSecretPath)) {
|
||||
const dst = `${CONTAINER_HOME}/.codeman/hook-secret`;
|
||||
extraMounts.push({ src: hookSecretPath, dst, readonly: true });
|
||||
envCreate.CODEMAN_HOOK_SECRET_FILE = dst; // a path is non-secret; the bytes ride the bind mount
|
||||
}
|
||||
}
|
||||
|
||||
const createContext: DockerCreateContext = {
|
||||
docker,
|
||||
sessionId,
|
||||
instance: CODEMAN_INSTANCE,
|
||||
userArgs,
|
||||
credentialMounts,
|
||||
extraMounts,
|
||||
envCreate,
|
||||
addHostGateway: !isDesktop,
|
||||
gatewayAlias,
|
||||
};
|
||||
|
||||
const execEnv: Record<string, string> = {
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
// UTF-8 at exec time too, so the tmux CLIENT this exec launches is UTF-8 and
|
||||
// renders box-drawing correctly even when reattaching to a container created
|
||||
// before this fix (client_utf8 is per-client, resolved from the exec's locale).
|
||||
LANG: 'C.UTF-8',
|
||||
LC_ALL: 'C.UTF-8',
|
||||
CODEMAN_SESSION_ID: sessionId.slice(0, 8),
|
||||
CODEMAN_MUX: '1',
|
||||
};
|
||||
// NAME-ONLY exec env forwarded from Codeman's process env (the docker client
|
||||
// inherits it), so API-key CLIs get their key without it appearing in argv.
|
||||
const execEnvNames =
|
||||
mode === 'codex'
|
||||
? ['OPENAI_API_KEY', 'CODEX_API_KEY']
|
||||
: mode === 'gemini'
|
||||
? ['GEMINI_API_KEY', 'GOOGLE_API_KEY']
|
||||
: [];
|
||||
|
||||
return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sensitive environment variables on a tmux session via setenv.
|
||||
* These are inherited by panes but not visible in ps output or tmux history.
|
||||
@@ -1209,6 +1501,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
effort,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
owner,
|
||||
} = options;
|
||||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
@@ -1228,6 +1522,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
remote,
|
||||
docker,
|
||||
owner,
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
@@ -1273,7 +1569,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
try {
|
||||
// Build the full command to run inside tmux
|
||||
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
||||
const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;
|
||||
const fullCmd = docker
|
||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||
: remote
|
||||
? buildRemoteLaunchCommand({ mode, remote, sessionId, claudeMode, allowedTools })
|
||||
: localFullCmd;
|
||||
|
||||
// Create tmux session in three steps to handle cold-start (no server running)
|
||||
// and avoid the race where the command exits before remain-on-exit is set:
|
||||
@@ -1324,7 +1624,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
// Replace the shell with the actual command (no echo in terminal). Keep
|
||||
// pane launch in /tmp, then cd inside bash against the current mount table.
|
||||
const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
execSync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
{
|
||||
@@ -1399,6 +1699,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
remote,
|
||||
docker,
|
||||
owner,
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
@@ -1484,6 +1786,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
effort,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
} = options;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
@@ -1521,7 +1824,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
const cmd = wrapWithNice(baseCmd, config);
|
||||
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
||||
const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;
|
||||
const fullCmd = docker
|
||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||
: remote
|
||||
? buildRemoteLaunchCommand({ mode, remote, sessionId, claudeMode, allowedTools })
|
||||
: localFullCmd;
|
||||
|
||||
try {
|
||||
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
||||
@@ -1539,7 +1846,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.applyEnvOverrides(muxName, envOverrides);
|
||||
|
||||
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
|
||||
const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
await execAsync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
{
|
||||
@@ -1725,6 +2032,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3c: Docker sessions run a DURABLE in-container tmux session. Kill
|
||||
// ONLY this session's in-container tmux session (best-effort). The container is
|
||||
// PER-CASE and shared by the case's other sessions, so we deliberately do NOT
|
||||
// `docker stop` it here — stopping/removing is an explicit teardown/case-delete.
|
||||
if (session.docker && !IS_TEST_MODE) {
|
||||
try {
|
||||
exec(buildDockerKillCommand({ docker: session.docker, sessionId }), { timeout: EXEC_TIMEOUT_MS }, () => {});
|
||||
} catch {
|
||||
// Best-effort — never affects the local kill result.
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: Direct kill by PID as final fallback
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
try {
|
||||
|
||||
+51
-8
@@ -43,6 +43,8 @@ interface QrTokenRecord {
|
||||
shortCode: string; // 6 chars base62 (for URL path)
|
||||
createdAt: number; // Date.now()
|
||||
consumed: boolean; // single-use flag
|
||||
/** Multi-user: the user this token logs in when redeemed (absent = rotating global token). */
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/** Rejection-sampled base62 short code — no modulo bias */
|
||||
@@ -378,23 +380,64 @@ export class TunnelManager extends EventEmitter {
|
||||
* Map.get() is hash-based — no timing side-channel from string comparison.
|
||||
*/
|
||||
consumeToken(shortCode: string): boolean {
|
||||
return this.consumeTokenWithIdentity(shortCode).ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like consumeToken, but also returns the bound username for multi-user tokens
|
||||
* (undefined for the rotating global token). Only the identity-less rotating
|
||||
* token triggers an immediate re-rotation (desktop gets a fresh QR); per-user
|
||||
* tokens are on-demand and self-expire.
|
||||
*/
|
||||
consumeTokenWithIdentity(shortCode: string): { ok: boolean; username?: string } {
|
||||
// Global rate limit (across all IPs)
|
||||
if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return false;
|
||||
if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return { ok: false };
|
||||
this.qrAttemptCount++;
|
||||
|
||||
const record = this.qrTokensByCode.get(shortCode);
|
||||
if (!record) return false;
|
||||
if (record.consumed) return false;
|
||||
if (!record) return { ok: false };
|
||||
if (record.consumed) return { ok: false };
|
||||
|
||||
const now = Date.now();
|
||||
if (now - record.createdAt > QR_TOKEN_GRACE_MS) return false;
|
||||
if (now - record.createdAt > QR_TOKEN_GRACE_MS) return { ok: false };
|
||||
|
||||
// Atomic consume (single-threaded JS = no race)
|
||||
record.consumed = true;
|
||||
// Immediately rotate so desktop gets a fresh QR
|
||||
this.rotateToken();
|
||||
this.emit('qrTokenRegenerated');
|
||||
return true;
|
||||
const username = record.username;
|
||||
if (!username) {
|
||||
// Rotating global token — immediately rotate so desktop gets a fresh QR.
|
||||
this.rotateToken();
|
||||
this.emit('qrTokenRegenerated');
|
||||
} else {
|
||||
this.qrTokensByCode.delete(shortCode);
|
||||
}
|
||||
return { ok: true, username };
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-user: mint a single-use token bound to a specific user (on-demand, no
|
||||
* rotation). Evicts expired/consumed tokens first. Returns the short code.
|
||||
*/
|
||||
mintUserToken(username: string): string {
|
||||
const now = Date.now();
|
||||
for (const [code, rec] of this.qrTokensByCode) {
|
||||
if (now - rec.createdAt > QR_TOKEN_GRACE_MS || rec.consumed) this.qrTokensByCode.delete(code);
|
||||
}
|
||||
const record: QrTokenRecord = {
|
||||
token: randomBytes(32).toString('hex'),
|
||||
shortCode: generateShortCode(),
|
||||
createdAt: Date.now(),
|
||||
consumed: false,
|
||||
username,
|
||||
};
|
||||
this.qrTokensByCode.set(record.shortCode, record);
|
||||
return record.shortCode;
|
||||
}
|
||||
|
||||
/** Render a QR SVG for an arbitrary short code (used by per-user minting). */
|
||||
async getQrSvgForCode(tunnelUrl: string, code: string): Promise<string> {
|
||||
const QRCode = await import('qrcode');
|
||||
return QRCode.toString(`${tunnelUrl}/q/${code}`, { type: 'svg', margin: 2, width: 256 });
|
||||
}
|
||||
|
||||
/** Force-regenerate (manual revocation via API) */
|
||||
|
||||
+29
-1
@@ -37,6 +37,16 @@ export enum ApiErrorCode {
|
||||
RATE_LIMITED = 'RATE_LIMITED',
|
||||
/** Operation could not be completed (well-formed but unprocessable) */
|
||||
OPERATION_FAILED = 'OPERATION_FAILED',
|
||||
/** Authenticated but not permitted (e.g. non-admin hitting an admin route) */
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
/** User must change their password before any other action (multi-user) */
|
||||
PASSWORD_CHANGE_REQUIRED = 'PASSWORD_CHANGE_REQUIRED',
|
||||
/** A user with this name already exists (multi-user) */
|
||||
USER_EXISTS = 'USER_EXISTS',
|
||||
/** No user with this name (multi-user) */
|
||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||
/** Refusing to demote/disable/delete the last enabled admin (multi-user) */
|
||||
LAST_ADMIN = 'LAST_ADMIN',
|
||||
/** Internal server error */
|
||||
INTERNAL_ERROR = 'INTERNAL_ERROR',
|
||||
}
|
||||
@@ -53,6 +63,11 @@ const ErrorMessages: Record<ApiErrorCode, string> = {
|
||||
[ApiErrorCode.ALREADY_EXISTS]: 'Resource already exists',
|
||||
[ApiErrorCode.RATE_LIMITED]: 'Too many requests',
|
||||
[ApiErrorCode.OPERATION_FAILED]: 'The operation failed',
|
||||
[ApiErrorCode.FORBIDDEN]: 'You do not have permission to perform this action',
|
||||
[ApiErrorCode.PASSWORD_CHANGE_REQUIRED]: 'You must change your password before continuing',
|
||||
[ApiErrorCode.USER_EXISTS]: 'A user with that name already exists',
|
||||
[ApiErrorCode.USER_NOT_FOUND]: 'No such user',
|
||||
[ApiErrorCode.LAST_ADMIN]: 'Cannot remove the last enabled admin',
|
||||
[ApiErrorCode.INTERNAL_ERROR]: 'An internal error occurred',
|
||||
};
|
||||
|
||||
@@ -69,6 +84,11 @@ const ErrorStatus: Record<ApiErrorCode, number> = {
|
||||
[ApiErrorCode.CONFLICT]: 409,
|
||||
[ApiErrorCode.ALREADY_EXISTS]: 409,
|
||||
[ApiErrorCode.OPERATION_FAILED]: 422,
|
||||
[ApiErrorCode.FORBIDDEN]: 403,
|
||||
[ApiErrorCode.PASSWORD_CHANGE_REQUIRED]: 403,
|
||||
[ApiErrorCode.USER_EXISTS]: 409,
|
||||
[ApiErrorCode.USER_NOT_FOUND]: 404,
|
||||
[ApiErrorCode.LAST_ADMIN]: 409,
|
||||
[ApiErrorCode.RATE_LIMITED]: 429,
|
||||
[ApiErrorCode.INTERNAL_ERROR]: 500,
|
||||
};
|
||||
@@ -124,7 +144,7 @@ export interface CaseInfo {
|
||||
/** Whether CLAUDE.md exists */
|
||||
hasClaudeMd?: boolean;
|
||||
/** Case storage/execution location */
|
||||
location?: 'local' | 'linked-local' | 'remote';
|
||||
location?: 'local' | 'linked-local' | 'remote' | 'docker';
|
||||
/** Whether this is a linked local folder */
|
||||
linked?: boolean;
|
||||
/** Remote case metadata for display and session creation */
|
||||
@@ -134,6 +154,14 @@ export interface CaseInfo {
|
||||
username: string;
|
||||
path: string;
|
||||
};
|
||||
/** Docker case metadata for display and session creation */
|
||||
docker?: {
|
||||
hostId: string;
|
||||
container: string;
|
||||
image?: string;
|
||||
path: string;
|
||||
network?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Error Handling Utilities ==========
|
||||
|
||||
@@ -36,6 +36,8 @@ export type ConcurrencyPolicy = 'warn_only' | 'skip_if_same_agent_running';
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Owning username in multi-user mode; the job launches as this user. Undefined in single-user. */
|
||||
owner?: string;
|
||||
/** Reuses Codeman's existing session modes; 'shell' covers Terminal/custom. */
|
||||
agentType: SessionMode;
|
||||
workingDir: string;
|
||||
|
||||
@@ -69,3 +69,4 @@ export * from './orchestrator.js';
|
||||
export * from './update.js';
|
||||
export * from './workflow-run.js';
|
||||
export * from './search.js';
|
||||
export * from './user.js';
|
||||
|
||||
+126
-1
@@ -35,10 +35,11 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
/**
|
||||
* Claude CLI startup permission mode.
|
||||
* - `'dangerously-skip-permissions'`: Bypass all permission prompts (default)
|
||||
* - `'auto'`: Anthropic's classifier-guarded low-prompt mode (`--permission-mode auto`)
|
||||
* - `'normal'`: Standard mode with permission prompts
|
||||
* - `'allowedTools'`: Only allow specific tools (requires allowedTools list)
|
||||
*/
|
||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
|
||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
|
||||
|
||||
/** Session mode: which CLI backend a session runs */
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini';
|
||||
@@ -84,6 +85,8 @@ export interface RemoteHost extends RemoteSshOptions {
|
||||
export interface RemoteCase {
|
||||
name: string;
|
||||
type: 'remote';
|
||||
/** Owning username in multi-user mode; absent = legacy/unassigned (admin-only). */
|
||||
owner?: string;
|
||||
hostId: string;
|
||||
remotePath: string;
|
||||
}
|
||||
@@ -98,6 +101,124 @@ export interface SessionRemote extends RemoteSshOptions {
|
||||
commands?: Partial<Record<RemoteCommandMode, string>>;
|
||||
}
|
||||
|
||||
// ========== Docker cases (COD-Docker) ==========
|
||||
//
|
||||
// Docker mode is a LOCATION OVERLAY on cases (never a 6th SessionMode), the exact
|
||||
// analog of the remote-SSH feature above: instead of a local tmux pane running
|
||||
// `ssh host` into a durable remote tmux server, a local tmux pane runs
|
||||
// `docker exec -it` into a durable in-container tmux server. The container is
|
||||
// scoped to the CASE (not the session), so multiple sessions can `docker exec`
|
||||
// into the same long-lived container. See `docs/docker-cases-plan.md`.
|
||||
|
||||
/** Which CLI backends a Docker case can run (same set as remote). */
|
||||
export type DockerCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
|
||||
|
||||
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
||||
export type DockerEngine = 'docker' | 'podman';
|
||||
|
||||
/**
|
||||
* Container network mode. `host` and any inbound `-p` publish are deliberately
|
||||
* unrepresentable (never in this union, never emitted by the flag builder).
|
||||
* - `bridge`: own netns, NAT egress, no inbound (default — every API CLI needs egress)
|
||||
* - `none`: fully offline sandbox (breaks API CLIs; reserved for `shell`)
|
||||
* - `custom`: a user-defined bridge `codeman-net-<slug>` (future egress-allowlist chokepoint)
|
||||
*/
|
||||
export type DockerNetworkMode = 'bridge' | 'none' | 'custom';
|
||||
|
||||
/** Per-container resource caps. Advisory under non-delegated rootless (see `capsEnforced`). */
|
||||
export interface DockerResourceLimits {
|
||||
/** e.g. '4g' -> --memory 4g --memory-swap 4g (swap==memory: a real OOM cap) */
|
||||
memory?: string;
|
||||
/** e.g. '2' -> --cpus 2 */
|
||||
cpus?: string;
|
||||
/** e.g. 512 -> --pids-limit 512 (fork-bomb guard) */
|
||||
pidsLimit?: number;
|
||||
/** e.g. '4096:8192' -> --ulimit nofile=4096:8192 */
|
||||
nofile?: string;
|
||||
/** e.g. '256m' -> --shm-size (only when a tool needs /dev/shm) */
|
||||
shmSize?: string;
|
||||
}
|
||||
|
||||
/** A reusable Docker engine/image/network/resource profile (mirror of RemoteHost). */
|
||||
export interface DockerHost {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Engine; when absent the availability probe resolves it (docker, else podman). */
|
||||
engine?: DockerEngine;
|
||||
/** Base image ref (built locally by scripts/build-agent-image.mjs, e.g. codeman/agent:base). */
|
||||
image: string;
|
||||
/** Advanced: remote daemon (-H ssh://user@host or a DOCKER_HOST value). */
|
||||
daemonHost?: string;
|
||||
/** Advanced: docker `--context` name. */
|
||||
context?: string;
|
||||
/** Network mode (default 'bridge'). */
|
||||
network?: DockerNetworkMode;
|
||||
/** Custom bridge name when network === 'custom'. */
|
||||
networkName?: string;
|
||||
resources?: DockerResourceLimits;
|
||||
/** GPU allocation, e.g. 'all' / '1' / 'device=0,1' -> `--gpus <value>` (needs the NVIDIA container toolkit). */
|
||||
gpus?: string;
|
||||
/** true (default) = convenient: bind-mount host cred dirs RW. false = sealed (blocks full-image export). */
|
||||
mountCredentials?: boolean;
|
||||
/** true (default) = wire in-container hooks (host-gateway callback + workspace scaffold). */
|
||||
hooksEnabled?: boolean;
|
||||
/** true (default) = a relaunch resumes the last conversation from the bind-mounted transcript. */
|
||||
resumeOnStart?: boolean;
|
||||
/** Per-mode command overrides (mirror RemoteHost.commands). */
|
||||
commands?: Partial<Record<DockerCommandMode, string>>;
|
||||
/** Escape hatch: extra `docker create` args (validated like extraSshOptions). */
|
||||
extraCreateArgs?: string[];
|
||||
/** Escape hatch: extra `docker exec` args. */
|
||||
extraExecArgs?: string[];
|
||||
}
|
||||
|
||||
/** A case linked to a Docker container (mirror of RemoteCase). */
|
||||
export interface DockerCase {
|
||||
name: string;
|
||||
type: 'docker';
|
||||
/** Owning username in multi-user mode; absent = legacy/unassigned (admin-only). */
|
||||
owner?: string;
|
||||
hostId: string;
|
||||
/** Absolute HOST directory: the bind-mount source AND Session.workingDir (real host bytes). */
|
||||
hostWorkspacePath: string;
|
||||
/** Container path (default = hostWorkspacePath: mirror -> transcript projHash correlates). */
|
||||
containerWorkdir?: string;
|
||||
/** Container name (default codeman-case-<slug>). */
|
||||
container?: string;
|
||||
/** Last captured Claude conversation id, replayed via --resume on a fresh launch. */
|
||||
lastClaudeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattened Docker execution metadata carried on a live session (mirror of
|
||||
* SessionRemote). Round-trips through MuxSession/SessionState/mux-sessions.json.
|
||||
*/
|
||||
export interface SessionDocker {
|
||||
hostId: string;
|
||||
label: string;
|
||||
engine: DockerEngine;
|
||||
image: string;
|
||||
/** Per-CASE container name (shared by all sessions of the case). */
|
||||
containerName: string;
|
||||
hostWorkspacePath: string;
|
||||
containerWorkdir: string;
|
||||
network: DockerNetworkMode;
|
||||
networkName?: string;
|
||||
resources?: DockerResourceLimits;
|
||||
/** GPU allocation ('all' / '1' / 'device=0,1'). */
|
||||
gpus?: string;
|
||||
mountCredentials: boolean;
|
||||
hooksEnabled: boolean;
|
||||
resumeOnStart: boolean;
|
||||
daemonHost?: string;
|
||||
context?: string;
|
||||
commands?: Partial<Record<DockerCommandMode, string>>;
|
||||
extraCreateArgs?: string[];
|
||||
extraExecArgs?: string[];
|
||||
/** Stable hash of the drift-relevant create args (recreate-on-drift detection). */
|
||||
configHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid Claude CLI effort levels (claude >= 2.1.154).
|
||||
* `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a
|
||||
@@ -217,6 +338,10 @@ export interface SessionState {
|
||||
workingDir: string;
|
||||
/** Remote execution metadata, present when this session runs over SSH through local tmux */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username in multi-user mode; undefined in single-user (ignored when the flag is off) */
|
||||
owner?: string;
|
||||
/** ID of currently assigned task, null if none */
|
||||
currentTaskId: string | null;
|
||||
/** Timestamp when session was created */
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @fileoverview Multi-user mode types (opt-in `--multiuser`).
|
||||
*
|
||||
* Users live in `~/.codeman/users.json` (via `dataPath`, mode 0600). Each record
|
||||
* carries a scrypt password hash with its own parameters so hashing cost can be
|
||||
* raised later and old records rehashed on next login. `AuthUser` is the
|
||||
* request-scoped identity decorated onto Fastify requests; in SINGLE-user mode a
|
||||
* synthetic `{ username: 'admin', role: 'admin' }` is used so downstream code has
|
||||
* one code path. See `src/user-store.ts` and `docs/multi-user-plan.md`.
|
||||
*/
|
||||
|
||||
export type UserRole = 'admin' | 'user';
|
||||
|
||||
/** Per-record scrypt parameters + salt/hash (all hex). */
|
||||
export interface PasswordHash {
|
||||
algo: 'scrypt';
|
||||
N: number;
|
||||
r: number;
|
||||
p: number;
|
||||
salt: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface UserRecord {
|
||||
/** Canonical lowercase slug; also the user's folder name under USER_SPACES_DIR. */
|
||||
username: string;
|
||||
role: UserRole;
|
||||
password: PasswordHash;
|
||||
/** Disabled accounts fail auth closed but keep their space on disk. */
|
||||
disabled?: boolean;
|
||||
/** Set by an admin reset; gates all API access until the user changes it. */
|
||||
mustChangePassword?: boolean;
|
||||
/**
|
||||
* Permission-mode grant (section 6.3). When false (the default for new users),
|
||||
* the user's Claude sessions are forced to `--permission-mode auto`, shell mode
|
||||
* and cron `launchCommand` are refused, and other CLIs' bypass flags are dropped.
|
||||
*/
|
||||
canBypassPermissions?: boolean;
|
||||
createdAt: number;
|
||||
lastLoginAt?: number;
|
||||
}
|
||||
|
||||
/** On-disk shape of `users.json`. */
|
||||
export interface UsersFile {
|
||||
version: 1;
|
||||
users: UserRecord[];
|
||||
}
|
||||
|
||||
/** Request-scoped identity (decorated as `req.authUser`). */
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
/** Admin-facing projection of a user: never carries the password hash. */
|
||||
export interface PublicUser {
|
||||
username: string;
|
||||
role: UserRole;
|
||||
disabled: boolean;
|
||||
mustChangePassword: boolean;
|
||||
canBypassPermissions: boolean;
|
||||
createdAt: number;
|
||||
lastLoginAt?: number;
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* @fileoverview Multi-user store: `~/.codeman/users.json` (via `dataPath`, 0600).
|
||||
*
|
||||
* Mirrors the storage-module pattern of `remote-hosts.ts` / `docker-hosts.ts`, but
|
||||
* because it holds password hashes it writes atomically (tmp + rename) at mode
|
||||
* 0600 and keeps only a SHORT in-process cache so the CLI (`codeman users …`) can
|
||||
* edit the file while the server runs and have changes picked up within the TTL.
|
||||
*
|
||||
* Pure, IO-free helpers (`isValidUsername`, `hashPassword`, `verifyPasswordHash`,
|
||||
* `needsRehash`, `resolveClaudeModeForUser`, the last-admin invariants) are split
|
||||
* out so they are unit-testable without a server. Hashing is `scrypt` from
|
||||
* `node:crypto` (no new deps), compared via `timingSafeEqual`; parameters are
|
||||
* stored per record so cost can be raised later and old records rehashed on their
|
||||
* next successful login.
|
||||
*
|
||||
* See `docs/multi-user-plan.md` sections 4.1, 5, 6.3.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { isAbsolute, join, relative } from 'node:path';
|
||||
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { dataPath, getDataDir } from './config/instance.js';
|
||||
import { getUserSpacesDir, isMultiUserMode, maxUsers } from './config/multiuser.js';
|
||||
import type { AuthUser, ClaudeMode, PasswordHash, PublicUser, UserRecord, UserRole, UsersFile } from './types.js';
|
||||
|
||||
const scrypt = promisify(scryptCb) as (
|
||||
password: string | Buffer,
|
||||
salt: string | Buffer,
|
||||
keylen: number,
|
||||
options: { N: number; r: number; p: number; maxmem: number }
|
||||
) => Promise<Buffer>;
|
||||
|
||||
const USERS_FILE = 'users.json';
|
||||
const CACHE_TTL_MS = 1000;
|
||||
const KEYLEN = 64;
|
||||
const SALT_BYTES = 32;
|
||||
/** Generous ceiling so raising N/r later does not trip scrypt's memory guard. */
|
||||
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
|
||||
|
||||
/** Current hashing parameters. Stored per record; raise these to increase cost. */
|
||||
export const DEFAULT_SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 } as const;
|
||||
|
||||
/** Username: lowercase, first char alphanumeric, 2-32 chars total. Becomes a folder name. */
|
||||
const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{1,31}$/;
|
||||
|
||||
/** Typed error whose `.code` maps to an API errorCode at the route layer. */
|
||||
export class UserStoreError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: 'USER_EXISTS' | 'USER_NOT_FOUND' | 'LAST_ADMIN' | 'INVALID_INPUT'
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'UserStoreError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────── pure helpers ───────────────────────────────
|
||||
|
||||
export function normalizeUsername(name: string): string {
|
||||
return String(name ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isValidUsername(name: string): boolean {
|
||||
return USERNAME_RE.test(normalizeUsername(name));
|
||||
}
|
||||
|
||||
/** Hash a password with the given (or current) scrypt params + a fresh random salt. */
|
||||
export async function hashPassword(
|
||||
password: string,
|
||||
params: { N: number; r: number; p: number } = DEFAULT_SCRYPT_PARAMS
|
||||
): Promise<PasswordHash> {
|
||||
const salt = randomBytes(SALT_BYTES);
|
||||
const derived = await scrypt(password, salt, KEYLEN, { ...params, maxmem: SCRYPT_MAXMEM });
|
||||
return {
|
||||
algo: 'scrypt',
|
||||
N: params.N,
|
||||
r: params.r,
|
||||
p: params.p,
|
||||
salt: salt.toString('hex'),
|
||||
hash: derived.toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Constant-time verify of a password against a stored hash record. Never throws. */
|
||||
export async function verifyPasswordHash(password: string, record: PasswordHash): Promise<boolean> {
|
||||
if (!record || record.algo !== 'scrypt') return false;
|
||||
let salt: Buffer;
|
||||
let expected: Buffer;
|
||||
try {
|
||||
salt = Buffer.from(record.salt, 'hex');
|
||||
expected = Buffer.from(record.hash, 'hex');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (expected.length === 0) return false;
|
||||
let derived: Buffer;
|
||||
try {
|
||||
derived = await scrypt(password, salt, expected.length, {
|
||||
N: record.N,
|
||||
r: record.r,
|
||||
p: record.p,
|
||||
maxmem: SCRYPT_MAXMEM,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (derived.length !== expected.length) return false;
|
||||
return timingSafeEqual(derived, expected);
|
||||
}
|
||||
|
||||
/** True when a stored hash uses weaker params than current and should be rehashed. */
|
||||
export function needsRehash(record: PasswordHash, params = DEFAULT_SCRYPT_PARAMS): boolean {
|
||||
return record.algo !== 'scrypt' || record.N !== params.N || record.r !== params.r || record.p !== params.p;
|
||||
}
|
||||
|
||||
/** URL-safe one-time password (16 chars) for admin create/reset flows. */
|
||||
export function generateOneTimePassword(): string {
|
||||
return randomBytes(12).toString('base64url');
|
||||
}
|
||||
|
||||
export function toPublicUser(u: UserRecord): PublicUser {
|
||||
return {
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
disabled: !!u.disabled,
|
||||
mustChangePassword: !!u.mustChangePassword,
|
||||
canBypassPermissions: !!u.canBypassPermissions,
|
||||
createdAt: u.createdAt,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function countEnabledAdmins(users: UserRecord[]): number {
|
||||
return users.filter((u) => u.role === 'admin' && !u.disabled).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section 6.3: resolve the effective Claude permission mode for a user. Admins and
|
||||
* granted users get the global mode as-is; a non-granted regular user whose mode
|
||||
* would be `dangerously-skip-permissions` is silently downgraded to `auto` (all
|
||||
* other modes are already <= auto and pass through). Pure.
|
||||
*/
|
||||
export function resolveClaudeModeForUser(
|
||||
globalMode: ClaudeMode | undefined,
|
||||
grant: { role: UserRole; canBypassPermissions?: boolean }
|
||||
): ClaudeMode {
|
||||
const mode: ClaudeMode = globalMode ?? 'dangerously-skip-permissions';
|
||||
if (grant.role === 'admin' || grant.canBypassPermissions) return mode;
|
||||
return mode === 'dangerously-skip-permissions' ? 'auto' : mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section 6.3: whether a user may run arbitrary commands as the host account
|
||||
* (shell-mode sessions, cron `launchCommand`, other CLIs' bypass flags). Same
|
||||
* one-bit grant as bypass. Admins always may.
|
||||
*/
|
||||
export function canRunPrivilegedCommands(grant: { role: UserRole; canBypassPermissions?: boolean }): boolean {
|
||||
return grant.role === 'admin' || !!grant.canBypassPermissions;
|
||||
}
|
||||
|
||||
// ─────────────────────────────── IO layer ───────────────────────────────
|
||||
|
||||
let cache: { users: UserRecord[]; ts: number } | null = null;
|
||||
|
||||
/** Drop the in-process cache (called after every write; exported for tests). */
|
||||
export function invalidateUsersCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
export async function readUsers(force = false): Promise<UserRecord[]> {
|
||||
const now = Date.now();
|
||||
if (!force && cache && now - cache.ts < CACHE_TTL_MS) return cache.users;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(dataPath(USERS_FILE), 'utf-8');
|
||||
} catch (err) {
|
||||
// ENOENT is the ONLY legitimately-empty store (first boot). Any other read
|
||||
// error (EIO/EACCES/EMFILE/EBUSY) is a transient/permission failure, NOT an
|
||||
// empty store — do NOT cache [] and do NOT let it look empty, or a following
|
||||
// createUser/bootstrap would overwrite users.json and destroy every account.
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
cache = { users: [], ts: now };
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// A present-but-corrupt file (invalid JSON) must also fail loud rather than
|
||||
// read as empty, so mutators/bootstrap abort instead of clobbering it.
|
||||
const parsed = JSON.parse(raw) as Partial<UsersFile>;
|
||||
const users = Array.isArray(parsed.users) ? parsed.users : [];
|
||||
cache = { users, ts: now };
|
||||
return users;
|
||||
}
|
||||
|
||||
async function writeUsers(users: UserRecord[]): Promise<void> {
|
||||
const dir = getDataDir();
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
const finalPath = dataPath(USERS_FILE);
|
||||
// Unique per-writer tmp name (pid + random) so the CLI (`codeman users …`) and
|
||||
// the live server — designed to write this file concurrently across processes —
|
||||
// never share a single `users.json.tmp` inode and tear each other's payload.
|
||||
// Matches the state-store.ts / self-update.ts convention.
|
||||
const tmpPath = `${finalPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
||||
const payload: UsersFile = { version: 1, users };
|
||||
try {
|
||||
await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
||||
await fs.chmod(tmpPath, 0o600).catch(() => {});
|
||||
await fs.rename(tmpPath, finalPath);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
cache = { users, ts: Date.now() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize every read-modify-write on users.json. Without this a fire-and-forget
|
||||
* touchLastLogin (fired on each Basic auth) can interleave with a route's
|
||||
* create/update and clobber records, since both do readUsers(true) → mutate →
|
||||
* writeUsers against a single shared file + tmp path.
|
||||
*/
|
||||
let mutateChain: Promise<unknown> = Promise.resolve();
|
||||
function withUsersLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = mutateChain.then(fn, fn);
|
||||
mutateChain = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function hasUsers(): Promise<boolean> {
|
||||
return (await readUsers()).length > 0;
|
||||
}
|
||||
|
||||
// A precomputed dummy hash so an unknown/disabled user costs the same scrypt work
|
||||
// as a real verify (defeats username-enumeration by timing). Created once, lazily.
|
||||
let dummyHashPromise: Promise<PasswordHash> | null = null;
|
||||
function getDummyHash(): Promise<PasswordHash> {
|
||||
if (!dummyHashPromise) dummyHashPromise = hashPassword('codeman-timing-equalization-placeholder');
|
||||
return dummyHashPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a username/password against the store. Returns the record (plus whether it
|
||||
* should be rehashed) on success, or null for wrong password / unknown / disabled
|
||||
* user. Runs a dummy scrypt on the miss path so timing does not reveal which users
|
||||
* exist. Never writes (the caller decides when to persist lastLogin / rehash).
|
||||
*/
|
||||
export async function verifyPassword(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<{ user: UserRecord; needsRehash: boolean } | null> {
|
||||
const user = await findUser(username);
|
||||
if (!user || user.disabled) {
|
||||
await verifyPasswordHash(password, await getDummyHash());
|
||||
return null;
|
||||
}
|
||||
const ok = await verifyPasswordHash(password, user.password);
|
||||
if (!ok) return null;
|
||||
return { user, needsRehash: needsRehash(user.password) };
|
||||
}
|
||||
|
||||
export async function findUser(username: string): Promise<UserRecord | undefined> {
|
||||
const norm = normalizeUsername(username);
|
||||
if (!norm) return undefined;
|
||||
const users = await readUsers();
|
||||
return users.find((u) => u.username === norm);
|
||||
}
|
||||
|
||||
export interface CreateUserOptions {
|
||||
username: string;
|
||||
role: UserRole;
|
||||
password: string;
|
||||
mustChangePassword?: boolean;
|
||||
canBypassPermissions?: boolean;
|
||||
}
|
||||
|
||||
export async function createUser(opts: CreateUserOptions): Promise<UserRecord> {
|
||||
const username = normalizeUsername(opts.username);
|
||||
if (!isValidUsername(username)) {
|
||||
throw new UserStoreError(
|
||||
'Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])',
|
||||
'INVALID_INPUT'
|
||||
);
|
||||
}
|
||||
if (opts.role !== 'admin' && opts.role !== 'user') {
|
||||
throw new UserStoreError('Role must be "admin" or "user"', 'INVALID_INPUT');
|
||||
}
|
||||
if (!opts.password || opts.password.length < 8) {
|
||||
throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT');
|
||||
}
|
||||
return withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
if (users.some((u) => u.username === username)) {
|
||||
throw new UserStoreError(`User "${username}" already exists`, 'USER_EXISTS');
|
||||
}
|
||||
if (users.length >= maxUsers()) {
|
||||
throw new UserStoreError(`Maximum number of users (${maxUsers()}) reached`, 'INVALID_INPUT');
|
||||
}
|
||||
const record: UserRecord = {
|
||||
username,
|
||||
role: opts.role,
|
||||
password: await hashPassword(opts.password),
|
||||
disabled: false,
|
||||
mustChangePassword: !!opts.mustChangePassword,
|
||||
canBypassPermissions: !!opts.canBypassPermissions,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
users.push(record);
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
/** Set a user's password. `mustChangePassword` is left unchanged unless specified. */
|
||||
export async function setPassword(
|
||||
username: string,
|
||||
password: string,
|
||||
opts: { mustChangePassword?: boolean } = {}
|
||||
): Promise<UserRecord> {
|
||||
if (!password || password.length < 8) {
|
||||
throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT');
|
||||
}
|
||||
const norm = normalizeUsername(username);
|
||||
return withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
record.password = await hashPassword(password);
|
||||
if (opts.mustChangePassword !== undefined) record.mustChangePassword = opts.mustChangePassword;
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
export interface UpdateUserPatch {
|
||||
role?: UserRole;
|
||||
disabled?: boolean;
|
||||
canBypassPermissions?: boolean;
|
||||
mustChangePassword?: boolean;
|
||||
}
|
||||
|
||||
export async function updateUser(username: string, patch: UpdateUserPatch): Promise<UserRecord> {
|
||||
const norm = normalizeUsername(username);
|
||||
return withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
|
||||
// Guard the last-enabled-admin invariant against demote/disable.
|
||||
const before = countEnabledAdmins(users);
|
||||
const projected: UserRecord = {
|
||||
...record,
|
||||
role: patch.role ?? record.role,
|
||||
disabled: patch.disabled ?? record.disabled,
|
||||
};
|
||||
const after = countEnabledAdmins(users.map((u) => (u.username === norm ? projected : u)));
|
||||
if (before > 0 && after === 0) {
|
||||
throw new UserStoreError('Cannot demote or disable the last enabled admin', 'LAST_ADMIN');
|
||||
}
|
||||
|
||||
if (patch.role !== undefined) record.role = patch.role;
|
||||
if (patch.disabled !== undefined) record.disabled = patch.disabled;
|
||||
if (patch.canBypassPermissions !== undefined) record.canBypassPermissions = patch.canBypassPermissions;
|
||||
if (patch.mustChangePassword !== undefined) record.mustChangePassword = patch.mustChangePassword;
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful login timestamp. Best-effort + throttled: skips the write if
|
||||
* the last login was within the last minute (Basic clients re-send credentials on
|
||||
* every request, so this fires often — the throttle keeps disk churn bounded).
|
||||
*/
|
||||
export async function touchLastLogin(username: string): Promise<void> {
|
||||
const norm = normalizeUsername(username);
|
||||
try {
|
||||
await withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) return;
|
||||
if (record.lastLoginAt && Date.now() - record.lastLoginAt < 60_000) return;
|
||||
record.lastLoginAt = Date.now();
|
||||
await writeUsers(users);
|
||||
});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(username: string): Promise<void> {
|
||||
const norm = normalizeUsername(username);
|
||||
await withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
const before = countEnabledAdmins(users);
|
||||
const remaining = users.filter((u) => u.username !== norm);
|
||||
const after = countEnabledAdmins(remaining);
|
||||
if (before > 0 && after === 0) {
|
||||
throw new UserStoreError('Cannot delete the last enabled admin', 'LAST_ADMIN');
|
||||
}
|
||||
await writeUsers(remaining);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* First-boot bootstrap: in multi-user mode with no users yet, create the initial
|
||||
* admin from `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` if both are set. Returns a
|
||||
* status the caller (server start / CLI) uses to decide whether to refuse boot.
|
||||
*/
|
||||
export async function bootstrapInitialAdmin(): Promise<{
|
||||
status: 'created' | 'exists' | 'missing-env';
|
||||
username?: string;
|
||||
}> {
|
||||
if (await hasUsers()) return { status: 'exists' };
|
||||
const username = process.env.CODEMAN_USERNAME;
|
||||
const password = process.env.CODEMAN_PASSWORD;
|
||||
if (!username || !password) return { status: 'missing-env' };
|
||||
const created = await createUser({ username, role: 'admin', password });
|
||||
return { status: 'created', username: created.username };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user's on-disk space (`<USER_SPACES_DIR>/<username>`) with the section 8
|
||||
* guard rails: the top-level dir must not be a symlink, and its realpath must
|
||||
* resolve strictly inside USER_SPACES_DIR (so a symlinked or `..`-escaping target
|
||||
* can never be used to rm an arbitrary tree). No-op if the space does not exist.
|
||||
*/
|
||||
export async function deleteUserSpace(username: string): Promise<void> {
|
||||
const norm = normalizeUsername(username);
|
||||
if (!isValidUsername(norm)) throw new UserStoreError('Invalid username', 'INVALID_INPUT');
|
||||
const root = getUserSpacesDir();
|
||||
const target = join(root, norm);
|
||||
let lst;
|
||||
try {
|
||||
lst = await fs.lstat(target);
|
||||
} catch {
|
||||
return; // nothing to delete
|
||||
}
|
||||
if (lst.isSymbolicLink()) {
|
||||
throw new UserStoreError('Refusing to delete a symlinked user space', 'INVALID_INPUT');
|
||||
}
|
||||
const realRoot = await fs.realpath(root).catch(() => root);
|
||||
const realTarget = await fs.realpath(target);
|
||||
const rel = relative(realRoot, realTarget);
|
||||
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
||||
throw new UserStoreError('User space escapes USER_SPACES_DIR', 'INVALID_INPUT');
|
||||
}
|
||||
await fs.rm(realTarget, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** The synthetic admin used in single-user mode so downstream has one code path. */
|
||||
export const SYNTHETIC_ADMIN: AuthUser = { username: 'admin', role: 'admin' };
|
||||
|
||||
/**
|
||||
* Whether a username may run arbitrary commands (shell mode, cron launchCommand,
|
||||
* other CLIs' bypass). Single-user or an unset owner: allowed. In multi-user a
|
||||
* MISSING user (e.g. deleted) fails closed (non-privileged). Used at cron fire time.
|
||||
*/
|
||||
export async function canUsernameRunPrivilegedCommands(username: string | undefined): Promise<boolean> {
|
||||
if (!isMultiUserMode() || !username) return true;
|
||||
const user = await findUser(username);
|
||||
return canRunPrivilegedCommands(user ?? { role: 'user' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective Claude mode for a username by looking up the grant. In
|
||||
* single-user mode (or for an unknown owner) the global mode passes through.
|
||||
*/
|
||||
export async function resolveClaudeModeForUsername(
|
||||
globalMode: ClaudeMode | undefined,
|
||||
username: string | undefined
|
||||
): Promise<ClaudeMode> {
|
||||
const fallback: ClaudeMode = globalMode ?? 'dangerously-skip-permissions';
|
||||
if (!isMultiUserMode() || !username) return fallback;
|
||||
// Fail closed: an unknown/deleted owner in multi-user mode is treated as a
|
||||
// non-granted regular user so a stale-owned spawn (e.g. an orphaned cron job)
|
||||
// is downgraded to `auto` rather than inheriting the global bypass.
|
||||
const user = await findUser(username);
|
||||
return resolveClaudeModeForUser(globalMode, user ?? { role: 'user' });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @fileoverview Append-only admin audit log (~/.codeman/admin-audit.jsonl).
|
||||
*
|
||||
* Every user-management action (create/patch/reset/delete/logout/assign) writes one
|
||||
* JSON line: timestamp, acting admin, action, target, request IP. Same idiom as
|
||||
* session-lifecycle.jsonl. Best-effort: a write failure never blocks the action.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
|
||||
export interface AdminAuditEntry {
|
||||
ts: number;
|
||||
admin: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
ip?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function appendAdminAudit(entry: Omit<AdminAuditEntry, 'ts'>): Promise<void> {
|
||||
try {
|
||||
const line = JSON.stringify({ ts: Date.now(), ...entry }) + '\n';
|
||||
await fs.appendFile(dataPath('admin-audit.jsonl'), line, { mode: 0o600 });
|
||||
} catch {
|
||||
/* best-effort audit; never block the action */
|
||||
}
|
||||
}
|
||||
+277
-57
@@ -8,7 +8,7 @@
|
||||
* - CORS (localhost only)
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { StaleExpirationMap } from '../../utils/index.js';
|
||||
import type { AuthSessionRecord } from '../ports/auth-port.js';
|
||||
@@ -20,6 +20,17 @@ import {
|
||||
AUTH_FAILURE_WINDOW_MS,
|
||||
} from '../../config/auth-config.js';
|
||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
|
||||
|
||||
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
|
||||
// ownership helpers default to a synthetic admin (see route-helpers).
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
authUser?: AuthUser;
|
||||
}
|
||||
}
|
||||
|
||||
// Auth session cookie name
|
||||
export const AUTH_COOKIE_NAME = 'codeman_session';
|
||||
@@ -30,6 +41,83 @@ interface AuthState {
|
||||
authFailures: StaleExpirationMap<string, number> | null;
|
||||
qrAuthFailures: StaleExpirationMap<string, number> | null;
|
||||
hookSecretFailures: StaleExpirationMap<string, number> | null;
|
||||
/** Per-username Basic-auth failure bucket (multi-user only). */
|
||||
userFailures: StaleExpirationMap<string, number> | null;
|
||||
}
|
||||
|
||||
/** Rate-limit response for a client that exceeded the failure cap. */
|
||||
function sendAuthRateLimit(reply: FastifyReply, failures: StaleExpirationMap<string, number>, key: string): void {
|
||||
const remainingMs = failures.getRemainingTtl(key) ?? AUTH_FAILURE_WINDOW_MS;
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
||||
reply.header('Retry-After', String(retryAfterSeconds));
|
||||
reply.code(429).send('Too Many Requests — try again later');
|
||||
}
|
||||
|
||||
/** Parse a `Basic base64(user:pass)` header into its parts, or null if malformed. */
|
||||
function parseBasicAuth(header?: string): { username: string; password: string } | null {
|
||||
if (!header || !header.startsWith('Basic ')) return null;
|
||||
try {
|
||||
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf-8');
|
||||
const idx = decoded.indexOf(':');
|
||||
if (idx < 0) return null;
|
||||
return { username: decoded.slice(0, idx), password: decoded.slice(idx + 1) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass, shared by the
|
||||
* single-user and multi-user auth hooks so the security-critical logic has ONE
|
||||
* source of truth. Returns:
|
||||
* - 'bypass' : loopback + valid hook secret; the caller should allow the request
|
||||
* - 'rejected' : a reply was already sent (wrong secret rate-limited / 401)
|
||||
* - 'continue' : not a hook request (or non-loopback); fall through to normal auth
|
||||
*
|
||||
* COD-91: the shared hook secret is required UNCONDITIONALLY on the loopback bypass
|
||||
* (a user's own loopback reverse proxy is indistinguishable from a real local hook).
|
||||
*/
|
||||
function checkHookSecretBypass(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
hookSecretFailures: StaleExpirationMap<string, number>
|
||||
): 'bypass' | 'rejected' | 'continue' {
|
||||
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
|
||||
const ip = req.ip;
|
||||
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
||||
if (isLoopback) {
|
||||
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
|
||||
const expected = Buffer.from(getHookSecret());
|
||||
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
|
||||
return 'bypass';
|
||||
}
|
||||
const hookIp = req.ip;
|
||||
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
|
||||
if (hookFailures >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, hookSecretFailures, hookIp);
|
||||
return 'rejected';
|
||||
}
|
||||
hookSecretFailures.set(hookIp, hookFailures + 1);
|
||||
reply.code(401).send('Unauthorized: hook secret required');
|
||||
return 'rejected';
|
||||
}
|
||||
// Non-localhost hook requests fall through to normal auth
|
||||
}
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests that a `mustChangePassword` user may still reach: the identity probe,
|
||||
* the password-change endpoint, and any non-API path (static assets / index.html,
|
||||
* so the browser can load the app and render the change-password modal).
|
||||
*/
|
||||
function isPasswordChangeExempt(req: FastifyRequest): boolean {
|
||||
const url = (req.url ?? '').split('?')[0];
|
||||
if (url === '/api/me' || url === '/api/me/password') return true;
|
||||
// Security: the WebSocket terminal (/ws/...) is a functional channel, not a static
|
||||
// asset, so it must NOT be exempt, or a locked user keeps a working terminal.
|
||||
if (url.startsWith('/ws/')) return false;
|
||||
return !url.startsWith('/api/');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,13 +135,20 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
authFailures: null,
|
||||
qrAuthFailures: null,
|
||||
hookSecretFailures: null,
|
||||
userFailures: null,
|
||||
};
|
||||
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
if (!authPassword) return state;
|
||||
// Always declare req.authUser so downstream reads are safe (single-user leaves it
|
||||
// undefined; the ownership helpers then default to a synthetic admin).
|
||||
if (!app.hasRequestDecorator('authUser')) app.decorateRequest('authUser', undefined);
|
||||
|
||||
const authUsername = process.env.CODEMAN_USERNAME || 'admin';
|
||||
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
|
||||
const multiUser = isMultiUserMode();
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
|
||||
// No auth at all: single-user with no password (byte-identical to legacy). In
|
||||
// multi-user mode auth is ALWAYS active (users authenticate individually), even
|
||||
// without CODEMAN_PASSWORD.
|
||||
if (!multiUser && !authPassword) return state;
|
||||
|
||||
// Session token store — active sessions extend TTL on access
|
||||
state.authSessions = new StaleExpirationMap<string, AuthSessionRecord>({
|
||||
@@ -87,57 +182,28 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
const authFailures = state.authFailures;
|
||||
const hookSecretFailures = state.hookSecretFailures;
|
||||
|
||||
function sendAuthRateLimit(
|
||||
reply: FastifyReply,
|
||||
clientIp: string,
|
||||
failures: StaleExpirationMap<string, number> = authFailures
|
||||
): void {
|
||||
const remainingMs = failures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
||||
reply.header('Retry-After', String(retryAfterSeconds));
|
||||
reply.code(429).send('Too Many Requests — try again later');
|
||||
if (multiUser) {
|
||||
// Per-username failure bucket: a botnet can't brute-force one account across
|
||||
// many IPs, and one user behind a NAT can't lock out everyone else.
|
||||
state.userFailures = new StaleExpirationMap<string, number>({
|
||||
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||||
refreshOnGet: false,
|
||||
});
|
||||
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures);
|
||||
return state;
|
||||
}
|
||||
|
||||
// ── Single-user Basic Auth (unchanged behavior; CODEMAN_PASSWORD required) ──
|
||||
const authUsername = process.env.CODEMAN_USERNAME || 'admin';
|
||||
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
|
||||
|
||||
app.addHook('onRequest', (req, reply, done) => {
|
||||
// Hook events + statusline telemetry come from local Claude Code (curl from
|
||||
// localhost) — no Basic-Auth credentials available. Validated downstream by
|
||||
// HookEventSchema / StatusTelemetrySchema. Same loopback+hook-secret gate.
|
||||
//
|
||||
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
|
||||
// `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
|
||||
// loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and
|
||||
// would pass. COD-91: require the shared hook secret on the loopback bypass
|
||||
// UNCONDITIONALLY (not just while the managed tunnel is up). Codeman can't detect
|
||||
// a user's own loopback reverse proxy (their own `cloudflared --url`, `tailscale
|
||||
// serve`, nginx → 127.0.0.1), so tunnel-gating left that path with the unsafe plain
|
||||
// bypass. Managed-session hooks always present the secret (X-Codeman-Hook-Secret,
|
||||
// from $CODEMAN_HOOK_SECRET_FILE — generated for every instance), so requiring it
|
||||
// always closes the gap without breaking the legitimate hook channel.
|
||||
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
|
||||
const ip = req.ip;
|
||||
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
||||
if (isLoopback) {
|
||||
// Always require the shared secret (constant-time compare).
|
||||
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
|
||||
const expected = Buffer.from(getHookSecret());
|
||||
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
// Wrong/absent secret — rate-limit per IP in the DEDICATED hook bucket
|
||||
// (never authFailures, which would lock out the login path).
|
||||
const hookIp = req.ip;
|
||||
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
|
||||
if (hookFailures >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, hookIp, hookSecretFailures);
|
||||
return;
|
||||
}
|
||||
hookSecretFailures.set(hookIp, hookFailures + 1);
|
||||
reply.code(401).send('Unauthorized: hook secret required');
|
||||
return;
|
||||
}
|
||||
// Non-localhost hook requests fall through to normal auth
|
||||
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
|
||||
if (bypass === 'bypass') {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
if (bypass === 'rejected') return;
|
||||
|
||||
// QR auth path — handled by the route itself (token validation + rate limiting)
|
||||
if (req.url?.startsWith('/q/')) {
|
||||
@@ -153,10 +219,6 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
if (sessionToken && authSessions.get(sessionToken) !== undefined) {
|
||||
// Sliding cookie: re-issue on every authenticated request so the browser
|
||||
// cookie lifetime tracks the server-side sliding TTL (refreshOnGet above).
|
||||
// Without this the cookie has a fixed lifetime from login; the browser
|
||||
// drops it mid-use, the next request arrives cookie-less and falls through
|
||||
// to Basic Auth — popping the native username/password dialog, which reads
|
||||
// as a random logout while actively working.
|
||||
reply.setCookie(AUTH_COOKIE_NAME, sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: https,
|
||||
@@ -206,7 +268,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
// Rate limit only requests that failed to authenticate on this attempt.
|
||||
const failures = authFailures.get(clientIp) ?? 0;
|
||||
if (failures >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, clientIp);
|
||||
sendAuthRateLimit(reply, authFailures, clientIp);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,6 +282,164 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-user auth hook (async, because password verification runs scrypt). Verifies
|
||||
* `username:password` against the user store, mints an identity-carrying cookie,
|
||||
* decorates `req.authUser`, enforces the per-IP + per-username rate limits, and the
|
||||
* `mustChangePassword` lockbox. The single-user hook above is left untouched.
|
||||
*/
|
||||
function registerMultiUserAuthHook(
|
||||
app: FastifyInstance,
|
||||
https: boolean,
|
||||
authSessions: StaleExpirationMap<string, AuthSessionRecord>,
|
||||
authFailures: StaleExpirationMap<string, number>,
|
||||
hookSecretFailures: StaleExpirationMap<string, number>,
|
||||
userFailures: StaleExpirationMap<string, number>
|
||||
): void {
|
||||
const setSessionCookie = (reply: FastifyReply, token: string) =>
|
||||
reply.setCookie(AUTH_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: https,
|
||||
sameSite: 'lax',
|
||||
maxAge: AUTH_SESSION_TTL_MS / 1000,
|
||||
path: '/',
|
||||
});
|
||||
|
||||
// Evict the oldest cookie session of the SAME user first (so one user logging in
|
||||
// 100 times cannot flush everyone else's sessions), falling back to global-oldest.
|
||||
const evictForCapacity = (username: string) => {
|
||||
let userKey: string | undefined;
|
||||
let userTs = Infinity;
|
||||
let globalKey: string | undefined;
|
||||
let globalTs = Infinity;
|
||||
for (const [k, v] of authSessions) {
|
||||
if (v.createdAt < globalTs) {
|
||||
globalTs = v.createdAt;
|
||||
globalKey = k;
|
||||
}
|
||||
if (v.username === username && v.createdAt < userTs) {
|
||||
userTs = v.createdAt;
|
||||
userKey = k;
|
||||
}
|
||||
}
|
||||
const key = userKey ?? globalKey;
|
||||
if (key !== undefined) authSessions.delete(key);
|
||||
};
|
||||
|
||||
const enforcePasswordChange = (req: FastifyRequest, reply: FastifyReply, mustChange: boolean): boolean => {
|
||||
if (mustChange && !isPasswordChangeExempt(req)) {
|
||||
reply.code(403).send(createErrorResponse(ApiErrorCode.PASSWORD_CHANGE_REQUIRED));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
|
||||
if (bypass === 'bypass' || bypass === 'rejected') return;
|
||||
|
||||
// QR redemption path — handled by the route itself.
|
||||
if (req.url?.startsWith('/q/')) return;
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// 1. Cookie session (carries identity + mustChangePassword snapshot).
|
||||
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
||||
const record = sessionToken ? authSessions.get(sessionToken) : undefined;
|
||||
if (record && record.username) {
|
||||
// Security: re-validate the cookie identity against the store on every request so
|
||||
// an out-of-band mutation the in-memory map can't see (the `codeman users` CLI,
|
||||
// a separate process, deleting/disabling/demoting a user) takes effect promptly
|
||||
// instead of riding the 24h cookie. findUser is cached ~1s, so this is cheap.
|
||||
let live: Awaited<ReturnType<typeof findUser>>;
|
||||
try {
|
||||
live = await findUser(record.username);
|
||||
} catch {
|
||||
// The store is transiently unreadable/corrupt (readUsers throws on a non-ENOENT
|
||||
// read, #23). Fall back to the cookie's snapshot for THIS request rather than
|
||||
// 500-ing an already-authenticated client (pre-#24 behaviour); a persistently
|
||||
// corrupt store still fails all WRITES loudly at the mutator/bootstrap layer.
|
||||
req.authUser = { username: record.username, role: record.role ?? 'user' };
|
||||
setSessionCookie(reply, sessionToken!);
|
||||
enforcePasswordChange(req, reply, !!record.mustChangePassword);
|
||||
return;
|
||||
}
|
||||
if (!live || live.disabled) {
|
||||
authSessions.delete(sessionToken!);
|
||||
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
||||
reply.code(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
// Trust the LIVE role/mustChangePassword, not the (possibly stale) cookie snapshot
|
||||
// (also defends #9/#13: a CLI demotion is reflected without a revoke).
|
||||
req.authUser = { username: live.username, role: live.role };
|
||||
setSessionCookie(reply, sessionToken!); // sliding re-issue
|
||||
enforcePasswordChange(req, reply, !!live.mustChangePassword);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Basic Auth against the user store (scrypt verify).
|
||||
// Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the
|
||||
// per-username bucket here; see below).
|
||||
const ipFail = authFailures.get(clientIp) ?? 0;
|
||||
if (ipFail >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, authFailures, clientIp);
|
||||
return;
|
||||
}
|
||||
const creds = parseBasicAuth(req.headers.authorization);
|
||||
if (creds) {
|
||||
const normUser = creds.username.trim().toLowerCase();
|
||||
// Security: VERIFY FIRST, then throttle only FAILED attempts. Consulting the
|
||||
// per-username bucket before verifying let throwaway IPs lock out a known account
|
||||
// (incl. admin) even with the correct password. A correct password must always
|
||||
// win and self-heal both buckets, regardless of the username-failure count.
|
||||
const result = await verifyPassword(creds.username, creds.password);
|
||||
if (result) {
|
||||
const { user, needsRehash: rehash } = result;
|
||||
if (rehash) void setPassword(user.username, creds.password).catch(() => {});
|
||||
void touchLastLogin(user.username).catch(() => {});
|
||||
authFailures.delete(clientIp);
|
||||
userFailures.delete(normUser);
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
|
||||
authSessions.set(token, {
|
||||
ip: clientIp,
|
||||
ua: req.headers['user-agent'] ?? '',
|
||||
createdAt: Date.now(),
|
||||
method: 'basic',
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
mustChangePassword: !!user.mustChangePassword,
|
||||
});
|
||||
req.authUser = { username: user.username, role: user.role };
|
||||
setSessionCookie(reply, token);
|
||||
enforcePasswordChange(req, reply, !!user.mustChangePassword);
|
||||
return;
|
||||
}
|
||||
// Failed guess: count it against BOTH buckets. Once the per-username bucket
|
||||
// reaches the cap, further FAILED attempts get 429 (throttles distributed
|
||||
// brute-force), but this path is only reached on a wrong password, so it can
|
||||
// never deny a correct one.
|
||||
const uFail = (userFailures.get(normUser) ?? 0) + 1;
|
||||
userFailures.set(normUser, uFail);
|
||||
authFailures.set(clientIp, ipFail + 1);
|
||||
if (uFail >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, userFailures, normUser);
|
||||
return;
|
||||
}
|
||||
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
|
||||
reply.code(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
// No credentials presented: count against the per-IP bucket and challenge.
|
||||
authFailures.set(clientIp, ipFail + 1);
|
||||
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
|
||||
reply.code(401).send('Unauthorized');
|
||||
});
|
||||
}
|
||||
|
||||
/** Methods that don't change server state and so skip the cross-site Origin check. */
|
||||
const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@ export function isLoopbackBindHost(host: string): boolean {
|
||||
*/
|
||||
export const DEFAULT_TRUSTED_HOST_SUFFIXES = ['.ts.net', '.trycloudflare.com', '.cfargotunnel.com'];
|
||||
|
||||
/**
|
||||
* Container-to-host gateway aliases (Docker / Podman). A hook `curl` from INSIDE a
|
||||
* docker case carries `Host: host.docker.internal:<port>` (the derived
|
||||
* CODEMAN_API_URL), so the always-on host guard must allow it or every in-container
|
||||
* hook is blocked 403. These names only resolve to the host from within a
|
||||
* container's network namespace, so they are not a DNS-rebinding surface for a
|
||||
* normal browser. Both engines' aliases are allowed so a mixed fleet keeps working.
|
||||
*/
|
||||
export const DOCKER_HOST_GATEWAY_ALIASES = ['host.docker.internal', 'host.containers.internal'];
|
||||
|
||||
/** Policy inputs for the anti-DNS-rebinding Host allowlist + cross-site Origin guard. */
|
||||
export interface HostPolicy {
|
||||
/** The host the server is bound to (e.g. '127.0.0.1', '0.0.0.0', or a hostname). */
|
||||
@@ -98,6 +108,8 @@ function matchesHost(hostname: string, policy: HostPolicy): boolean {
|
||||
const bind = parseAuthorityHostname(policy.bindHost);
|
||||
if (bind && hostname === bind) return true;
|
||||
if (policy.tunnelHost && hostname === policy.tunnelHost) return true;
|
||||
// Docker/Podman container-to-host gateway aliases (for in-container hook curls).
|
||||
if (DOCKER_HOST_GATEWAY_ALIASES.includes(hostname)) return true;
|
||||
for (const suffix of DEFAULT_TRUSTED_HOST_SUFFIXES) {
|
||||
if (hostname === suffix.slice(1) || hostname.endsWith(suffix)) return true;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,19 @@ export interface AuthSessionRecord {
|
||||
ua: string;
|
||||
createdAt: number;
|
||||
method: 'qr' | 'basic';
|
||||
/**
|
||||
* Multi-user identity carried by the cookie (single-user leaves these unset).
|
||||
* Snapshotted at mint time. Authorization-relevant admin changes (password reset,
|
||||
* disable, delete, role change, bypass-grant change) revoke the user's sessions so
|
||||
* a stale snapshot can't outlive the change; additionally the cookie fast-path
|
||||
* re-reads role/disabled/mustChangePassword live from the store each request, so an
|
||||
* out-of-band CLI mutation also takes effect promptly. See docs/multi-user-plan.md
|
||||
* section 5.
|
||||
*/
|
||||
username?: string;
|
||||
role?: 'admin' | 'user';
|
||||
/** Whether this user must change their password before other actions are allowed. */
|
||||
mustChangePassword?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthPort {
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface ConfigPort {
|
||||
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
|
||||
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
||||
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
||||
getLightState(): unknown;
|
||||
getLightState(identity?: { username: string; role: 'admin' | 'user' }): unknown;
|
||||
getLightSessionsState(): unknown[];
|
||||
startTranscriptWatcher(sessionId: string, transcriptPath: string): void;
|
||||
stopTranscriptWatcher(sessionId: string): void;
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface ScheduledRun {
|
||||
completedTasks: number;
|
||||
totalCost: number;
|
||||
logs: string[];
|
||||
/** Multi-user owner (username) — undefined in single-user mode. Used to scope
|
||||
* list/delete and to downgrade the spawned Session's permission mode. */
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
export interface InfraPort {
|
||||
@@ -33,6 +36,6 @@ export interface InfraPort {
|
||||
readonly teamWatcher: TeamWatcher;
|
||||
readonly tunnelManager: TunnelManager;
|
||||
readonly pushStore: PushSubscriptionStore;
|
||||
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun>;
|
||||
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number, owner?: string): Promise<ScheduledRun>;
|
||||
stopScheduledRun(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @fileoverview Multi-user frontend: identity boot, admin Users panel, and the
|
||||
* change-password flow. Self-contained (builds its own DOM) so it needs no
|
||||
* index.html surgery beyond the script tag and integrates with the existing App
|
||||
* Settings modal by injecting a "Users" tab (admins in multi-user mode only).
|
||||
*
|
||||
* @dependency app.js (window.app), settings-ui.js (App Settings modal + tab switch)
|
||||
* @loadorder after settings-ui.js / ultracode-panel.js, before session-ui.js
|
||||
*
|
||||
* In single-user mode GET /api/me returns a synthetic admin with multiUser:false,
|
||||
* so none of the admin UI is shown and behavior is unchanged.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const unwrap = (body) => (body && typeof body === 'object' && 'data' in body ? body.data : body);
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await window.fetch(path, { headers: { Accept: 'application/json' } });
|
||||
return unwrap(await res.json());
|
||||
}
|
||||
async function apiSend(method, path, body) {
|
||||
const res = await window.fetch(path, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let json = null;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
/* empty body */
|
||||
}
|
||||
return { ok: res.ok, status: res.status, body: json, data: unwrap(json) };
|
||||
}
|
||||
|
||||
// ── Change-password modal ─────────────────────────────────────────────────
|
||||
let cpModal = null;
|
||||
function buildChangePasswordModal() {
|
||||
if (cpModal) return cpModal;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'modal';
|
||||
el.id = 'changePasswordModal';
|
||||
el.style.zIndex = '3100';
|
||||
el.innerHTML = `
|
||||
<div class="modal-content" style="max-width:420px">
|
||||
<div class="modal-header"><h2>Change Password</h2></div>
|
||||
<div class="modal-body">
|
||||
<p id="cpMustNote" class="form-hint" style="display:none;color:var(--warning,#c80)">
|
||||
You must change your password before continuing.</p>
|
||||
<div class="form-row"><label>Current password</label>
|
||||
<input type="password" id="cpCurrent" class="form-input" autocomplete="current-password"></div>
|
||||
<div class="form-row"><label>New password (min 8)</label>
|
||||
<input type="password" id="cpNew" class="form-input" autocomplete="new-password"></div>
|
||||
<div class="form-row"><label>Confirm new password</label>
|
||||
<input type="password" id="cpConfirm" class="form-input" autocomplete="new-password"></div>
|
||||
<p id="cpError" style="color:var(--error,#c33);min-height:1.2em"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" id="cpCancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="cpSubmit">Change password</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(el);
|
||||
el.querySelector('#cpCancel').onclick = () => (el.style.display = 'none');
|
||||
el.querySelector('#cpSubmit').onclick = async () => {
|
||||
const current = el.querySelector('#cpCurrent').value;
|
||||
const nw = el.querySelector('#cpNew').value;
|
||||
const confirm = el.querySelector('#cpConfirm').value;
|
||||
const err = el.querySelector('#cpError');
|
||||
err.textContent = '';
|
||||
if (nw.length < 8) return (err.textContent = 'New password must be at least 8 characters.');
|
||||
if (nw !== confirm) return (err.textContent = 'Passwords do not match.');
|
||||
const r = await apiSend('POST', '/api/me/password', { currentPassword: current, newPassword: nw });
|
||||
if (!r.ok) return (err.textContent = (r.body && r.body.error) || 'Change failed.');
|
||||
el.style.display = 'none';
|
||||
if (window.app && window.app.showToast) window.app.showToast('Password changed');
|
||||
};
|
||||
cpModal = el;
|
||||
return el;
|
||||
}
|
||||
function openChangePassword(forced) {
|
||||
const el = buildChangePasswordModal();
|
||||
el.querySelector('#cpMustNote').style.display = forced ? '' : 'none';
|
||||
el.querySelector('#cpCancel').style.display = forced ? 'none' : '';
|
||||
el.querySelector('#cpError').textContent = '';
|
||||
el.style.display = 'flex';
|
||||
}
|
||||
|
||||
// ── Fetch interceptor: surface PASSWORD_CHANGE_REQUIRED ───────────────────
|
||||
function installInterceptor() {
|
||||
const orig = window.fetch;
|
||||
window.fetch = async function (...args) {
|
||||
const res = await orig.apply(this, args);
|
||||
if (res.status === 403) {
|
||||
try {
|
||||
const clone = res.clone();
|
||||
const j = await clone.json();
|
||||
if (j && j.errorCode === 'PASSWORD_CHANGE_REQUIRED') openChangePassword(true);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Admin Users panel (injected into the App Settings modal) ──────────────
|
||||
function injectUsersTab() {
|
||||
const modal = document.getElementById('appSettingsModal');
|
||||
if (!modal || modal.querySelector('[data-tab="settings-users"]')) return;
|
||||
const tabs = modal.querySelector('.modal-tabs');
|
||||
const body = modal.querySelector('.modal-body');
|
||||
if (!tabs || !body) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'modal-tab-btn';
|
||||
btn.dataset.tab = 'settings-users';
|
||||
btn.textContent = 'Users';
|
||||
tabs.appendChild(btn);
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-tab-content hidden';
|
||||
content.id = 'settings-users';
|
||||
content.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong>Users</strong>
|
||||
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
|
||||
</div>
|
||||
<p class="form-hint">Users share the host account; this separates workspaces, it does not sandbox
|
||||
users from each other. Pair with Docker cases for isolation.</p>
|
||||
<div id="adminUsersTable"></div>
|
||||
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--muted,#888)"></p>`;
|
||||
body.appendChild(content);
|
||||
// Render whenever the tab is shown (the shared switchSettingsTab toggles it).
|
||||
btn.addEventListener('click', renderUsers);
|
||||
content.querySelector('#adminAddUser').onclick = addUserFlow;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
||||
}
|
||||
|
||||
async function renderUsers() {
|
||||
const table = document.getElementById('adminUsersTable');
|
||||
if (!table) return;
|
||||
table.innerHTML = 'Loading…';
|
||||
let users;
|
||||
try {
|
||||
users = await apiGet('/api/admin/users');
|
||||
} catch {
|
||||
table.innerHTML = 'Failed to load users.';
|
||||
return;
|
||||
}
|
||||
const rows = users
|
||||
.map((u) => {
|
||||
const flags = [
|
||||
u.role === 'admin' ? 'admin' : 'user',
|
||||
u.disabled ? 'disabled' : 'enabled',
|
||||
u.canBypassPermissions ? 'can-bypass' : '',
|
||||
u.mustChangePassword ? 'must-change-pw' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const st = u.stats || {};
|
||||
return `<tr data-u="${esc(u.username)}">
|
||||
<td>${esc(u.username)}</td>
|
||||
<td style="font-size:.85em;color:var(--muted,#888)">${esc(flags)}</td>
|
||||
<td style="font-size:.85em">${st.liveSessions ?? 0} live · ${st.caseCount ?? 0} cases</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="btn btn-xs" data-act="role">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
|
||||
<button class="btn btn-xs" data-act="disabled">${u.disabled ? 'Enable' : 'Disable'}</button>
|
||||
<button class="btn btn-xs" data-act="bypass">${u.canBypassPermissions ? 'Revoke bypass' : 'Grant bypass'}</button>
|
||||
<button class="btn btn-xs" data-act="reset">Reset pw</button>
|
||||
<button class="btn btn-xs" data-act="delete">Delete</button>
|
||||
</td></tr>`;
|
||||
})
|
||||
.join('');
|
||||
table.innerHTML = `<table style="width:100%;border-collapse:collapse" class="admin-users">
|
||||
<thead><tr><th align="left">User</th><th align="left">Flags</th><th align="left">Usage</th><th></th></tr></thead>
|
||||
<tbody>${rows}</tbody></table>`;
|
||||
table.querySelectorAll('button[data-act]').forEach((b) => {
|
||||
b.onclick = () =>
|
||||
userAction(
|
||||
b.closest('tr').dataset.u,
|
||||
b.dataset.act,
|
||||
users.find((x) => x.username === b.closest('tr').dataset.u)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function setMsg(t) {
|
||||
const m = document.getElementById('adminUsersMsg');
|
||||
if (m) m.textContent = t || '';
|
||||
}
|
||||
|
||||
async function userAction(username, act, u) {
|
||||
if (act === 'role') {
|
||||
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, {
|
||||
role: u.role === 'admin' ? 'user' : 'admin',
|
||||
});
|
||||
setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'disabled') {
|
||||
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, { disabled: !u.disabled });
|
||||
setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'bypass') {
|
||||
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, {
|
||||
canBypassPermissions: !u.canBypassPermissions,
|
||||
});
|
||||
setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'reset') {
|
||||
if (!window.confirm(`Reset ${username}'s password? They must set a new one on next login.`)) return;
|
||||
const r = await apiSend('POST', `/api/admin/users/${encodeURIComponent(username)}/reset-password`);
|
||||
if (r.ok && r.data && r.data.oneTimePassword) {
|
||||
window.prompt(`One-time password for ${username} (copy it now — shown once):`, r.data.oneTimePassword);
|
||||
} else setMsg((r.body && r.body.error) || 'Reset failed.');
|
||||
} else if (act === 'delete') {
|
||||
const typed = window.prompt(`Type "${username}" to delete this user. Add " +space" to also delete their files.`);
|
||||
if (typed !== username && typed !== `${username} +space`) return setMsg('Delete cancelled.');
|
||||
const deleteSpace = typed.endsWith(' +space');
|
||||
const r = await apiSend('DELETE', `/api/admin/users/${encodeURIComponent(username)}`, { deleteSpace });
|
||||
setMsg(r.ok ? `Deleted ${username}.` : (r.body && r.body.error) || 'Delete failed.');
|
||||
}
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
async function addUserFlow() {
|
||||
const username = window.prompt('New username (lowercase, 2-32 chars, [a-z0-9_-]):');
|
||||
if (!username) return;
|
||||
const admin = window.confirm('Make this user an admin? (OK = admin, Cancel = regular user)');
|
||||
const r = await apiSend('POST', '/api/admin/users', { username: username.trim(), role: admin ? 'admin' : 'user' });
|
||||
if (r.ok && r.data && r.data.oneTimePassword) {
|
||||
window.prompt(`Created ${username}. One-time password (copy it now — shown once):`, r.data.oneTimePassword);
|
||||
} else setMsg((r.body && r.body.error) || 'Create failed.');
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
installInterceptor();
|
||||
let me = null;
|
||||
try {
|
||||
me = await apiGet('/api/me');
|
||||
} catch {
|
||||
/* server may be pre-auth */
|
||||
}
|
||||
window.__codemanUser = me || { username: 'admin', role: 'admin', multiUser: false };
|
||||
document.dispatchEvent(new CustomEvent('codeman:me', { detail: window.__codemanUser }));
|
||||
if (window.__codemanUser.mustChangePassword) openChangePassword(true);
|
||||
if (window.__codemanUser.multiUser && window.__codemanUser.role === 'admin') {
|
||||
injectUsersTab();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
|
||||
window.codemanAdmin = { openChangePassword, renderUsers };
|
||||
})();
|
||||
@@ -1433,6 +1433,49 @@ class CodemanApp {
|
||||
for (const event of [SSE_EVENTS.SESSION_CREATED, SSE_EVENTS.SESSION_DELETED]) {
|
||||
addListener(event, () => this._onSessionListMaybeChanged());
|
||||
}
|
||||
|
||||
// Docker export/import: toast + refresh the Manage-tab exports list on completion.
|
||||
addListener(SSE_EVENTS.DOCKER_EXPORT_COMPLETE, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
this.showToast(`Docker export ready: ${d.bundle} (${Math.round((d.sizeBytes || 0) / 1e6)} MB)`, 'success');
|
||||
this.refreshDockerExports?.();
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker export complete:', err);
|
||||
}
|
||||
});
|
||||
addListener(SSE_EVENTS.DOCKER_EXPORT_FAILED, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
this.showToast(`Docker export failed: ${d.error || 'unknown error'}`, 'error');
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker export failed:', err);
|
||||
}
|
||||
});
|
||||
// Base image auto-build on first Docker case (build-on-first-use). A single
|
||||
// multi-minute event; surface start/finish so the Run spinner is explained.
|
||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => {
|
||||
this.showToast('Building the Codeman agent image (first Docker case, a few minutes)...', 'info', {
|
||||
duration: 8000,
|
||||
});
|
||||
});
|
||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_COMPLETE, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
if (d.error) this.showToast(`Agent image build failed: ${d.error}`, 'error');
|
||||
else this.showToast('Agent image ready. Starting the container...', 'success');
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker image build complete:', err);
|
||||
}
|
||||
});
|
||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_FAILED, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
this.showToast(`Agent image build failed: ${d.error || 'unknown error'}`, 'error');
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker image build failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -474,6 +474,16 @@ const SSE_EVENTS = {
|
||||
CASE_LINKED: 'case:linked',
|
||||
CASE_DELETED: 'case:deleted',
|
||||
CASE_ORDER_CHANGED: 'case:order-changed',
|
||||
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
|
||||
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
|
||||
DOCKER_IMPORT_COMPLETE: 'docker:importComplete',
|
||||
DOCKER_IMAGE_BUILD_STARTED: 'docker:imageBuildStarted',
|
||||
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
|
||||
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
|
||||
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
|
||||
// Multi-user (admin-only / targeted)
|
||||
ADMIN_USERS_CHANGED: 'admin:usersChanged',
|
||||
AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
+142
-3
@@ -118,12 +118,13 @@
|
||||
</div>
|
||||
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
|
||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-away-digest" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
|
||||
<button class="btn-icon-header btn-session-manager" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
|
||||
<button class="btn-icon-header btn-away-digest btn-away-digest--hidden" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
|
||||
<button class="btn-icon-header btn-session-manager btn-session-manager--hidden" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
|
||||
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer btn-file-viewer--hidden" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
|
||||
@@ -557,7 +558,7 @@
|
||||
<div class="toolbar-right">
|
||||
<!-- Orchestrator button hidden until feature is ready -->
|
||||
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">⚙ Orchestrator</button> -->
|
||||
<button class="btn-toolbar btn-sm" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||
<button class="btn-toolbar btn-sm btn-cron" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1268,6 +1269,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the file viewer button in header (opens the file browser panel for the active session)">
|
||||
<span class="settings-item-label">File Viewer</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowFileViewerButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the attachments button in header (opens the attachment history drawer)">
|
||||
<span class="settings-item-label">Attachments Button</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1282,6 +1290,27 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the session manager button in the header (opens the session manager — sessions also stay reachable via the Ctrl+K palette)">
|
||||
<span class="settings-item-label">Session Manager Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowSessionButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the away digest button in the header (opens the 'what happened while you were away' summary)">
|
||||
<span class="settings-item-label">Away Digest Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowAwayDigestButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the Cron button in the footer toolbar (opens the cron jobs manager)">
|
||||
<span class="settings-item-label">Cron Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowCronButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show a terminal redraw button in the header — refit the terminal to the current screen size (useful when switching between devices)">
|
||||
<span class="settings-item-label">Redraw Terminal Button</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1420,6 +1449,7 @@
|
||||
<label>Startup Mode</label>
|
||||
<select id="appSettingsClaudeMode" class="form-select">
|
||||
<option value="dangerously-skip-permissions">Skip Permissions (default)</option>
|
||||
<option value="auto">Auto (classifier-guarded, low prompts)</option>
|
||||
<option value="normal">Normal (with prompts)</option>
|
||||
<option value="allowedTools">Allowed Tools Only</option>
|
||||
</select>
|
||||
@@ -1828,6 +1858,7 @@
|
||||
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
|
||||
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
|
||||
<button class="modal-tab-btn" data-tab="case-remote">Remote</button>
|
||||
<button class="modal-tab-btn" data-tab="case-docker">Docker</button>
|
||||
<button class="modal-tab-btn" data-tab="case-manage">Manage</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -1842,6 +1873,53 @@
|
||||
<label>Description (optional)</label>
|
||||
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row docker-quick-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
|
||||
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use.</span>
|
||||
</div>
|
||||
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
|
||||
<summary>Container settings (optional, sensible defaults)</summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
<label>Template</label>
|
||||
<select id="quickDockerTemplate" onchange="app.applyDockerTemplate()">
|
||||
<option value="small">Small — 2 GB RAM, 1 CPU</option>
|
||||
<option value="medium" selected>Medium — 4 GB RAM, 2 CPU (default)</option>
|
||||
<option value="large">Large — 8 GB RAM, 4 CPU</option>
|
||||
<option value="gpu">GPU — 8 GB RAM, 4 CPU, all GPUs</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<span class="form-hint">Disk is elastic: storage grows automatically as data flows in (no fixed cap).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Memory</label>
|
||||
<input type="text" id="quickDockerMemory" placeholder="4g" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>CPUs</label>
|
||||
<input type="text" id="quickDockerCpus" placeholder="2" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>GPUs</label>
|
||||
<input type="text" id="quickDockerGpus" placeholder="none (e.g. all, or 1)" autocomplete="off" spellcheck="false">
|
||||
<span class="form-hint">Needs the NVIDIA container toolkit on the host.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Network</label>
|
||||
<select id="quickDockerNetwork">
|
||||
<option value="bridge">bridge (internet on)</option>
|
||||
<option value="none">none (fully isolated)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Image</label>
|
||||
<input type="text" id="quickDockerImage" placeholder="codeman/agent:base" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="quickDockerMountCreds" checked> Mount host credentials (~/.claude etc.)</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<!-- Link Existing Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-link">
|
||||
@@ -1916,12 +1994,72 @@
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<!-- Docker Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-docker">
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="dockerCaseName" placeholder="sandbox" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Runs inside an isolated container. Multiple sessions can share the same container.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Workspace Path</label>
|
||||
<input type="text" id="dockerWorkspacePath" placeholder="/home/user/projects/sandbox" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Host ID</label>
|
||||
<input type="text" id="dockerHostId" placeholder="local" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">A reusable docker host profile. Reuse the same ID across cases to share settings.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Image</label>
|
||||
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini + tmux.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Network</label>
|
||||
<select id="dockerNetwork">
|
||||
<option value="bridge">bridge (internet on, default)</option>
|
||||
<option value="none">none (fully isolated, no network)</option>
|
||||
<option value="custom">custom bridge</option>
|
||||
</select>
|
||||
</div>
|
||||
<details class="advanced-options">
|
||||
<summary>Advanced container settings</summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
<label>Memory</label>
|
||||
<input type="text" id="dockerMemory" placeholder="4g" autocomplete="off" spellcheck="false">
|
||||
<span class="form-hint">Optional, e.g. 4g / 512m. Enforced as a hard OOM cap where the engine supports it.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>CPUs</label>
|
||||
<input type="text" id="dockerCpus" placeholder="2" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="dockerMountCredentials" checked> Mount host credentials (~/.claude etc.)</label>
|
||||
<span class="form-hint">On: your existing login just works (creds stay on the host, never in exports). Off: sealed sandbox, log in inside the container.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="dockerResumeOnStart" checked> Resume last conversation on relaunch</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<span class="form-hint" id="dockerLinkStatus" style="margin-top: 8px; display: block;"></span>
|
||||
</div>
|
||||
<!-- Manage Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-manage">
|
||||
<div class="case-manage-list" id="caseManageList">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<span class="form-hint" style="margin-top: 8px; display: block;">Use arrows to reorder. Changes are saved automatically.</span>
|
||||
<div id="dockerExportsSection" style="margin-top: 16px; border-top: 1px solid var(--border, #333); padding-top: 12px;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;">
|
||||
<strong style="font-size: 13px;">Docker exports</strong>
|
||||
<button class="btn-toolbar" onclick="app.refreshDockerExports()">Refresh</button>
|
||||
</div>
|
||||
<div class="case-manage-list" id="dockerExportsList"><span class="form-hint">No exports yet. Export a docker case from its tab.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
@@ -2357,6 +2495,7 @@
|
||||
<script defer src="settings-ui.js"></script>
|
||||
<script defer src="panels-ui.js"></script>
|
||||
<script defer src="ultracode-panel.js"></script>
|
||||
<script defer src="admin-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -3098,6 +3098,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Header "File Viewer" button (opt-in via App Settings → Header Displays →
|
||||
// File Viewer). Toggles the file browser panel open/closed without a trip
|
||||
// through settings. Persists via the same `showFileBrowser` flag the Panels
|
||||
// section + the panel's own close (X) use, so the three stay in sync.
|
||||
toggleFileBrowserButton() {
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
const isOpen = panel?.classList.contains('visible');
|
||||
const btn = document.querySelector('.btn-file-viewer');
|
||||
if (isOpen) {
|
||||
this.closeFileBrowserPanel();
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
return;
|
||||
}
|
||||
if (!this.activeSessionId) {
|
||||
this.showToast('Open a session to browse its files', 'info');
|
||||
return;
|
||||
}
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
settings.showFileBrowser = true;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
const checkbox = document.getElementById('appSettingsShowFileBrowser');
|
||||
if (checkbox) checkbox.checked = true;
|
||||
this.applyMonitorVisibility();
|
||||
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||||
},
|
||||
|
||||
closeFileBrowserPanel() {
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
if (panel) {
|
||||
@@ -3130,6 +3156,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
settings.showFileBrowser = false;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
const checkbox = document.getElementById('appSettingsShowFileBrowser');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
const headerBtn = document.querySelector('.btn-file-viewer');
|
||||
if (headerBtn) headerBtn.setAttribute('aria-expanded', 'false');
|
||||
},
|
||||
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
|
||||
|
||||
+304
-34
@@ -45,7 +45,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
formatCasePickerLabel(c) {
|
||||
return c?.location === 'remote' && c.remote?.hostId ? `${c.name} @ ${c.remote.hostId}` : c?.name || '';
|
||||
if (c?.location === 'remote' && c.remote?.hostId) return `${c.name} @ ${c.remote.hostId}`;
|
||||
if (c?.location === 'docker') return `${c.name} (${this.dockerCaseTag(c.docker?.hostId)})`;
|
||||
return c?.name || '';
|
||||
},
|
||||
|
||||
// Short parenthetical tag for a dockerized case: '(docker)' for the default /
|
||||
// auto-provisioned host (one-click "Run in Docker", the Docker-tab 'local'
|
||||
// default, or a per-case 'q-<name>' resource-override host), otherwise the custom
|
||||
// docker host id the user named (e.g. '(gpu-box)'). Keeps the case name short.
|
||||
dockerCaseTag(hostId) {
|
||||
if (!hostId || hostId === 'default' || hostId === 'local' || /^q-/.test(hostId)) return 'docker';
|
||||
return hostId;
|
||||
},
|
||||
|
||||
buildCasePickerOptions(cases = []) {
|
||||
@@ -70,7 +81,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
c.location,
|
||||
c.remote?.hostId,
|
||||
c.remote?.label,
|
||||
c.remote?.path
|
||||
c.remote?.path,
|
||||
c.docker?.container,
|
||||
c.docker?.image,
|
||||
c.docker?.path
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
return { name: c.name, label, case: c, searchText };
|
||||
})
|
||||
@@ -480,6 +494,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
input.value = Math.max(1, current - 1);
|
||||
},
|
||||
|
||||
// Next free <prefix><n> index for a case's session tabs (e.g. w1-<case>,
|
||||
// w2-<case> for agents, s1-<case> for shells), shared by the local and
|
||||
// remote/docker launch paths so all tabs follow the same naming convention.
|
||||
_nextCaseSessionStartNumber(caseName, prefix = 'w') {
|
||||
const re = new RegExp(`^${prefix}(\\d+)-([a-zA-Z0-9_-]+)`);
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions || []) {
|
||||
const match = session.name && session.name.match(re);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
return startNumber;
|
||||
},
|
||||
|
||||
async runClaude() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
|
||||
@@ -517,13 +547,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Remote cases run over ssh — POST /api/sessions stat-validates workingDir on
|
||||
// the LOCAL fs (a remote user@host:/path never exists locally), so route them
|
||||
// through /api/quick-start, which resolves the remote case + launches via ssh.
|
||||
if (caseData.location === 'remote') {
|
||||
if (caseData.location === 'remote' || caseData.location === 'docker') {
|
||||
// Name remote/docker tabs with the same w<n>-<case> convention as local
|
||||
// sessions (quick-start would otherwise auto-generate codeman-<id>).
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
||||
const remoteIds = [];
|
||||
for (let i = 0; i < tabCount; i++) {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'claude' })
|
||||
body: JSON.stringify({ caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}` })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
|
||||
@@ -541,16 +574,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
let firstSessionId = null;
|
||||
|
||||
// Find the highest existing w-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^w(\d+)-([a-zA-Z0-9_-]+)/);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) {
|
||||
startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
||||
|
||||
// Get global Ralph tracker setting
|
||||
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
|
||||
@@ -694,18 +718,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const selectedCase = (this.cases || []).find(c => c.name === caseName);
|
||||
const isRemoteCase = caseData.location === 'remote' || selectedCase?.location === 'remote';
|
||||
const isRemoteCase =
|
||||
caseData.location === 'remote' ||
|
||||
caseData.location === 'docker' ||
|
||||
selectedCase?.location === 'remote' ||
|
||||
selectedCase?.location === 'docker';
|
||||
const workingDir = caseData.path;
|
||||
if (!workingDir) throw new Error('Case path not found');
|
||||
|
||||
// Remote cases run over ssh — route through /api/quick-start (see runClaude).
|
||||
if (caseData.location === 'remote') {
|
||||
if (caseData.location === 'remote' || caseData.location === 'docker') {
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
|
||||
const remoteIds = [];
|
||||
for (let i = 0; i < shellCount; i++) {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'shell' })
|
||||
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}` })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
|
||||
@@ -721,16 +750,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Find the highest existing s-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^s(\d+)-([a-zA-Z0-9_-]+)/);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) {
|
||||
startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
|
||||
|
||||
// Create all shell sessions in parallel
|
||||
const sessionNames = [];
|
||||
@@ -789,7 +809,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run the CLI on the REMOTE host — the local /api/opencode/status
|
||||
// probe and the local-only config/env below don't apply (quick-start rejects them).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
|
||||
@@ -818,6 +839,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'opencode',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
@@ -843,7 +865,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Codex on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
|
||||
@@ -869,6 +892,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'codex',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
@@ -897,7 +921,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Gemini on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`);
|
||||
@@ -922,6 +947,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'gemini',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
geminiConfig: { approvalMode: 'yolo' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
@@ -1567,10 +1593,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (tabName === 'case-manage') {
|
||||
submitBtn.style.display = 'none';
|
||||
this.renderCaseManageList();
|
||||
this.refreshDockerExports();
|
||||
} else {
|
||||
submitBtn.style.display = '';
|
||||
submitBtn.textContent =
|
||||
tabName === 'case-create' ? 'Create' : tabName === 'case-remote' ? 'Link Remote' : 'Link';
|
||||
tabName === 'case-create'
|
||||
? 'Create'
|
||||
: tabName === 'case-remote'
|
||||
? 'Link Remote'
|
||||
: tabName === 'case-docker'
|
||||
? 'Link Docker'
|
||||
: 'Link';
|
||||
}
|
||||
// Focus appropriate input
|
||||
if (tabName === 'case-create') {
|
||||
@@ -1579,6 +1612,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('linkCaseName').focus();
|
||||
} else if (tabName === 'case-remote') {
|
||||
document.getElementById('remoteCaseName').focus();
|
||||
} else if (tabName === 'case-docker') {
|
||||
document.getElementById('dockerCaseName').focus();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1596,6 +1631,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
await this.createCase();
|
||||
} else if (this.caseModalTab === 'case-remote') {
|
||||
await this.linkRemoteCase();
|
||||
} else if (this.caseModalTab === 'case-docker') {
|
||||
await this.linkDockerCase();
|
||||
} else {
|
||||
await this.linkCase();
|
||||
}
|
||||
@@ -1619,21 +1656,36 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// One-click "Run in Docker": create the case folder AND a container, then start
|
||||
// a session inside it. Optional expandable settings override the defaults.
|
||||
const inDocker = document.getElementById('newCaseDocker')?.checked;
|
||||
const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases';
|
||||
const payload = inDocker
|
||||
? { name, description, ...this._collectDockerQuickSettings() }
|
||||
: { name, description };
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/cases', {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.closeCreateCaseModal();
|
||||
this.showToast(`Case "${name}" created`, 'success');
|
||||
// Reload cases and select the new one
|
||||
await this.loadQuickStartCases(name);
|
||||
// Save as last used case
|
||||
await this.saveLastUsedCase(name);
|
||||
if (inDocker) {
|
||||
const caps = data.data?.capsEnforced === false ? ' (resource caps advisory on this engine)' : '';
|
||||
this.showToast(`Docker case "${name}" created${caps} — starting session…`, 'success');
|
||||
// Start a session INSIDE the container (routes through quick-start).
|
||||
await this.runClaude();
|
||||
} else {
|
||||
this.showToast(`Case "${name}" created`, 'success');
|
||||
}
|
||||
} else {
|
||||
this.showToast(data.error || 'Failed to create case', 'error');
|
||||
}
|
||||
@@ -1643,6 +1695,47 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Fill the memory/cpu/gpu fields from a resource template. `medium` clears them so
|
||||
// the server uses its defaults (no per-case host); `custom` leaves them editable.
|
||||
applyDockerTemplate() {
|
||||
const t = document.getElementById('quickDockerTemplate')?.value;
|
||||
const presets = {
|
||||
small: { m: '2g', c: '1', g: '' },
|
||||
medium: { m: '', c: '', g: '' },
|
||||
large: { m: '8g', c: '4', g: '' },
|
||||
gpu: { m: '8g', c: '4', g: 'all' },
|
||||
};
|
||||
const p = presets[t];
|
||||
if (!p) return; // 'custom' — leave fields as-is
|
||||
const set = (id, v) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = v;
|
||||
};
|
||||
set('quickDockerMemory', p.m);
|
||||
set('quickDockerCpus', p.c);
|
||||
set('quickDockerGpus', p.g);
|
||||
},
|
||||
|
||||
// Collect only the non-default docker overrides (empty fields fall back to defaults
|
||||
// server-side; sent as undefined, never null, per the Zod .optional() gotcha).
|
||||
_collectDockerQuickSettings() {
|
||||
const val = (id) => (document.getElementById(id)?.value || '').trim();
|
||||
const o = {};
|
||||
const mem = val('quickDockerMemory');
|
||||
if (mem) o.memory = mem;
|
||||
const cpus = val('quickDockerCpus');
|
||||
if (cpus) o.cpus = cpus;
|
||||
const gpus = val('quickDockerGpus');
|
||||
if (gpus && gpus.toLowerCase() !== 'none') o.gpus = gpus;
|
||||
const net = document.getElementById('quickDockerNetwork')?.value;
|
||||
if (net && net !== 'bridge') o.network = net;
|
||||
const img = val('quickDockerImage');
|
||||
if (img) o.image = img;
|
||||
const mc = document.getElementById('quickDockerMountCreds');
|
||||
if (mc && !mc.checked) o.mountCredentials = false;
|
||||
return o;
|
||||
},
|
||||
|
||||
async linkCase() {
|
||||
const name = document.getElementById('linkCaseName').value.trim();
|
||||
const path = document.getElementById('linkCasePath').value.trim();
|
||||
@@ -1767,6 +1860,177 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async linkDockerCase() {
|
||||
const name = document.getElementById('dockerCaseName').value.trim();
|
||||
const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim();
|
||||
const hostId = document.getElementById('dockerHostId').value.trim() || 'local';
|
||||
const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base';
|
||||
const network = document.getElementById('dockerNetwork').value;
|
||||
const memory = document.getElementById('dockerMemory').value.trim();
|
||||
const cpus = document.getElementById('dockerCpus').value.trim();
|
||||
const mountCredentials = document.getElementById('dockerMountCredentials').checked;
|
||||
const resumeOnStart = document.getElementById('dockerResumeOnStart').checked;
|
||||
const statusEl = document.getElementById('dockerLinkStatus');
|
||||
|
||||
if (!name || !hostWorkspacePath) {
|
||||
this.showToast('Please enter a case name and workspace path', 'error');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name) || !/^[a-zA-Z0-9_-]+$/.test(hostId)) {
|
||||
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!hostWorkspacePath.startsWith('/')) {
|
||||
this.showToast('Workspace path must be absolute', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...';
|
||||
// omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null)
|
||||
const resources = {};
|
||||
if (memory) resources.memory = memory;
|
||||
if (cpus) resources.cpus = cpus;
|
||||
const hostPayload = {
|
||||
id: hostId,
|
||||
label: hostId,
|
||||
image,
|
||||
network,
|
||||
mountCredentials,
|
||||
resumeOnStart,
|
||||
...(Object.keys(resources).length ? { resources } : {}),
|
||||
};
|
||||
// PUT (update-or-create) so re-linking with the same host id refreshes its settings.
|
||||
let hostRes = await fetch('/api/docker-hosts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(hostPayload),
|
||||
});
|
||||
let hostData = await hostRes.json();
|
||||
if (!hostData.success && hostData.errorCode === 'ALREADY_EXISTS') {
|
||||
hostRes = await fetch(`/api/docker-hosts/${encodeURIComponent(hostId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(hostPayload),
|
||||
});
|
||||
hostData = await hostRes.json();
|
||||
}
|
||||
if (!hostData.success) throw new Error(hostData.error || 'Failed to save docker host');
|
||||
|
||||
const caseRes = await fetch('/api/cases/docker-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, hostId, hostWorkspacePath }),
|
||||
});
|
||||
const caseData = await caseRes.json();
|
||||
if (caseData.success) {
|
||||
this.closeCreateCaseModal();
|
||||
const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : '';
|
||||
this.showToast(`Docker case "${name}" linked${caps}`, 'success');
|
||||
await this.loadQuickStartCases(name);
|
||||
await this.saveLastUsedCase(name);
|
||||
} else {
|
||||
if (statusEl) statusEl.textContent = caseData.error || 'Failed to link docker case';
|
||||
this.showToast(caseData.error || 'Failed to link docker case', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to link docker case:', err);
|
||||
if (statusEl) statusEl.textContent = err.message;
|
||||
this.showToast('Failed to link docker case: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Docker export / import UI
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async refreshDockerExports() {
|
||||
const listEl = document.getElementById('dockerExportsList');
|
||||
if (!listEl) return;
|
||||
try {
|
||||
const res = await fetch('/api/docker-exports');
|
||||
const data = await res.json();
|
||||
const exports = data?.data?.exports || [];
|
||||
if (exports.length === 0) {
|
||||
listEl.innerHTML = '<span class="form-hint">No exports yet. Export a docker case from its tab.</span>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = exports
|
||||
.map(e => {
|
||||
const mb = (e.sizeBytes / 1e6).toFixed(1);
|
||||
const nm = this.escapeHtml ? this.escapeHtml(e.name) : e.name;
|
||||
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
|
||||
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
|
||||
<span style="flex-shrink:0;">
|
||||
<a class="btn-toolbar" href="/api/docker-exports/${encodeURIComponent(e.name)}" download>Download</a>
|
||||
<button class="btn-toolbar" onclick="app.importDockerBundle('${nm.replace(/'/g, "\\'")}')">Import</button>
|
||||
<button class="btn-toolbar" onclick="app.deleteDockerExport('${nm.replace(/'/g, "\\'")}')">Delete</button>
|
||||
</span>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
} catch (err) {
|
||||
listEl.innerHTML = `<span class="form-hint">Failed to load exports: ${err.message}</span>`;
|
||||
}
|
||||
},
|
||||
|
||||
async exportDockerCaseBundle(caseName, mode = 'full') {
|
||||
try {
|
||||
const res = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.showToast(`Exporting "${caseName}" (${mode})... you'll be notified when the bundle is ready`, 'info');
|
||||
} else {
|
||||
this.showToast(data.error || 'Export failed', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Export failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async importDockerBundle(bundle) {
|
||||
const newCaseName = prompt('New case name for the imported bundle:', bundle.split('-')[0] + '-imported');
|
||||
if (!newCaseName) return;
|
||||
const destWorkspacePath = prompt('Absolute host directory to restore the workspace into:', '');
|
||||
if (!destWorkspacePath) return;
|
||||
try {
|
||||
const res = await fetch('/api/docker-cases/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bundle, newCaseName, destWorkspacePath }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.showToast(`Imported as "${newCaseName}"`, 'success');
|
||||
await this.loadQuickStartCases(newCaseName);
|
||||
} else {
|
||||
this.showToast(data.error || 'Import failed', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Import failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteDockerExport(filename) {
|
||||
if (!confirm(`Delete export bundle "${filename}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/docker-exports/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.showToast('Export deleted', 'success');
|
||||
this.refreshDockerExports();
|
||||
} else {
|
||||
this.showToast(data.error || 'Delete failed', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Delete failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case Management (reorder + delete)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1791,6 +2055,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
||||
</div>
|
||||
<div class="case-manage-actions">
|
||||
${
|
||||
c.location === 'docker'
|
||||
? `<button class="case-manage-btn" onclick="app.exportDockerCaseBundle(${escapeHtml(JSON.stringify(c.name))}, 'full')"
|
||||
title="Export container (full image + workspace) to move to another machine">📦</button>`
|
||||
: ''
|
||||
}
|
||||
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
|
||||
|
||||
@@ -307,6 +307,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
|
||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
|
||||
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
|
||||
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||
// WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
|
||||
@@ -324,6 +325,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Session Manager + Away Digest buttons default OFF; Cron button defaults ON.
|
||||
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
||||
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? true;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -1422,6 +1427,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
||||
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
||||
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
||||
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
|
||||
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
|
||||
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
|
||||
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
|
||||
@@ -1432,6 +1438,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
@@ -1611,8 +1620,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
skin: _skin,
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
showFileViewerButton: _fvb,
|
||||
webglRendererEnabled: _wgl,
|
||||
terminalWheelLocalScrollback: _twls,
|
||||
// Per-device header/toolbar button toggles — client-only, and absent from
|
||||
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
|
||||
showSessionButton: _ssb,
|
||||
showAwayDigestButton: _adb,
|
||||
showCronButton: _crb,
|
||||
...serverSettings
|
||||
} = settings;
|
||||
try {
|
||||
@@ -1769,7 +1784,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
showMultiMonitorButton: false,
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
showFileViewerButton: false,
|
||||
showRedrawButton: false,
|
||||
showSessionButton: false,
|
||||
showAwayDigestButton: false,
|
||||
showCronButton: true,
|
||||
// Input
|
||||
gestureControlEnabled: false,
|
||||
// Feature toggles - keep tracking on even on mobile
|
||||
@@ -1882,6 +1901,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
attachmentsBtn.classList.toggle('btn-attachments-history--hidden', !showAttachmentsButton);
|
||||
}
|
||||
|
||||
// File Viewer header button — opt-in, default OFF. Marker class (base is
|
||||
// display:inline-flex !important); clicking it toggles the file browser panel.
|
||||
const showFileViewerButton = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
|
||||
const fileViewerBtn = document.querySelector('.btn-file-viewer');
|
||||
if (fileViewerBtn) {
|
||||
fileViewerBtn.classList.toggle('btn-file-viewer--hidden', !showFileViewerButton);
|
||||
}
|
||||
|
||||
// Multi-monitor button — hidden by default (App Settings → Display → "Header
|
||||
// Displays"). The server renders the correct initial state on every reload;
|
||||
// this handles a live toggle from a settings save (no reload). Toggle the
|
||||
@@ -1917,6 +1944,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
redrawBtn.classList.toggle('btn-redraw-terminal--hidden', !showRedrawButton);
|
||||
}
|
||||
|
||||
// Session Manager button — opt-in, hidden by default (App Settings → Display).
|
||||
// Marker class (base is display:inline-flex !important); phones keep it hidden
|
||||
// via mobile.css regardless. Sessions stay reachable via the Ctrl+K palette.
|
||||
const showSessionButton = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
||||
const sessionBtn = document.querySelector('.btn-session-manager');
|
||||
if (sessionBtn) {
|
||||
sessionBtn.classList.toggle('btn-session-manager--hidden', !showSessionButton);
|
||||
}
|
||||
|
||||
// Away Digest button — opt-in, hidden by default. Same marker pattern.
|
||||
const showAwayDigestButton = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
||||
const awayDigestBtn = document.querySelector('.btn-away-digest');
|
||||
if (awayDigestBtn) {
|
||||
awayDigestBtn.classList.toggle('btn-away-digest--hidden', !showAwayDigestButton);
|
||||
}
|
||||
|
||||
// Cron button (footer toolbar) — shown by default; hide when disabled.
|
||||
const showCronButton = settings.showCronButton ?? defaults.showCronButton ?? true;
|
||||
const cronBtn = document.querySelector('.btn-cron');
|
||||
if (cronBtn) {
|
||||
cronBtn.classList.toggle('btn-cron--hidden', !showCronButton);
|
||||
}
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
const notifBtn = document.querySelector('.btn-notifications');
|
||||
@@ -2153,8 +2203,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'webglRendererEnabled',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalWheelLocalScrollback',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
|
||||
// can show it while mobile stays hidden. It used to sync, so an older
|
||||
|
||||
@@ -5383,6 +5383,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
/* Modal Tabs */
|
||||
.modal-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0 1rem 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -9447,6 +9448,24 @@ kbd {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* "Run in Docker" quick option — the primary one-click entry point, so its
|
||||
label + hint read larger and brighter than the standard form label/hint. */
|
||||
.docker-quick-row .checkbox-row {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.docker-quick-row .form-hint {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* The container-settings panel is always shown + expanded (no longer gated on
|
||||
the checkbox), so its summary header reads a touch larger too. */
|
||||
.docker-quick-settings > summary {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Response Viewer — native-scroll overlay for reading Claude responses
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@@ -9471,6 +9490,42 @@ kbd {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "Session Manager" + "Away Digest" header buttons — opt-in (App Settings →
|
||||
Display), hidden by default. Same marker pattern as the response viewer: a
|
||||
base inline-flex !important so an inline style can't override it, and a
|
||||
more-specific marker rule to hide. Phones keep them hidden regardless via the
|
||||
higher-specificity mobile.css rule (.btn-icon-header.btn-...). */
|
||||
.btn-session-manager {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-session-manager.btn-session-manager--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.btn-away-digest {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-away-digest.btn-away-digest--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "File Viewer" header button — opt-in (App Settings → Header Displays), hidden
|
||||
by default. Clicking it toggles the file browser panel. Same marker pattern as
|
||||
the response viewer: a base inline-flex !important so an inline style can't
|
||||
override it, and a more-specific marker rule to hide. */
|
||||
.btn-file-viewer {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-file-viewer.btn-file-viewer--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "Cron" footer-toolbar button — shown by default (App Settings → Display can
|
||||
hide it). Toolbar button, not a header icon, so only the hide marker is
|
||||
needed; out-specify any base .btn-toolbar display. */
|
||||
.btn-toolbar.btn-cron--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "Ultracode Agents" header launcher — opt-in (App Settings → Display), hidden by
|
||||
default everywhere (so the mobile-header-buttons-policy guard auto-excludes it).
|
||||
Base inline-flex !important + a more-specific marker rule to hide. */
|
||||
|
||||
+183
-4
@@ -6,17 +6,23 @@
|
||||
*/
|
||||
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { realpathSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import type { z } from 'zod';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { Session } from '../session.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../types.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import type { SessionPort } from './ports/session-port.js';
|
||||
import type { EventPort } from './ports/event-port.js';
|
||||
import type { AuthSessionRecord } from './ports/auth-port.js';
|
||||
import type { StaleExpirationMap } from '../utils/index.js';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js';
|
||||
import { SYNTHETIC_ADMIN, findUser } from '../user-store.js';
|
||||
|
||||
// Shared path constants used across route modules. CASES_DIR (project folders)
|
||||
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
|
||||
@@ -79,13 +85,186 @@ export function validateSessionFilePath(
|
||||
// Maximum hook data size (prevents oversized SSE broadcasts)
|
||||
const MAX_HOOK_DATA_SIZE = 8 * 1024;
|
||||
|
||||
/**
|
||||
* Effective identity for a request. In multi-user mode this is the auth-decorated
|
||||
* user; in single-user mode (or when unset) it defaults to a synthetic admin so
|
||||
* downstream ownership checks are no-ops and there is ONE code path.
|
||||
*/
|
||||
export function getAuthUser(req: FastifyRequest): AuthUser {
|
||||
return req.authUser ?? SYNTHETIC_ADMIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an identity may see/act on a resource with the given owner. Always true
|
||||
* in single-user mode; in multi-user, admins see everything and regular users only
|
||||
* their own (an absent owner is legacy/unassigned = admin-only).
|
||||
*/
|
||||
export function canAccessOwned(user: AuthUser, owner: string | undefined): boolean {
|
||||
if (!isMultiUserMode()) return true;
|
||||
if (user.role === 'admin') return true;
|
||||
return !!owner && owner === user.username;
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner to stamp on a resource created by this request: the requesting user in
|
||||
* multi-user mode, or undefined in single-user (so state stays owner-free and the
|
||||
* flag can be removed later without leaving stray owners).
|
||||
*/
|
||||
export function ownerFor(req: FastifyRequest): string | undefined {
|
||||
return isMultiUserMode() ? getAuthUser(req).username : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cases directory for a request/user: the shared ~/codeman-cases in single-user
|
||||
* mode, or the per-user ~/codeman-users/<username>/cases in multi-user (created
|
||||
* lazily). Admins are NOT auto-scoped here — an admin acting on a specific user's
|
||||
* case resolves through the owner-aware case resolver instead.
|
||||
*/
|
||||
export function resolveCasesDir(user?: AuthUser): string {
|
||||
if (!isMultiUserMode() || !user) return CASES_DIR;
|
||||
const dir = userCasesDir(user.username);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Realpath-confine a non-admin's requested working directory to their own case
|
||||
* space in multi-user mode. Returns true if allowed. Admins and single-user mode
|
||||
* are unrestricted. The path need not exist yet (checked against its nearest
|
||||
* existing ancestor) so newly-created case dirs pass. This is the load-bearing
|
||||
* rule (plan 6.2/14.7): every file-serving surface downstream trusts workingDir.
|
||||
*/
|
||||
export function isWorkingDirAllowed(user: AuthUser, workingDir: string): boolean {
|
||||
if (!isMultiUserMode() || user.role === 'admin') return true;
|
||||
const base = userCasesDir(user.username);
|
||||
// Resolve the deepest existing ancestor to defeat symlink escapes without
|
||||
// requiring the leaf to exist yet.
|
||||
const resolveExisting = (p: string): string => {
|
||||
let cur = resolve(p);
|
||||
// walk up until an existing path is found
|
||||
for (;;) {
|
||||
try {
|
||||
return realpathSync(cur);
|
||||
} catch {
|
||||
const parent = resolve(cur, '..');
|
||||
if (parent === cur) return cur;
|
||||
cur = parent;
|
||||
}
|
||||
}
|
||||
};
|
||||
let realBase: string;
|
||||
try {
|
||||
realBase = realpathSync(base);
|
||||
} catch {
|
||||
// base does not exist yet — create it so confinement has a stable anchor
|
||||
mkdirSync(base, { recursive: true });
|
||||
realBase = realpathSync(base);
|
||||
}
|
||||
const realTarget = resolveExisting(workingDir);
|
||||
if (realTarget === realBase) return true;
|
||||
const rel = relative(realBase, realTarget);
|
||||
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Username-keyed variant of `isWorkingDirAllowed` for spawn sites that only carry
|
||||
* an owner username (cron fire-time, scheduled-run loop) rather than a live request.
|
||||
* Resolves the owner's role from the store; a missing/deleted user is treated as a
|
||||
* non-privileged regular user (fails closed to their deterministic case space).
|
||||
* No-op (true) in single-user mode or for an unset owner.
|
||||
*/
|
||||
export async function isWorkingDirAllowedForUsername(
|
||||
username: string | undefined,
|
||||
workingDir: string
|
||||
): Promise<boolean> {
|
||||
if (!isMultiUserMode() || !username) return true;
|
||||
const user = await findUser(username);
|
||||
return isWorkingDirAllowed({ username, role: user?.role ?? 'user' }, workingDir);
|
||||
}
|
||||
|
||||
/** Whether the caller is an admin (or single-user mode, where the sole user is admin). */
|
||||
export function isAdmin(req: FastifyRequest): boolean {
|
||||
return !isMultiUserMode() || getAuthUser(req).role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* First line of admin-only handlers: 403 FORBIDDEN + returns false when the caller
|
||||
* is not an admin. Always true in single-user mode (the sole user is the admin).
|
||||
*/
|
||||
export function requireAdmin(req: FastifyRequest, reply: FastifyReply): boolean {
|
||||
if (isAdmin(req)) return true;
|
||||
reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN));
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-capacity check, centralized so the global cap AND the per-user cap are
|
||||
* enforced everywhere a session is created (the check was copy-pasted at 6 sites).
|
||||
* Pure: takes the sessions Map so it composes with ctx.sessions / this.sessions /
|
||||
* this.deps.sessions callers. Per-user cap only applies in multi-user mode.
|
||||
*/
|
||||
export function sessionCapacityState(
|
||||
sessions: ReadonlyMap<string, Session>,
|
||||
owner?: string
|
||||
): { atGlobalCap: boolean; atUserCap: boolean } {
|
||||
const atGlobalCap = sessions.size >= MAX_CONCURRENT_SESSIONS;
|
||||
let atUserCap = false;
|
||||
if (isMultiUserMode() && owner) {
|
||||
let count = 0;
|
||||
for (const s of sessions.values()) if (s.owner === owner) count++;
|
||||
atUserCap = count >= maxSessionsPerUser();
|
||||
}
|
||||
return { atGlobalCap, atUserCap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Route sugar: the human-readable error message when at capacity, else null. The
|
||||
* caller wraps it in createErrorResponse with its own error code (OPERATION_FAILED
|
||||
* vs SESSION_BUSY, matching the pre-existing per-route codes).
|
||||
*/
|
||||
export function sessionCapacityMessage(sessions: ReadonlyMap<string, Session>, owner?: string): string | null {
|
||||
const { atGlobalCap, atUserCap } = sessionCapacityState(sessions, owner);
|
||||
if (atGlobalCap) {
|
||||
return `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`;
|
||||
}
|
||||
if (atUserCap) {
|
||||
return `Your session limit (${maxSessionsPerUser()}) reached. Delete some of your sessions first.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke every cookie session belonging to a user (optionally keeping one token,
|
||||
* e.g. the caller's own during a self-service password change). Returns the count.
|
||||
*/
|
||||
export function revokeUserSessions(
|
||||
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null,
|
||||
username: string,
|
||||
exceptToken?: string
|
||||
): number {
|
||||
if (!authSessions) return 0;
|
||||
const norm = username.trim().toLowerCase();
|
||||
let removed = 0;
|
||||
for (const [token, record] of authSessions) {
|
||||
if (record.username === norm && token !== exceptToken) {
|
||||
authSessions.delete(token);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a session by ID or throw a structured error.
|
||||
* Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`.
|
||||
*
|
||||
* When `req` is passed in multi-user mode, a session the caller does not own is
|
||||
* reported as NOT_FOUND (never 403), so existence of other users' sessions is not
|
||||
* leaked. Single-user / admin callers are unaffected.
|
||||
*/
|
||||
export function findSessionOrFail(ctx: SessionPort, sessionId: string): Session {
|
||||
export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: FastifyRequest): Session {
|
||||
const session = ctx.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
if (!session || (req && !canAccessOwned(getAuthUser(req), session.owner))) {
|
||||
throw Object.assign(new Error(`Session ${sessionId} not found`), {
|
||||
statusCode: 404,
|
||||
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @fileoverview Admin user-management routes (multi-user mode only).
|
||||
*
|
||||
* All handlers: 404 unless multi-user mode is active, requireAdmin, and audit-logged
|
||||
* to ~/.codeman/admin-audit.jsonl. Endpoints (docs/multi-user-plan.md section 8):
|
||||
* GET /api/admin/users
|
||||
* POST /api/admin/users
|
||||
* PATCH /api/admin/users/:username
|
||||
* POST /api/admin/users/:username/reset-password
|
||||
* POST /api/admin/users/:username/logout
|
||||
* DELETE /api/admin/users/:username
|
||||
*
|
||||
* Self-service GET /api/me + POST /api/me/password live in me-routes.ts.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isMultiUserMode, userCasesDir } from '../../config/multiuser.js';
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
deleteUserSpace,
|
||||
findUser,
|
||||
generateOneTimePassword,
|
||||
readUsers,
|
||||
setPassword,
|
||||
toPublicUser,
|
||||
updateUser,
|
||||
UserStoreError,
|
||||
} from '../../user-store.js';
|
||||
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
|
||||
import { appendAdminAudit } from '../admin-audit.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { AuthPort } from '../ports/auth-port.js';
|
||||
import type { SessionPort } from '../ports/session-port.js';
|
||||
import type { EventPort } from '../ports/event-port.js';
|
||||
|
||||
const CreateUserSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
role: z.enum(['admin', 'user']).default('user'),
|
||||
password: z.string().min(8).max(1024).optional(),
|
||||
canBypassPermissions: z.boolean().optional(),
|
||||
});
|
||||
const UpdateUserSchema = z.object({
|
||||
role: z.enum(['admin', 'user']).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
canBypassPermissions: z.boolean().optional(),
|
||||
});
|
||||
const DeleteUserSchema = z.object({ deleteSpace: z.boolean().optional() });
|
||||
|
||||
/** Map a UserStoreError's code onto the API error code + status. */
|
||||
function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof createErrorResponse> {
|
||||
if (err instanceof UserStoreError) {
|
||||
const code = ApiErrorCode[err.code as keyof typeof ApiErrorCode] ?? ApiErrorCode.INVALID_INPUT;
|
||||
reply.code(
|
||||
err.code === 'USER_EXISTS' || err.code === 'LAST_ADMIN' ? 409 : err.code === 'USER_NOT_FOUND' ? 404 : 400
|
||||
);
|
||||
return createErrorResponse(code, err.message);
|
||||
}
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : 'error');
|
||||
}
|
||||
|
||||
export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & AuthPort & EventPort): void {
|
||||
// Gate: admin routes exist only in multi-user mode, and only for admins.
|
||||
const gate = (req: FastifyRequest, reply: FastifyReply): boolean => {
|
||||
if (!isMultiUserMode()) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Not found'));
|
||||
return false;
|
||||
}
|
||||
return requireAdmin(req, reply);
|
||||
};
|
||||
const audit = (req: FastifyRequest, action: string, target?: string, detail?: Record<string, unknown>) =>
|
||||
void appendAdminAudit({ admin: getAuthUser(req).username, action, target, ip: req.ip, detail });
|
||||
|
||||
// Count a user's live sessions + active cookie sessions + case folders.
|
||||
const statsFor = (username: string) => {
|
||||
let liveSessions = 0;
|
||||
for (const s of ctx.sessions.values()) if (s.owner === username) liveSessions++;
|
||||
let activeSessions = 0;
|
||||
if (ctx.authSessions) for (const [, rec] of ctx.authSessions) if (rec.username === username) activeSessions++;
|
||||
let caseCount = 0;
|
||||
try {
|
||||
caseCount = readdirSync(userCasesDir(username), { withFileTypes: true }).filter((e) => e.isDirectory()).length;
|
||||
} catch {
|
||||
/* no cases dir yet */
|
||||
}
|
||||
return { liveSessions, activeSessions, caseCount };
|
||||
};
|
||||
|
||||
app.get('/api/admin/users', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const users = await readUsers(true);
|
||||
return {
|
||||
success: true,
|
||||
data: users.map((u) => ({ ...toPublicUser(u), stats: statsFor(u.username) })),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/admin/users', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const parsed = CreateUserSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input');
|
||||
}
|
||||
// No password given: generate a one-time password, returned ONCE, force change.
|
||||
const oneTime = parsed.data.password ? undefined : generateOneTimePassword();
|
||||
try {
|
||||
const user = await createUser({
|
||||
username: parsed.data.username,
|
||||
role: parsed.data.role,
|
||||
password: parsed.data.password ?? oneTime!,
|
||||
canBypassPermissions: parsed.data.canBypassPermissions,
|
||||
mustChangePassword: !parsed.data.password,
|
||||
});
|
||||
audit(req, 'user.create', user.username, { role: user.role });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { user: toPublicUser(user), oneTimePassword: oneTime } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/admin/users/:username', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const parsed = UpdateUserSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input');
|
||||
}
|
||||
try {
|
||||
const user = await updateUser(username, parsed.data);
|
||||
// Security: revoke the target's cookie sessions on ANY successful update. role,
|
||||
// disabled, and canBypassPermissions are all authorization-relevant, and the
|
||||
// cookie snapshots role, so a stale cookie could otherwise retain old privileges
|
||||
// (a demoted admin staying admin). Idempotent, affects only the target, and
|
||||
// forces a re-auth that re-snapshots the new record.
|
||||
revokeUserSessions(ctx.authSessions, user.username);
|
||||
audit(req, 'user.update', user.username, parsed.data);
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { user: toPublicUser(user) } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/users/:username/reset-password', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
if (!(await findUser(username))) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user');
|
||||
}
|
||||
const oneTime = generateOneTimePassword();
|
||||
try {
|
||||
await setPassword(username, oneTime, { mustChangePassword: true });
|
||||
revokeUserSessions(ctx.authSessions, username);
|
||||
audit(req, 'user.reset-password', username);
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { oneTimePassword: oneTime } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/users/:username/logout', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const revoked = revokeUserSessions(ctx.authSessions, username);
|
||||
audit(req, 'user.logout', username, { revoked });
|
||||
return { success: true, data: { revoked } };
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:username', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const parsed = DeleteUserSchema.safeParse(req.body ?? {});
|
||||
const deleteSpace = parsed.success ? parsed.data.deleteSpace : false;
|
||||
try {
|
||||
// Security: validate BEFORE any teardown. deleteUser runs the authoritative
|
||||
// existence + last-admin guard under lock with no side effects, so a refusal
|
||||
// (409 LAST_ADMIN / 404 USER_NOT_FOUND) leaves the user's live sessions and
|
||||
// cookies untouched. Only after it succeeds do we irreversibly kill sessions and
|
||||
// revoke cookies. (owned is captured from the in-memory map, independent of the
|
||||
// record, so it is safe to read before the delete.)
|
||||
const owned = [...ctx.sessions.values()].filter((s) => s.owner === username).map((s) => s.id);
|
||||
await deleteUser(username); // throws LAST_ADMIN / USER_NOT_FOUND (no side effects)
|
||||
for (const id of owned) {
|
||||
await ctx.cleanupSession(id, true, 'admin_delete_user').catch(() => {});
|
||||
}
|
||||
revokeUserSessions(ctx.authSessions, username);
|
||||
if (deleteSpace) await deleteUserSpace(username);
|
||||
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { username, deletedSpace: !!deleteSpace } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
+643
-50
@@ -5,11 +5,13 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, createReadStream } from 'node:fs';
|
||||
import { exec } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { join, resolve, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, CaseInfo } from '../../types.js';
|
||||
import type { ApiResponse, CaseInfo, DockerHost, SessionDocker } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import {
|
||||
CreateCaseSchema,
|
||||
@@ -17,13 +19,48 @@ import {
|
||||
CaseOrderSchema,
|
||||
RemoteCaseLinkSchema,
|
||||
RemoteHostSchema,
|
||||
DockerCaseLinkSchema,
|
||||
DockerHostSchema,
|
||||
DockerExportSchema,
|
||||
DockerImportSchema,
|
||||
DockerQuickCreateSchema,
|
||||
} from '../schemas.js';
|
||||
import { exportDockerCase, importDockerBundle, listDockerExports, exportBundleName } from '../../docker-export.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||
import {
|
||||
canAccessOwned,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
isWorkingDirAllowed,
|
||||
ownerFor,
|
||||
resolveCasesDir,
|
||||
SETTINGS_PATH,
|
||||
validatePathWithinBase,
|
||||
parseBody,
|
||||
readJsonConfig,
|
||||
} from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { AuthUser } from '../../types.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
checkDockerAvailable,
|
||||
checkDockerImagePresent,
|
||||
checkDockerTmuxAvailable,
|
||||
ensureAgentBaseImage,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
dockerContainerName,
|
||||
dockerDisplayPath,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
toSessionDocker,
|
||||
writeDockerCases,
|
||||
writeDockerHosts,
|
||||
} from '../../docker-hosts.js';
|
||||
import { buildDockerRemoveCommand } from '../../tmux-manager.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
readRemoteCases,
|
||||
@@ -36,17 +73,77 @@ import {
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
const DOCKER_EXPORTS_DIR = dataPath('docker-exports');
|
||||
/** Auto-created host profile for the one-click "Run in Docker" case flow. */
|
||||
const DEFAULT_DOCKER_HOST_ID = 'default';
|
||||
|
||||
/** App version for export manifests (best-effort read of package.json). */
|
||||
const APP_VERSION = (() => {
|
||||
try {
|
||||
const pkgPath = fileURLToPath(new URL('../../../package.json', import.meta.url));
|
||||
return (JSON.parse(readFileSync(pkgPath, 'utf-8')).version as string) || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
})();
|
||||
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
|
||||
}
|
||||
|
||||
/** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */
|
||||
async function resolveCasePath(name: string): Promise<string> {
|
||||
/**
|
||||
* Resolve a case name to its directory path, checking linked cases first, then the
|
||||
* user's case space (per-user in multi-user mode, the shared CASES_DIR otherwise).
|
||||
*/
|
||||
async function resolveCasePath(name: string, user?: AuthUser): Promise<string> {
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name]) return linkedCases[name];
|
||||
return join(CASES_DIR, name);
|
||||
// Linked cases carry no owner (legacy/admin-only registry): a non-admin must not
|
||||
// resolve arbitrary linked paths by name in multi-user mode (path-escape guard).
|
||||
if (linkedCases[name] && (!isMultiUserMode() || user?.role === 'admin')) return linkedCases[name];
|
||||
return join(resolveCasesDir(user), name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a docker case on its base image, AUTO-BUILDING the default image on first
|
||||
* use so a missing image is never a blocker (the user's ask: "create it when it's
|
||||
* used for the first time"). Present image → verify tmux (hard prerequisite).
|
||||
* Default image missing → kick off a BACKGROUND build with SSE progress and return
|
||||
* `imageBuilding: true` (the case is created regardless; first launch awaits the
|
||||
* same dedup'd build). Custom image missing → a real error (we can't build a
|
||||
* foreign ref, and `--pull=never` forbids pulling).
|
||||
*/
|
||||
async function ensureCaseImage(
|
||||
broadcast: EventPort['broadcast'],
|
||||
sessionDocker: SessionDocker,
|
||||
name: string
|
||||
): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> {
|
||||
if (await checkDockerImagePresent(sessionDocker.engine, sessionDocker.image)) {
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||||
if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
|
||||
return { ok: true, imageBuilding: false };
|
||||
}
|
||||
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `base image ${sessionDocker.image} not present; only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull it first.`,
|
||||
};
|
||||
}
|
||||
broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
|
||||
void ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
|
||||
onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
|
||||
})
|
||||
.then((r) =>
|
||||
broadcast(r.ok ? SseEvent.DockerImageBuildComplete : SseEvent.DockerImageBuildFailed, {
|
||||
name,
|
||||
image: sessionDocker.image,
|
||||
error: r.error,
|
||||
})
|
||||
)
|
||||
.catch((err) =>
|
||||
broadcast(SseEvent.DockerImageBuildFailed, { name, image: sessionDocker.image, error: getErrorMessage(err) })
|
||||
);
|
||||
return { ok: true, imageBuilding: true };
|
||||
}
|
||||
|
||||
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
|
||||
@@ -56,47 +153,54 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
// ========== List Cases ==========
|
||||
|
||||
app.get('/api/cases', async (): Promise<CaseInfo[]> => {
|
||||
app.get('/api/cases', async (req): Promise<CaseInfo[]> => {
|
||||
const cases: CaseInfo[] = [];
|
||||
const user = getAuthUser(req);
|
||||
const admin = isAdmin(req);
|
||||
// Non-admins enumerate their OWN case space; admins see the shared CASES_DIR.
|
||||
const listBase = resolveCasesDir(user);
|
||||
|
||||
// Get cases from CASES_DIR
|
||||
// Get cases from the user's (or shared) cases dir
|
||||
try {
|
||||
const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
|
||||
const entries = await fs.readdir(listBase, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
|
||||
cases.push({
|
||||
name: e.name,
|
||||
path: join(CASES_DIR, e.name),
|
||||
hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')),
|
||||
path: join(listBase, e.name),
|
||||
hasClaudeMd: existsSync(join(listBase, e.name, 'CLAUDE.md')),
|
||||
location: 'local',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// CASES_DIR may not exist yet
|
||||
// dir may not exist yet
|
||||
}
|
||||
|
||||
// Get linked cases
|
||||
// Linked cases (v1 registry has no owner) are admin-only in multi-user mode.
|
||||
const linkedCases = await readLinkedCases();
|
||||
const existingNames = new Set(cases.map((c) => c.name));
|
||||
for (const [name, path] of Object.entries(linkedCases)) {
|
||||
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
||||
cases.push({
|
||||
name,
|
||||
path,
|
||||
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
||||
linked: true,
|
||||
location: 'linked-local',
|
||||
});
|
||||
if (admin) {
|
||||
for (const [name, path] of Object.entries(linkedCases)) {
|
||||
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
||||
cases.push({
|
||||
name,
|
||||
path,
|
||||
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
||||
linked: true,
|
||||
location: 'linked-local',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get remote cases
|
||||
// Get remote cases (owner-scoped; legacy no-owner = admin-only)
|
||||
const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
|
||||
for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
|
||||
const host = remoteHostMap.get(remoteCase.hostId);
|
||||
if (!host || !SAFE_CASE_NAME.test(remoteCase.name)) continue;
|
||||
if (!admin && !canAccessOwned(user, remoteCase.owner)) continue;
|
||||
existingNames.add(remoteCase.name);
|
||||
const remoteCaseInfo: CaseInfo = {
|
||||
name: remoteCase.name,
|
||||
@@ -118,6 +222,36 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
}
|
||||
|
||||
// Get docker cases
|
||||
const dockerHosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const dockerHostMap = new Map(dockerHosts.map((host) => [host.id, host]));
|
||||
for (const dockerCase of await readDockerCases(CODEMAN_CONFIG_DIR)) {
|
||||
const host = dockerHostMap.get(dockerCase.hostId);
|
||||
if (!host || !SAFE_CASE_NAME.test(dockerCase.name)) continue;
|
||||
if (!admin && !canAccessOwned(user, dockerCase.owner)) continue;
|
||||
existingNames.add(dockerCase.name);
|
||||
const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
|
||||
const dockerCaseInfo: CaseInfo = {
|
||||
name: dockerCase.name,
|
||||
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
|
||||
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||
location: 'docker',
|
||||
docker: {
|
||||
hostId: host.id,
|
||||
container,
|
||||
image: host.image,
|
||||
path: dockerCase.hostWorkspacePath,
|
||||
network: host.network ?? 'bridge',
|
||||
},
|
||||
};
|
||||
const existingIndex = cases.findIndex((item) => item.name === dockerCase.name);
|
||||
if (existingIndex === -1) {
|
||||
cases.push(dockerCaseInfo);
|
||||
} else {
|
||||
cases[existingIndex] = dockerCaseInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by persisted caseOrder from settings.json
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings', {});
|
||||
const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : [];
|
||||
@@ -136,7 +270,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
const { name, description } = parseBody(CreateCaseSchema, req.body);
|
||||
|
||||
const casePath = validatePathWithinBase(name, CASES_DIR);
|
||||
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
@@ -165,9 +299,21 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/remote-hosts', async () => readRemoteHosts(CODEMAN_CONFIG_DIR));
|
||||
// Hosts are machine-level infra config (ssh users/identity paths): non-admins get an
|
||||
// empty list in multi-user mode, matching the admin-only write side. No-op otherwise.
|
||||
app.get('/api/remote-hosts', async (req) =>
|
||||
isMultiUserMode() && !isAdmin(req) ? [] : readRemoteHosts(CODEMAN_CONFIG_DIR)
|
||||
);
|
||||
|
||||
app.post('/api/remote-hosts', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
// Hosts are machine-level resources: only admins may define them in multi-user mode.
|
||||
const adminOnly = (req: FastifyRequest, reply: { code: (n: number) => unknown }): ApiResponse<never> | null =>
|
||||
isAdmin(req)
|
||||
? null
|
||||
: (reply.code(403), createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'));
|
||||
|
||||
app.post('/api/remote-hosts', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const host = parseBody(RemoteHostSchema, req.body);
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
if (hosts.some((item) => item.id === host.id)) {
|
||||
@@ -177,7 +323,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.put('/api/remote-hosts/:id', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
app.put('/api/remote-hosts/:id', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
const host = parseBody(RemoteHostSchema, { ...(req.body as object), id });
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
@@ -189,7 +337,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.delete('/api/remote-hosts/:id', async (req): Promise<ApiResponse<{ id: string }>> => {
|
||||
app.delete('/api/remote-hosts/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
const cases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (cases.some((item) => item.hostId === id)) {
|
||||
@@ -204,7 +354,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
});
|
||||
|
||||
app.post('/api/cases/remote-link', async (req): Promise<ApiResponse<{ case: unknown }>> => {
|
||||
const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const };
|
||||
const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const, owner: ownerFor(req) };
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
const host = hosts.find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
@@ -214,7 +364,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
if (
|
||||
remoteCases.some((item) => item.name === remoteCase.name) ||
|
||||
linkedCases[remoteCase.name] ||
|
||||
existsSync(join(CASES_DIR, remoteCase.name))
|
||||
existsSync(join(resolveCasesDir(getAuthUser(req)), remoteCase.name))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
@@ -232,8 +382,393 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return { success: true, data: { case: remoteCase } };
|
||||
});
|
||||
|
||||
// ========== Docker hosts + docker cases (COD-Docker) ==========
|
||||
|
||||
// Hosts are machine-level infra config (images/mounts/env): non-admins get an empty
|
||||
// list in multi-user mode, matching the admin-only write side. No-op otherwise.
|
||||
app.get('/api/docker-hosts', async (req) =>
|
||||
isMultiUserMode() && !isAdmin(req) ? [] : readDockerHosts(CODEMAN_CONFIG_DIR)
|
||||
);
|
||||
|
||||
app.post('/api/docker-hosts', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const host = parseBody(DockerHostSchema, req.body);
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
if (hosts.some((item) => item.id === host.id)) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Docker host already exists');
|
||||
}
|
||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.put('/api/docker-hosts/:id', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
const host = parseBody(DockerHostSchema, { ...(req.body as object), id });
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const index = hosts.findIndex((item) => item.id === id);
|
||||
if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const next = [...hosts];
|
||||
next[index] = host;
|
||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, next);
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.delete('/api/docker-hosts/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
const cases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (cases.some((item) => item.hostId === id)) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Docker host is still used by docker cases');
|
||||
}
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
await writeDockerHosts(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
hosts.filter((item) => item.id !== id)
|
||||
);
|
||||
return { success: true, data: { id } };
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/api/cases/docker-link',
|
||||
async (
|
||||
req
|
||||
): Promise<
|
||||
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }>
|
||||
> => {
|
||||
const dockerCase = {
|
||||
...parseBody(DockerCaseLinkSchema, req.body),
|
||||
type: 'docker' as const,
|
||||
owner: ownerFor(req),
|
||||
};
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const host = hosts.find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
dockerCases.some((item) => item.name === dockerCase.name) ||
|
||||
linkedCases[dockerCase.name] ||
|
||||
existsSync(join(resolveCasesDir(getAuthUser(req)), dockerCase.name))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// Confine the bind-mounted workspace to the caller's own space BEFORE creating it
|
||||
// (also removes the arbitrary-dir-creation primitive). No-op for admins/single-user.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), dockerCase.hostWorkspacePath)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'hostWorkspacePath is outside your workspace');
|
||||
}
|
||||
|
||||
// The workspace is a REAL host directory (bind-mounted into the container), so
|
||||
// create it now if missing. Scaffolding (.claude/settings.local.json + CLAUDE.md)
|
||||
// is written by quick-start on first launch, matching local-case behaviour.
|
||||
if (!existsSync(dockerCase.hostWorkspacePath)) {
|
||||
try {
|
||||
mkdirSync(dockerCase.hostWorkspacePath, { recursive: true });
|
||||
} catch (err) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`Could not create workspace: ${getErrorMessage(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Courtesy validation: docker daemon must be reachable. The base image is
|
||||
// auto-built on first use (default image) rather than being a link-time
|
||||
// blocker, so a missing image kicks off a background build instead of erroring.
|
||||
const availability = await checkDockerAvailable(host.engine);
|
||||
if (!availability.ok) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
availability.error || 'docker daemon is not available'
|
||||
);
|
||||
}
|
||||
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), dockerCase.name);
|
||||
if (!imageGate.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
|
||||
}
|
||||
|
||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
|
||||
ctx.broadcast(SseEvent.CaseLinked, {
|
||||
name: dockerCase.name,
|
||||
path: dockerCase.hostWorkspacePath,
|
||||
type: 'docker',
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
case: dockerCase,
|
||||
capsEnforced: availability.capsEnforced,
|
||||
isDesktop: availability.isDesktop,
|
||||
imageBuilding: imageGate.imageBuilding,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// One-click "Run in Docker": create a NORMAL case (folder in CASES_DIR, scaffolded)
|
||||
// AND link it to a hardened container with default settings, auto-provisioning a
|
||||
// shared `default` docker host so the user never touches host/image/network fields.
|
||||
app.post(
|
||||
'/api/cases/docker-quickcreate',
|
||||
async (
|
||||
req
|
||||
): Promise<
|
||||
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }>
|
||||
> => {
|
||||
const body = parseBody(DockerQuickCreateSchema, req.body);
|
||||
const { name, description } = body;
|
||||
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
|
||||
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
|
||||
// Collision across every case kind.
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
existsSync(casePath) ||
|
||||
dockerCases.some((item) => item.name === name) ||
|
||||
remoteCases.some((item) => item.name === name) ||
|
||||
linkedCases[name]
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// The checkbox alone (no overrides) uses the shared `default` host; any tweaked
|
||||
// setting gets a dedicated per-case host so it never mutates the shared default.
|
||||
const hasOverrides = !!(
|
||||
body.image ||
|
||||
body.network ||
|
||||
body.networkName ||
|
||||
body.memory ||
|
||||
body.cpus ||
|
||||
body.gpus ||
|
||||
body.mountCredentials !== undefined
|
||||
);
|
||||
const resources: { memory?: string; cpus?: string } = {};
|
||||
if (body.memory) resources.memory = body.memory;
|
||||
if (body.cpus) resources.cpus = body.cpus;
|
||||
const desiredHost: DockerHost = {
|
||||
id: hasOverrides ? `q-${name}` : DEFAULT_DOCKER_HOST_ID,
|
||||
label: hasOverrides ? `Case: ${name}` : 'Default',
|
||||
image: body.image || DEFAULT_AGENT_IMAGE,
|
||||
network: body.network || 'bridge',
|
||||
...(body.networkName ? { networkName: body.networkName } : {}),
|
||||
...(Object.keys(resources).length ? { resources } : {}),
|
||||
...(body.gpus ? { gpus: body.gpus } : {}),
|
||||
mountCredentials: body.mountCredentials ?? true,
|
||||
resumeOnStart: true,
|
||||
hooksEnabled: true,
|
||||
};
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const existing = hosts.find((item) => item.id === desiredHost.id);
|
||||
// Reuse the shared default if present; create/refresh a per-case host for overrides.
|
||||
const host = existing && !hasOverrides ? existing : desiredHost;
|
||||
if (!existing) {
|
||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, desiredHost]);
|
||||
} else if (hasOverrides) {
|
||||
await writeDockerHosts(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
hosts.map((h) => (h.id === desiredHost.id ? desiredHost : h))
|
||||
);
|
||||
}
|
||||
|
||||
// Probe the daemon BEFORE scaffolding so a missing docker surfaces a clear
|
||||
// error instead of leaving an orphaned case folder. The base image is NOT a
|
||||
// blocker: a missing default image auto-builds in the background on first use.
|
||||
const availability = await checkDockerAvailable(host.engine);
|
||||
if (!availability.ok) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
availability.error || 'docker daemon is not available'
|
||||
);
|
||||
}
|
||||
const dockerCase = {
|
||||
name,
|
||||
type: 'docker' as const,
|
||||
hostId: host.id,
|
||||
hostWorkspacePath: casePath,
|
||||
owner: ownerFor(req),
|
||||
};
|
||||
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), name);
|
||||
if (!imageGate.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
|
||||
}
|
||||
|
||||
// Scaffold the case folder exactly like a normal case.
|
||||
try {
|
||||
mkdirSync(casePath, { recursive: true });
|
||||
mkdirSync(join(casePath, 'src'), { recursive: true });
|
||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||
writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, description || '', templatePath));
|
||||
await writeHooksConfig(casePath);
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
|
||||
ctx.broadcast(SseEvent.CaseLinked, { name, path: casePath, type: 'docker' });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
case: dockerCase,
|
||||
capsEnforced: availability.capsEnforced,
|
||||
isDesktop: availability.isDesktop,
|
||||
imageBuilding: imageGate.imageBuilding,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ========== Docker export / import ==========
|
||||
|
||||
// Export a docker case to a portable bundle. Runs in the BACKGROUND (a full image
|
||||
// save can take minutes) and broadcasts docker:exportComplete / docker:exportFailed.
|
||||
app.post('/api/docker-cases/:name/export', async (req): Promise<ApiResponse<{ started: true; bundle: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const { mode = 'full' } = parseBody(DockerExportSchema, req.body ?? {});
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
|
||||
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
if (mode === 'full' && !sessionDocker.mountCredentials) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'full-image export is refused for a sealed container (its in-container login would ride the committed layer). Use a workspace-only export.'
|
||||
);
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const bundle = exportBundleName(name, timestamp, mode);
|
||||
// Fire-and-forget: the client watches for the SSE completion event.
|
||||
void exportDockerCase({
|
||||
docker: sessionDocker,
|
||||
caseName: name,
|
||||
timestamp,
|
||||
exportsDir: DOCKER_EXPORTS_DIR,
|
||||
mode,
|
||||
codemanVersion: APP_VERSION,
|
||||
})
|
||||
.then((result) => {
|
||||
ctx.broadcast(SseEvent.DockerExportComplete, {
|
||||
name,
|
||||
bundle: basename(result.bundlePath),
|
||||
sizeBytes: result.sizeBytes,
|
||||
mode,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
ctx.broadcast(SseEvent.DockerExportFailed, { name, mode, error: getErrorMessage(err) });
|
||||
});
|
||||
|
||||
return { success: true, data: { started: true, bundle } };
|
||||
});
|
||||
|
||||
app.get('/api/docker-exports', async (): Promise<ApiResponse<{ exports: unknown[] }>> => {
|
||||
return { success: true, data: { exports: await listDockerExports(DOCKER_EXPORTS_DIR) } };
|
||||
});
|
||||
|
||||
// Download an export bundle (filename resolved WITHIN the exports dir — no traversal).
|
||||
app.get('/api/docker-exports/:filename', async (req, reply) => {
|
||||
const { filename } = req.params as { filename: string };
|
||||
if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid bundle filename');
|
||||
}
|
||||
const full = join(DOCKER_EXPORTS_DIR, filename);
|
||||
if (!existsSync(full)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Export not found');
|
||||
reply.header('Content-Type', 'application/gzip');
|
||||
reply.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
return reply.send(createReadStream(full));
|
||||
});
|
||||
|
||||
app.delete('/api/docker-exports/:filename', async (req): Promise<ApiResponse<{ filename: string }>> => {
|
||||
const { filename } = req.params as { filename: string };
|
||||
if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid bundle filename');
|
||||
}
|
||||
const full = join(DOCKER_EXPORTS_DIR, filename);
|
||||
if (!existsSync(full)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Export not found');
|
||||
await fs.rm(full, { force: true });
|
||||
return { success: true, data: { filename } };
|
||||
});
|
||||
|
||||
// Import a bundle (already present in the exports dir) into a NEW docker case.
|
||||
app.post('/api/docker-cases/import', async (req): Promise<ApiResponse<{ case: unknown }>> => {
|
||||
const { bundle, newCaseName, destWorkspacePath } = parseBody(DockerImportSchema, req.body);
|
||||
const bundlePath = join(DOCKER_EXPORTS_DIR, bundle);
|
||||
if (!existsSync(bundlePath)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Bundle not found in exports dir');
|
||||
|
||||
// Name-collision guard across ALL case kinds.
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
dockerCases.some((item) => item.name === newCaseName) ||
|
||||
linkedCases[newCaseName] ||
|
||||
existsSync(join(resolveCasesDir(getAuthUser(req)), newCaseName))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// Import extracts a tar into destWorkspacePath (later becomes Session.workingDir):
|
||||
// confine it to the caller's own space. No-op for admins/single-user.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), destWorkspacePath)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'destWorkspacePath is outside your workspace');
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
let result;
|
||||
try {
|
||||
result = await importDockerBundle({ bundlePath, destWorkspace: destWorkspacePath, engine: 'docker', timestamp });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
// Create a dedicated docker host pointing at the quarantined imported image
|
||||
// (full mode) or the manifest's base image (workspace-only).
|
||||
const hostId = `imported-${newCaseName}`;
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
if (!hosts.some((h) => h.id === hostId)) {
|
||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, [
|
||||
...hosts,
|
||||
{
|
||||
id: hostId,
|
||||
label: `Imported: ${newCaseName}`,
|
||||
engine: result.manifest.engine,
|
||||
image: result.importedImage ?? result.manifest.image,
|
||||
network: (['bridge', 'none', 'custom'].includes(result.manifest.network)
|
||||
? result.manifest.network
|
||||
: 'bridge') as 'bridge' | 'none' | 'custom',
|
||||
},
|
||||
]);
|
||||
}
|
||||
const newCase = {
|
||||
name: newCaseName,
|
||||
type: 'docker' as const,
|
||||
hostId,
|
||||
hostWorkspacePath: destWorkspacePath,
|
||||
containerWorkdir: result.manifest.containerWorkdir,
|
||||
owner: ownerFor(req),
|
||||
};
|
||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, newCase]);
|
||||
ctx.broadcast(SseEvent.DockerImportComplete, { name: newCaseName, path: destWorkspacePath, type: 'docker' });
|
||||
return { success: true, data: { case: newCase } };
|
||||
});
|
||||
|
||||
// Link an existing folder as a case
|
||||
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
app.post('/api/cases/link', async (req, reply): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
// Linking writes an arbitrary absolute path into the shared ownerless registry:
|
||||
// admin-only in multi-user mode (mirrors host CRUD + the admin-only GET listing).
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Expand ~ to home directory
|
||||
@@ -245,7 +780,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
|
||||
// Check if case name already exists in CASES_DIR
|
||||
const casePath = join(CASES_DIR, name);
|
||||
const casePath = join(resolveCasesDir(getAuthUser(req)), name);
|
||||
if (existsSync(casePath)) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
|
||||
}
|
||||
@@ -280,24 +815,56 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
app.delete('/api/cases/:name', async (req): Promise<ApiResponse<{ name: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const user = getAuthUser(req);
|
||||
|
||||
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||
if (!validatePathWithinBase(name, resolveCasesDir(user))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Fold ownership INTO the match (don't early-return): a non-owned same-named remote/
|
||||
// docker case is skipped so control falls through to the caller's own local delete.
|
||||
// canAccessOwned is all-true for admins/single-user, so flag-OFF stays byte-identical.
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (remoteCases.some((item) => item.name === name)) {
|
||||
if (remoteCases.some((item) => item.name === name && canAccessOwned(user, item.owner))) {
|
||||
await writeRemoteCases(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
remoteCases.filter((item) => item.name !== name)
|
||||
remoteCases.filter((item) => !(item.name === name && canAccessOwned(user, item.owner)))
|
||||
);
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
|
||||
return { success: true, data: { name } };
|
||||
}
|
||||
|
||||
// Check linked cases first — unlink only, don't delete the actual directory
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
const dockerCase = dockerCases.find((item) => item.name === name && canAccessOwned(user, item.owner));
|
||||
if (dockerCase) {
|
||||
await writeDockerCases(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
dockerCases.filter((item) => item !== dockerCase)
|
||||
);
|
||||
// Best-effort `docker rm -f` the per-case container (case-delete is the
|
||||
// explicit teardown that removes it; the bind-mounted workspace survives).
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (host) {
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
try {
|
||||
exec(buildDockerRemoveCommand(sessionDocker), { timeout: 15_000 }, () => {});
|
||||
} catch {
|
||||
/* best-effort — never blocks the unlink */
|
||||
}
|
||||
// Remove the per-container claude-config seed file (account metadata copy).
|
||||
await fs
|
||||
.rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' });
|
||||
return { success: true, data: { name } };
|
||||
}
|
||||
|
||||
// Check linked cases first — unlink only, don't delete the actual directory.
|
||||
// Linked cases carry no owner (admin-only WRITE in multi-user mode), so a non-admin
|
||||
// must not unlink one either; skip so control falls through to their local delete.
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name]) {
|
||||
if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req))) {
|
||||
delete linkedCases[name];
|
||||
try {
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
|
||||
@@ -309,7 +876,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
|
||||
// Case in CASES_DIR — delete the entire directory
|
||||
const casePath = join(CASES_DIR, name);
|
||||
const casePath = join(resolveCasesDir(getAuthUser(req)), name);
|
||||
if (!existsSync(casePath)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
|
||||
}
|
||||
@@ -351,12 +918,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
app.get('/api/cases/:name', async (req) => {
|
||||
const { name } = req.params as { name: string };
|
||||
|
||||
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||
if (!validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Fold ownership INTO the match (don't early-return): a non-owned same-named remote/
|
||||
// docker case is skipped so control falls through to the caller's own LOCAL case
|
||||
// (remote/docker names are globally unique, local names per-user). No metadata is
|
||||
// disclosed for a foreign case. canAccessOwned is allow-all for admins/single-user.
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCase = remoteCases.find((item) => item.name === name);
|
||||
const remoteCase = remoteCases.find((item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner));
|
||||
if (remoteCase) {
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
@@ -374,13 +945,35 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
};
|
||||
}
|
||||
|
||||
const casePath = await resolveCasePath(name);
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
|
||||
(item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner)
|
||||
);
|
||||
if (dockerCase) {
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
|
||||
return {
|
||||
name,
|
||||
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
|
||||
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||
location: 'docker',
|
||||
docker: {
|
||||
hostId: host.id,
|
||||
container,
|
||||
image: host.image,
|
||||
path: dockerCase.hostWorkspacePath,
|
||||
network: host.network ?? 'bridge',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const casePath = await resolveCasePath(name, getAuthUser(req));
|
||||
|
||||
if (!existsSync(casePath)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
|
||||
}
|
||||
|
||||
const linked = casePath !== join(CASES_DIR, name);
|
||||
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
|
||||
return {
|
||||
name,
|
||||
path: casePath,
|
||||
@@ -393,12 +986,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
app.get('/api/cases/:name/fix-plan', async (req) => {
|
||||
const { name } = req.params as { name: string };
|
||||
|
||||
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||
if (!validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Get case path (check linked cases first, then CASES_DIR)
|
||||
const casePath = await resolveCasePath(name);
|
||||
const casePath = await resolveCasePath(name, getAuthUser(req));
|
||||
|
||||
const fixPlanPath = join(casePath, '@fix_plan.md');
|
||||
|
||||
@@ -498,11 +1091,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
|
||||
const { caseName } = req.params as { caseName: string };
|
||||
if (!validatePathWithinBase(caseName, CASES_DIR)) {
|
||||
if (!validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
const casePath = await resolveCasePath(caseName);
|
||||
const casePath = await resolveCasePath(caseName, getAuthUser(req));
|
||||
|
||||
const wizardDir = join(casePath, 'ralph-wizard');
|
||||
|
||||
@@ -541,7 +1134,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// Cache disabled to ensure fresh prompts when starting new plan generations
|
||||
app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
|
||||
const { caseName, filePath } = req.params as { caseName: string; filePath: string };
|
||||
if (!validatePathWithinBase(caseName, CASES_DIR)) {
|
||||
if (!validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
@@ -550,7 +1143,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
reply.header('Pragma', 'no-cache');
|
||||
reply.header('Expires', '0');
|
||||
|
||||
const casePath = await resolveCasePath(caseName);
|
||||
const casePath = await resolveCasePath(caseName, getAuthUser(req));
|
||||
|
||||
const wizardDir = join(casePath, 'ralph-wizard');
|
||||
|
||||
|
||||
@@ -5,19 +5,29 @@
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort } from '../ports/index.js';
|
||||
import type { EventPort, SessionPort } from '../ports/index.js';
|
||||
import { getAuthUser, canAccessOwned } from '../route-helpers.js';
|
||||
import { createErrorResponse, ApiErrorCode } from '../../types.js';
|
||||
|
||||
export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort): void {
|
||||
export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort & SessionPort): void {
|
||||
app.post('/api/clipboard', async (req) => {
|
||||
const body = req.body as { text?: string; sessionId?: string };
|
||||
const text = body?.text;
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing or empty "text" field');
|
||||
}
|
||||
// Multi-user: a supplied sessionId must belong to the caller — never let a
|
||||
// client target another user's session (no-op in single-user).
|
||||
if (body.sessionId && !canAccessOwned(getAuthUser(req), ctx.sessions.get(body.sessionId)?.owner)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Cannot target another user session');
|
||||
}
|
||||
ctx.broadcast(SseEvent.ClipboardWrite, {
|
||||
text,
|
||||
sessionId: body.sessionId ?? null,
|
||||
// Stamp the trusted caller identity so deriveSseHint routes this write to the
|
||||
// caller's own tabs only (multi-user). Undefined in single-user → JSON drops
|
||||
// the field and delivery stays global to that one user's browsers.
|
||||
callerUsername: req.authUser?.username,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return {};
|
||||
|
||||
@@ -9,33 +9,77 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import { canAccessOwned, getAuthUser, isWorkingDirAllowed, ownerFor, parseBody } from '../route-helpers.js';
|
||||
import { canUsernameRunPrivilegedCommands } from '../../user-store.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { CronJob } from '../../types/cron.js';
|
||||
import type { CronPort } from '../ports/index.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
// A job the caller may see/act on (own, or admin/single-user).
|
||||
const canTouch = (req: FastifyRequest, job: CronJob | null | undefined): job is CronJob =>
|
||||
!!job && canAccessOwned(getAuthUser(req), job.owner);
|
||||
|
||||
// ── Jobs ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/cron/jobs', async () => {
|
||||
return ctx.cron.listJobs();
|
||||
app.get('/api/cron/jobs', async (req) => {
|
||||
const jobs = ctx.cron.listJobs();
|
||||
if (!isMultiUserMode()) return jobs;
|
||||
const user = getAuthUser(req);
|
||||
if (user.role === 'admin') return jobs;
|
||||
return (jobs as CronJob[]).filter((j) => canAccessOwned(user, j.owner));
|
||||
});
|
||||
|
||||
app.post('/api/cron/jobs', async (req) => {
|
||||
// No custom errorMessage: surface the schema's field-specific messages
|
||||
// (e.g. "runAt is required for a one-time schedule").
|
||||
const body = parseBody(CronJobSchema, req.body);
|
||||
return { job: ctx.cron.createJob(body) };
|
||||
// Section 6.2: confine the job's workingDir to the owner's case space (mirrors
|
||||
// POST /api/sessions). No-op allow-all for admins/single-user. workingDir is
|
||||
// required by CronJobSchema so it is always present here.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), body.workingDir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||||
}
|
||||
// Section 6.3: shell mode / a launchCommand is arbitrary host-account execution.
|
||||
// Resolve the owner's grant from the store (AuthUser.role alone can't tell a GRANTED
|
||||
// regular user from a plain one); mirrors session-routes + the cron fire-time re-check.
|
||||
if (
|
||||
(body.agentType === 'shell' || body.launchCommand) &&
|
||||
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.FORBIDDEN,
|
||||
'Shell/launchCommand cron jobs require the can-bypass-permissions grant'
|
||||
);
|
||||
}
|
||||
return { job: ctx.cron.createJob(body, ownerFor(req)) };
|
||||
});
|
||||
|
||||
app.get('/api/cron/jobs/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const job = ctx.cron.getJob(id);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
if (!canTouch(req, job)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
return job;
|
||||
});
|
||||
|
||||
app.put('/api/cron/jobs/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
const body = parseBody(CronJobUpdateSchema, req.body);
|
||||
// Section 6.2: the update body is partial, so only confine when workingDir is set.
|
||||
if (body.workingDir !== undefined && !isWorkingDirAllowed(getAuthUser(req), body.workingDir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||||
}
|
||||
if (
|
||||
(body.agentType === 'shell' || body.launchCommand) &&
|
||||
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.FORBIDDEN,
|
||||
'Shell/launchCommand cron jobs require the can-bypass-permissions grant'
|
||||
);
|
||||
}
|
||||
const job = ctx.cron.updateJob(id, body);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
return { job };
|
||||
@@ -43,7 +87,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
|
||||
app.delete('/api/cron/jobs/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!ctx.cron.deleteJob(id)) {
|
||||
if (!canTouch(req, ctx.cron.getJob(id)) || !ctx.cron.deleteJob(id)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
}
|
||||
return {};
|
||||
@@ -51,6 +95,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
|
||||
app.put('/api/cron/jobs/:id/enabled', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
const { enabled } = parseBody(CronJobEnabledSchema, req.body, 'Invalid request body');
|
||||
const job = ctx.cron.setEnabled(id, enabled);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
@@ -62,7 +107,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
app.post('/api/cron/jobs/:id/run', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const job = ctx.cron.getJob(id);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
if (!canTouch(req, job)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
const run = await ctx.cron.runNow(id);
|
||||
return { run, activeAgents: ctx.cron.countActiveAgents(job.agentType, job.id) };
|
||||
});
|
||||
@@ -71,10 +116,22 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
|
||||
app.get('/api/cron/jobs/:id/runs', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Owner-gate like every other :id handler so a foreign job's run history (session
|
||||
// ids, names, deep links) isn't leaked; NOT_FOUND avoids disclosing existence.
|
||||
if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
return ctx.cron.listRuns(id);
|
||||
});
|
||||
|
||||
app.get('/api/cron/runs', async () => {
|
||||
return ctx.cron.listRuns();
|
||||
app.get('/api/cron/runs', async (req) => {
|
||||
const runs = ctx.cron.listRuns();
|
||||
if (!isMultiUserMode()) return runs;
|
||||
const user = getAuthUser(req);
|
||||
if (user.role === 'admin') return runs;
|
||||
// Non-admin: keep only runs whose owning job the caller can access (drops runs
|
||||
// whose job is absent from the map — defensive; deleteJob already cascades).
|
||||
const ownerByJobId = new Map<string, string | undefined>(
|
||||
ctx.cron.listJobs().map((j): [string, string | undefined] => [j.id, j.owner])
|
||||
);
|
||||
return runs.filter((run) => canAccessOwned(user, ownerByJobId.get(run.cronJobId)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
||||
import { canAccessOwned, findSessionOrFail, getAuthUser, validateSessionFilePath } from '../route-helpers.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -227,13 +228,17 @@ async function serveThumbnail(reply: FastifyReply, resolvedPath: string, extensi
|
||||
function getKnownSessionWorkingDir(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId: string,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
req: FastifyRequest
|
||||
): string | undefined {
|
||||
// Multi-user: a non-admin may only reach their OWN session's files. A foreign
|
||||
// (or missing) session is reported identically as 404 so existence isn't leaked.
|
||||
const user = getAuthUser(req);
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession) return liveSession.workingDir;
|
||||
if (liveSession && canAccessOwned(user, liveSession.owner)) return liveSession.workingDir;
|
||||
|
||||
const stored = ctx.store.getSession(sessionId);
|
||||
if (stored) return stored.workingDir;
|
||||
if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) return stored.workingDir;
|
||||
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
|
||||
return undefined;
|
||||
@@ -261,10 +266,13 @@ function appendDownloadFlag(url: string): string {
|
||||
|
||||
function getSessionAttachmentHistory(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId: string
|
||||
sessionId: string,
|
||||
req: FastifyRequest
|
||||
): { workingDir: string; history: SessionAttachmentHistoryItem[] } | undefined {
|
||||
const user = getAuthUser(req);
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession) {
|
||||
if (!canAccessOwned(user, liveSession.owner)) return undefined;
|
||||
return {
|
||||
workingDir: liveSession.workingDir,
|
||||
history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [],
|
||||
@@ -272,7 +280,7 @@ function getSessionAttachmentHistory(
|
||||
}
|
||||
|
||||
const stored = ctx.store.getSession(sessionId) as StoredSessionWithPrivateAttachmentHistory | undefined;
|
||||
if (!stored) return undefined;
|
||||
if (!stored || !canAccessOwned(user, (stored as { owner?: string }).owner)) return undefined;
|
||||
|
||||
return {
|
||||
workingDir: stored.workingDir,
|
||||
@@ -371,7 +379,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { depth, showHidden } = req.query as { depth?: string; showHidden?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
|
||||
const includeHidden = showHidden === 'true';
|
||||
@@ -495,7 +503,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-content', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!filePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
|
||||
@@ -648,7 +656,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath, download } = req.query as { path?: string; download?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!filePath) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||
@@ -737,7 +745,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// attachment-history list are layered on separately.
|
||||
app.post('/api/sessions/:id/attachments', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const body = (req.body || {}) as { path?: string };
|
||||
|
||||
if (!body.path || typeof body.path !== 'string') {
|
||||
@@ -766,7 +774,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// each entry to current metadata + routes. External entries are re-registered.
|
||||
app.get('/api/sessions/:id/attachments', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const sessionHistory = getSessionAttachmentHistory(ctx, id);
|
||||
const sessionHistory = getSessionAttachmentHistory(ctx, id, req);
|
||||
if (!sessionHistory) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`));
|
||||
return;
|
||||
@@ -794,7 +802,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// size/mtime as the underlying file is rewritten).
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
@@ -831,7 +839,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const { download } = req.query as { download?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir);
|
||||
@@ -850,7 +858,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// convert server-side; PDF/PNG/text redirect to the raw route.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
@@ -870,7 +878,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// Serve a first-page thumbnail of a registered attachment by id.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
@@ -884,7 +892,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
|
||||
if (!filePath) {
|
||||
@@ -912,7 +920,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
|
||||
if (!filePath) {
|
||||
@@ -941,7 +949,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath, lines } = req.query as { path?: string; lines?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!filePath) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||
@@ -1003,7 +1011,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// malformed error envelope instead of wrapping it).
|
||||
app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
|
||||
const { id, streamId } = req.params as { id: string; streamId: string };
|
||||
findSessionOrFail(ctx, id); // Validates session exists
|
||||
findSessionOrFail(ctx, id, req); // Validates session exists
|
||||
const closed = fileStreamManager.closeStream(streamId);
|
||||
return { closed };
|
||||
});
|
||||
@@ -1024,7 +1032,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
return;
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, sessionId);
|
||||
const session = findSessionOrFail(ctx, sessionId, req);
|
||||
const validated = validateSessionFilePath(session.workingDir, filePath);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
|
||||
@@ -19,4 +19,6 @@ export { registerPlanRoutes } from './plan-routes.js';
|
||||
export { registerOrchestratorRoutes } from './orchestrator-routes.js';
|
||||
export { registerClipboardRoutes } from './clipboard-routes.js';
|
||||
export { registerSearchRoutes } from './search-routes.js';
|
||||
export { registerMeRoutes } from './me-routes.js';
|
||||
export { registerAdminRoutes } from './admin-routes.js';
|
||||
export { registerWsRoutes } from './ws-routes.js';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @fileoverview Self-service identity routes (multi-user + single-user).
|
||||
*
|
||||
* - GET /api/me : who am I ({ username, role, mustChangePassword }).
|
||||
* Works in single-user mode too, returning the synthetic
|
||||
* admin so the frontend has one "am I admin" code path.
|
||||
* - POST /api/me/password : change my own password (verifies the current one,
|
||||
* clears mustChangePassword, revokes my OTHER sessions).
|
||||
*
|
||||
* These are the two endpoints a `mustChangePassword` user may still reach (the auth
|
||||
* middleware's lockbox exempts them). See docs/multi-user-plan.md sections 5, 8.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, setPassword, verifyPassword } from '../../user-store.js';
|
||||
import { getAuthUser, revokeUserSessions } from '../route-helpers.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import type { AuthPort } from '../ports/auth-port.js';
|
||||
|
||||
const PasswordChangeSchema = z.object({
|
||||
currentPassword: z.string().min(1).max(1024),
|
||||
newPassword: z.string().min(8).max(1024),
|
||||
});
|
||||
|
||||
export function registerMeRoutes(app: FastifyInstance, ctx: AuthPort): void {
|
||||
// GET /api/me — identity probe. Synthetic admin in single-user mode. The
|
||||
// `multiUser` flag lets the frontend distinguish a single-user admin (no admin
|
||||
// UI) from a real multi-user admin.
|
||||
app.get('/api/me', async (req) => {
|
||||
if (!isMultiUserMode()) {
|
||||
return { success: true, data: { username: 'admin', role: 'admin', mustChangePassword: false, multiUser: false } };
|
||||
}
|
||||
const user = getAuthUser(req);
|
||||
const record = await findUser(user.username);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
mustChangePassword: !!record?.mustChangePassword,
|
||||
multiUser: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/me/password — self-service password change.
|
||||
app.post('/api/me/password', async (req, reply) => {
|
||||
if (!isMultiUserMode()) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Multi-user mode is not enabled');
|
||||
}
|
||||
const parsed = PasswordChangeSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
parsed.error.issues[0]?.message ?? 'New password must be at least 8 characters'
|
||||
);
|
||||
}
|
||||
const { username } = getAuthUser(req);
|
||||
const verified = await verifyPassword(username, parsed.data.currentPassword);
|
||||
if (!verified) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Current password is incorrect');
|
||||
}
|
||||
await setPassword(username, parsed.data.newPassword, { mustChangePassword: false });
|
||||
|
||||
// Revoke this user's OTHER cookie sessions; keep the caller's own session alive
|
||||
// and clear its mustChangePassword snapshot so they aren't re-locked immediately.
|
||||
const currentToken = req.cookies[AUTH_COOKIE_NAME];
|
||||
revokeUserSessions(ctx.authSessions, username, currentToken);
|
||||
if (currentToken) {
|
||||
const rec = ctx.authSessions?.get(currentToken);
|
||||
if (rec) rec.mustChangePassword = false;
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
@@ -6,9 +6,14 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import type { InfraPort } from '../ports/index.js';
|
||||
import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js';
|
||||
import { requireAdmin } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
|
||||
export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
app.get('/api/mux-sessions', async () => {
|
||||
app.get('/api/mux-sessions', async (req, reply) => {
|
||||
// Multi-user: this recovery/debug surface exposes every user's tmux + workdirs → admin-only
|
||||
// (requireAdmin is a no-op allow-all in single-user mode, so flag-off is unchanged).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const sessions = await ctx.mux.getSessionsWithStats();
|
||||
return {
|
||||
sessions,
|
||||
@@ -16,23 +21,31 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/mux-sessions/:sessionId', async (req) => {
|
||||
app.delete('/api/mux-sessions/:sessionId', async (req, reply) => {
|
||||
// Multi-user: killing any tmux session by name is a cross-user destructive action → admin-only.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { sessionId } = req.params as { sessionId: string };
|
||||
const success = await ctx.mux.killSession(sessionId);
|
||||
return { killed: success };
|
||||
});
|
||||
|
||||
app.post('/api/mux-sessions/reconcile', async () => {
|
||||
app.post('/api/mux-sessions/reconcile', async (req, reply) => {
|
||||
// Multi-user: process-wide reconcile → admin-only.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const result = await ctx.mux.reconcileSessions();
|
||||
return result;
|
||||
});
|
||||
|
||||
app.post('/api/mux-sessions/stats/start', async () => {
|
||||
app.post('/api/mux-sessions/stats/start', async (req, reply) => {
|
||||
// Multi-user: process-wide stats collection toggle → admin-only.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
ctx.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||||
return {};
|
||||
});
|
||||
|
||||
app.post('/api/mux-sessions/stats/stop', async () => {
|
||||
app.post('/api/mux-sessions/stats/stop', async (req, reply) => {
|
||||
// Multi-user: process-wide stats collection toggle → admin-only.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
ctx.mux.stopStatsCollection();
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { OrchestratorStartSchema, OrchestratorRejectSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import { parseBody, requireAdmin } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, OrchestratorPort } from '../ports/index.js';
|
||||
|
||||
@@ -79,7 +80,10 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Start
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/start', async (req) => {
|
||||
app.post('/api/orchestrator/start', async (req, reply) => {
|
||||
// Multi-user: the orchestrator is a process-wide singleton with no per-user
|
||||
// isolation → admin-only (requireAdmin is a no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { goal, config } = parseBody(OrchestratorStartSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Initialize loop if needed
|
||||
@@ -115,7 +119,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Approve / Reject Plan
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/approve', async () => {
|
||||
app.post('/api/orchestrator/approve', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
@@ -128,7 +134,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/reject', async (req) => {
|
||||
app.post('/api/orchestrator/reject', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
const { feedback } = parseBody(OrchestratorRejectSchema, req.body, 'Feedback is required');
|
||||
@@ -147,7 +155,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Pause / Resume / Stop
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/pause', async () => {
|
||||
app.post('/api/orchestrator/pause', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
@@ -158,7 +168,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/resume', async () => {
|
||||
app.post('/api/orchestrator/resume', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
@@ -171,7 +183,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/stop', async () => {
|
||||
app.post('/api/orchestrator/stop', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
@@ -186,7 +200,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Status / Plan
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.get('/api/orchestrator/status', async () => {
|
||||
app.get('/api/orchestrator/status', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = ctx.orchestratorLoop;
|
||||
if (!loop) {
|
||||
return { ok: true, state: 'idle', plan: null, stats: null };
|
||||
@@ -198,7 +214,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/orchestrator/plan', async () => {
|
||||
app.get('/api/orchestrator/plan', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = ctx.orchestratorLoop;
|
||||
if (!loop) {
|
||||
return { ok: true, plan: null };
|
||||
@@ -215,7 +233,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Phase Operations
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/phase/:id/skip', async (req) => {
|
||||
app.post('/api/orchestrator/phase/:id/skip', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
@@ -227,7 +247,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/phase/:id/retry', async (req) => {
|
||||
app.post('/api/orchestrator/phase/:id/retry', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
|
||||
@@ -17,7 +17,15 @@ import {
|
||||
PlanTaskUpdateSchema,
|
||||
PlanTaskAddSchema,
|
||||
} from '../schemas.js';
|
||||
import { findSessionOrFail, parseBody, CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
|
||||
import {
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
ownerFor,
|
||||
parseBody,
|
||||
resolveCasesDir,
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
@@ -124,12 +132,19 @@ Return ONLY a JSON array. Each item MUST have:
|
||||
|
||||
NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
// Create temporary session for the AI call using Opus 4.5 for deep reasoning
|
||||
// Create temporary session for the AI call using Opus 4.5 for deep reasoning.
|
||||
// Section 6.3: downgrade a non-granted user's one-shot to a classifier-guarded mode.
|
||||
const planOwner = ownerFor(req);
|
||||
const planClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const planClaudeMode = await resolveClaudeModeForUsername(planClaudeModeConfig.claudeMode, planOwner);
|
||||
const session = new Session({
|
||||
workingDir: process.cwd(),
|
||||
mux: ctx.mux,
|
||||
useMux: false, // No mux needed for one-shot
|
||||
mode: 'claude',
|
||||
claudeMode: planClaudeMode,
|
||||
allowedTools: planClaudeModeConfig.allowedTools,
|
||||
owner: planOwner,
|
||||
});
|
||||
|
||||
// Use configured model for plan generation, falling back to opus
|
||||
@@ -228,7 +243,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Determine output directory for saving wizard results
|
||||
let outputDir: string | undefined;
|
||||
if (caseName) {
|
||||
const casePath = validatePathWithinBase(caseName, CASES_DIR);
|
||||
const casePath = validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)));
|
||||
if (casePath && existsSync(casePath)) {
|
||||
outputDir = join(casePath, 'ralph-wizard');
|
||||
|
||||
@@ -246,7 +261,18 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
|
||||
const detailedModelConfig = await ctx.getModelConfig();
|
||||
const orchestrator = new PlanOrchestrator(ctx.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined);
|
||||
// Section 6.3: resolve the owner's permission mode (mirrors /api/generate-plan above) and
|
||||
// thread it + owner + allowedTools into the orchestrator's internal research/planner one-shots
|
||||
// so a non-granted multi-user user cannot run them under --dangerously-skip-permissions.
|
||||
// In single-user, resolveClaudeModeForUsername returns the global mode = byte-identical.
|
||||
const detailedOwner = ownerFor(req);
|
||||
const detailedClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const detailedClaudeMode = await resolveClaudeModeForUsername(detailedClaudeModeConfig.claudeMode, detailedOwner);
|
||||
const orchestrator = new PlanOrchestrator(ctx.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined, {
|
||||
claudeMode: detailedClaudeMode,
|
||||
owner: detailedOwner,
|
||||
allowedTools: detailedClaudeModeConfig.allowedTools,
|
||||
});
|
||||
|
||||
// Store orchestrator for potential cancellation via API (not on disconnect)
|
||||
// Plan generation continues even if browser disconnects - only explicit cancel stops it
|
||||
@@ -359,7 +385,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => {
|
||||
const { id, taskId } = req.params as { id: string; taskId: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
@@ -385,7 +411,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.post('/api/sessions/:id/plan/checkpoint', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
@@ -401,7 +427,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.get('/api/sessions/:id/plan/history', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
@@ -415,7 +441,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.post('/api/sessions/:id/plan/rollback/:version', async (req) => {
|
||||
const { id, version } = req.params as { id: string; version: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
@@ -435,7 +461,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.post('/api/sessions/:id/plan/task', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
|
||||
@@ -24,6 +24,10 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
userAgent: userAgent ?? req.headers['user-agent'] ?? '',
|
||||
createdAt: Date.now(),
|
||||
pushPreferences: pushPreferences ?? {},
|
||||
// Multi-user: stamp the trusted caller identity so sendPushNotifications can
|
||||
// scope session notifications to the owner (+ admins). Undefined in single-user.
|
||||
username: req.authUser?.username,
|
||||
role: req.authUser?.role,
|
||||
});
|
||||
return { success: true, data: { id: record.id } };
|
||||
});
|
||||
|
||||
@@ -13,13 +13,22 @@ import { Session, isExternalCliMode } from '../../session.js';
|
||||
import { RespawnController } from '../../respawn-controller.js';
|
||||
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
getAuthUser,
|
||||
ownerFor,
|
||||
resolveCasesDir,
|
||||
sessionCapacityMessage,
|
||||
SETTINGS_PATH,
|
||||
findSessionOrFail,
|
||||
parseBody,
|
||||
} from '../route-helpers.js';
|
||||
import { resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { buildRalphLoopPrompt } from '../../prompts/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
||||
|
||||
export function registerRalphRoutes(
|
||||
app: FastifyInstance,
|
||||
@@ -42,7 +51,7 @@ export function registerRalphRoutes(
|
||||
reset?: boolean | 'full';
|
||||
disableAutoEnable?: boolean;
|
||||
};
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
@@ -118,7 +127,7 @@ export function registerRalphRoutes(
|
||||
// Reset circuit breaker for Ralph tracker
|
||||
app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.ralphTracker.resetCircuitBreaker();
|
||||
return {};
|
||||
@@ -127,7 +136,7 @@ export function registerRalphRoutes(
|
||||
// Get Ralph status block and circuit breaker state
|
||||
app.get('/api/sessions/:id/ralph-status', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -147,7 +156,7 @@ export function registerRalphRoutes(
|
||||
// Generate @fix_plan.md content from todos
|
||||
app.get('/api/sessions/:id/fix-plan', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const content = session.ralphTracker.generateFixPlanMarkdown();
|
||||
return {
|
||||
@@ -163,7 +172,7 @@ export function registerRalphRoutes(
|
||||
app.post('/api/sessions/:id/fix-plan/import', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { content } = parseBody(FixPlanImportSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
|
||||
ctx.persistSessionState(session);
|
||||
@@ -180,7 +189,7 @@ export function registerRalphRoutes(
|
||||
// Write @fix_plan.md to session's working directory
|
||||
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -207,7 +216,7 @@ export function registerRalphRoutes(
|
||||
// Read @fix_plan.md from session's working directory and import
|
||||
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -246,7 +255,7 @@ export function registerRalphRoutes(
|
||||
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -271,13 +280,9 @@ export function registerRalphRoutes(
|
||||
|
||||
// Start a Ralph Loop — creates a new session with autonomous cycling
|
||||
app.post('/api/ralph-loop/start', async (req): Promise<ApiResponse> => {
|
||||
// Prevent unbounded session creation
|
||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.SESSION_BUSY,
|
||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`
|
||||
);
|
||||
}
|
||||
const rlOwner = ownerFor(req);
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, rlOwner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||
|
||||
const {
|
||||
caseName,
|
||||
@@ -290,11 +295,13 @@ export function registerRalphRoutes(
|
||||
effort,
|
||||
} = parseBody(RalphLoopStartSchema, req.body);
|
||||
|
||||
const casePath = join(CASES_DIR, caseName);
|
||||
// Multi-user: cases live in the requesting user's space.
|
||||
const rlCasesBase = resolveCasesDir(getAuthUser(req));
|
||||
const casePath = join(rlCasesBase, caseName);
|
||||
|
||||
// Security: Path traversal protection
|
||||
const rlResolvedPath = resolve(casePath);
|
||||
const rlResolvedBase = resolve(CASES_DIR);
|
||||
const rlResolvedBase = resolve(rlCasesBase);
|
||||
const rlRelPath = relative(rlResolvedBase, rlResolvedPath);
|
||||
if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
@@ -324,6 +331,7 @@ export function registerRalphRoutes(
|
||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||
const rlModelConfig = await ctx.getModelConfig();
|
||||
const rlClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const rlClaudeMode = await resolveClaudeModeForUsername(rlClaudeModeConfig.claudeMode, rlOwner);
|
||||
const session = new Session({
|
||||
workingDir: casePath,
|
||||
mux: ctx.mux,
|
||||
@@ -331,10 +339,11 @@ export function registerRalphRoutes(
|
||||
mode: 'claude',
|
||||
niceConfig,
|
||||
model: rlModelConfig?.defaultModel || undefined,
|
||||
claudeMode: rlClaudeModeConfig.claudeMode,
|
||||
claudeMode: rlClaudeMode,
|
||||
allowedTools: rlClaudeModeConfig.allowedTools,
|
||||
envOverrides,
|
||||
effort,
|
||||
owner: rlOwner,
|
||||
});
|
||||
|
||||
// Configure Ralph tracker
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type PersistedRespa
|
||||
import { RespawnController, type RespawnConfig } from '../../respawn-controller.js';
|
||||
import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { findSessionOrFail, autoConfigureRalph, parseBody } from '../route-helpers.js';
|
||||
import { findSessionOrFail, autoConfigureRalph, parseBody, canAccessOwned, getAuthUser } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { isExternalCliMode } from '../../session.js';
|
||||
@@ -46,7 +46,11 @@ export function registerRespawnRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
if (!controller) {
|
||||
// Multi-user: gate on the owner from the same source the data comes from, and
|
||||
// return the existing neutral shape (not 404) when foreign so existence isn't
|
||||
// leaked. canAccessOwned is allow-all in single-user mode → byte-identical.
|
||||
const owner = ctx.sessions.get(id)?.owner ?? ctx.mux.getSession(id)?.owner;
|
||||
if (!controller || !canAccessOwned(getAuthUser(req), owner)) {
|
||||
return { enabled: false, status: null };
|
||||
}
|
||||
|
||||
@@ -60,16 +64,21 @@ export function registerRespawnRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/respawn/config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Multi-user: owner-gate each branch against the source of the data, preserving
|
||||
// the neutral {config:null,active:false} shape when foreign (no existence leak).
|
||||
// canAccessOwned is allow-all in single-user mode → byte-identical, and this keeps
|
||||
// the mux-only pre-config path working (findSessionOrFail would break it).
|
||||
const user = getAuthUser(req);
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
if (controller) {
|
||||
if (controller && canAccessOwned(user, ctx.sessions.get(id)?.owner)) {
|
||||
return { config: controller.getConfig(), active: true };
|
||||
}
|
||||
|
||||
// Return pre-saved config from mux-sessions.json
|
||||
const preConfig = ctx.mux.getSession(id)?.respawnConfig;
|
||||
if (preConfig) {
|
||||
return { config: preConfig, active: false };
|
||||
const mux = ctx.mux.getSession(id);
|
||||
if (mux?.respawnConfig && canAccessOwned(user, mux.owner)) {
|
||||
return { config: mux.respawnConfig, active: false };
|
||||
}
|
||||
|
||||
return { config: null, active: false };
|
||||
@@ -87,7 +96,7 @@ export function registerRespawnRoutes(
|
||||
if (req.body) {
|
||||
body = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Respawn is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
@@ -122,6 +131,9 @@ export function registerRespawnRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/respawn/stop', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Owner-gate before any side effects (matches start/config/enable): a non-owner
|
||||
// gets NOT_FOUND and never reaches stop/delete/clearRespawnConfig/persist.
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
if (!controller) {
|
||||
@@ -144,10 +156,7 @@ export function registerRespawnRoutes(
|
||||
ctx.mux.clearRespawnConfig(id);
|
||||
|
||||
// Update state.json (respawnConfig removed)
|
||||
const session = ctx.sessions.get(id);
|
||||
if (session) {
|
||||
ctx.persistSessionState(session);
|
||||
}
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
ctx.broadcast(SseEvent.RespawnStopped, { sessionId: id });
|
||||
|
||||
@@ -160,7 +169,7 @@ export function registerRespawnRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
// Validate respawn config to prevent arbitrary field injection
|
||||
const config = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
@@ -226,7 +235,7 @@ export function registerRespawnRoutes(
|
||||
respawnConfig?: Partial<RespawnConfig>;
|
||||
durationMinutes?: number;
|
||||
};
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -299,7 +308,7 @@ export function registerRespawnRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = reResult.data as { config?: Partial<RespawnConfig>; durationMinutes?: number };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Respawn is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
|
||||
@@ -7,17 +7,35 @@ import { FastifyInstance } from 'fastify';
|
||||
import { statSync } from 'node:fs';
|
||||
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
|
||||
import { ScheduledRunSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import {
|
||||
parseBody,
|
||||
getAuthUser,
|
||||
ownerFor,
|
||||
isWorkingDirAllowed,
|
||||
canAccessOwned,
|
||||
resolveCasesDir,
|
||||
} from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { SessionPort, EventPort, InfraPort, ScheduledRun } from '../ports/index.js';
|
||||
|
||||
export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & InfraPort): void {
|
||||
app.get('/api/scheduled', async () => {
|
||||
return Array.from(ctx.scheduledRuns.values());
|
||||
app.get('/api/scheduled', async (req) => {
|
||||
// Multi-user: non-admins see only their own runs (no-op in single-user).
|
||||
const user = getAuthUser(req);
|
||||
return Array.from(ctx.scheduledRuns.values()).filter((r) => canAccessOwned(user, r.owner));
|
||||
});
|
||||
|
||||
app.post('/api/scheduled', async (req): Promise<{ run: ScheduledRun } | ApiResponse<never>> => {
|
||||
const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Multi-user: confine the run's workingDir to the caller's own case space.
|
||||
// The spawned Session (--dangerously-skip-permissions by default) trusts this
|
||||
// dir; without confinement a non-admin could point it at another user's files.
|
||||
// No-op for admins / single-user (isWorkingDirAllowed returns true).
|
||||
if (workingDir && !isWorkingDirAllowed(getAuthUser(req), workingDir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is not within your allowed workspace');
|
||||
}
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
if (workingDir) {
|
||||
try {
|
||||
@@ -30,7 +48,11 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
|
||||
}
|
||||
}
|
||||
|
||||
const run = await ctx.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60);
|
||||
// Multi-user: default a missing workingDir to the user's own cases dir rather
|
||||
// than the server's cwd. Single-user keeps process.cwd() (byte-identical).
|
||||
const effectiveWorkingDir = workingDir || (isMultiUserMode() ? resolveCasesDir(getAuthUser(req)) : process.cwd());
|
||||
|
||||
const run = await ctx.startScheduledRun(prompt, effectiveWorkingDir, durationMinutes ?? 60, ownerFor(req));
|
||||
return { run };
|
||||
});
|
||||
|
||||
@@ -38,7 +60,9 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
|
||||
const { id } = req.params as { id: string };
|
||||
const run = ctx.scheduledRuns.get(id);
|
||||
|
||||
if (!run) {
|
||||
// NOT_FOUND (not FORBIDDEN) for a foreign run so existence isn't leaked; no-op
|
||||
// for admins / single-user (canAccessOwned returns true).
|
||||
if (!run || !canAccessOwned(getAuthUser(req), run.owner)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
|
||||
}
|
||||
|
||||
@@ -50,7 +74,8 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
|
||||
const { id } = req.params as { id: string };
|
||||
const run = ctx.scheduledRuns.get(id);
|
||||
|
||||
if (!run) {
|
||||
// Owner-scoped read: a foreign run reads as NOT_FOUND (no-op in single-user).
|
||||
if (!run || !canAccessOwned(getAuthUser(req), run.owner)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import { canAccessOwned, getAuthUser, parseBody } from '../route-helpers.js';
|
||||
import { SearchQuerySchema } from '../schemas.js';
|
||||
import {
|
||||
searchSources,
|
||||
@@ -62,13 +62,14 @@ interface SessionLike {
|
||||
* Harvest the three source arrays from the live in-memory stores. Reads only
|
||||
* bounded, already-loaded data — no disk I/O, no terminal buffers.
|
||||
*/
|
||||
function harvestSources(ctx: SessionPort & InfraPort): SearchSources {
|
||||
function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) => boolean): SearchSources {
|
||||
const sessions: SessionSearchInput[] = [];
|
||||
const events: EventSearchInput[] = [];
|
||||
const files: FileSearchInput[] = [];
|
||||
|
||||
for (const raw of ctx.sessions.values()) {
|
||||
const s = raw as unknown as SessionLike;
|
||||
const s = raw as unknown as SessionLike & { owner?: string };
|
||||
if (canSee && !canSee(s.owner)) continue; // multi-user ownership scope
|
||||
const sessionName = s.name ?? '';
|
||||
const timestamp = s.lastActivityAt ?? s.createdAt ?? 0;
|
||||
|
||||
@@ -96,7 +97,8 @@ function harvestSources(ctx: SessionPort & InfraPort): SearchSources {
|
||||
|
||||
// Events: from the live run-summary trackers, keyed by session id.
|
||||
for (const [sessionId, tracker] of ctx.runSummaryTrackers) {
|
||||
const session = ctx.sessions.get(sessionId) as unknown as SessionLike | undefined;
|
||||
const session = ctx.sessions.get(sessionId) as unknown as (SessionLike & { owner?: string }) | undefined;
|
||||
if (canSee && !canSee(session?.owner)) continue; // multi-user ownership scope
|
||||
const sessionName = session?.name ?? '';
|
||||
const summary = tracker.getSummary();
|
||||
// Newest events are most relevant; cap the per-session harvest.
|
||||
@@ -120,6 +122,8 @@ export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & In
|
||||
app.get('/api/search', async (req) => {
|
||||
// Zod-validate the query. parseBody throws a structured 400 on failure.
|
||||
const { q, types, limit } = parseBody(SearchQuerySchema, req.query);
|
||||
const user = getAuthUser(req);
|
||||
const canSee = (owner?: string) => canAccessOwned(user, owner);
|
||||
|
||||
const allowed: Set<SearchSourceType> | null = types
|
||||
? new Set(
|
||||
@@ -130,7 +134,7 @@ export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & In
|
||||
)
|
||||
: null;
|
||||
|
||||
const sources = harvestSources(ctx);
|
||||
const sources = harvestSources(ctx, canSee);
|
||||
|
||||
// Apply the optional source-type filter before searching so excluded
|
||||
// sources never contribute to (or consume budget in) the result set.
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
getErrorMessage,
|
||||
type ApiResponse,
|
||||
type SessionColor,
|
||||
type CodexConfig,
|
||||
type GeminiConfig,
|
||||
} from '../../types.js';
|
||||
import { Session, isAltScreenStripMode } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -38,13 +40,22 @@ import {
|
||||
} from '../schemas.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
canAccessOwned,
|
||||
CASES_DIR,
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
isWorkingDirAllowed,
|
||||
ownerFor,
|
||||
parseBody,
|
||||
persistAndBroadcastSession,
|
||||
resolveCasesDir,
|
||||
sessionCapacityMessage,
|
||||
SETTINGS_PATH,
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import {
|
||||
writeHooksConfig,
|
||||
@@ -67,13 +78,21 @@ import {
|
||||
type MuxStatInput,
|
||||
} from '../../services/unified-session-service.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
||||
import { RunSummaryTracker } from '../../run-summary.js';
|
||||
|
||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
|
||||
import {
|
||||
checkDockerAvailable,
|
||||
checkDockerTmuxAvailable,
|
||||
ensureAgentBaseImage,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
toSessionDocker,
|
||||
} from '../../docker-hosts.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
@@ -250,6 +269,29 @@ export function _resetPasteRateBuckets(): void {
|
||||
pasteRateBuckets.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
|
||||
* cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`
|
||||
* and Gemini `--approval-mode yolo` disable the safety classifier the non-granted-user
|
||||
* downgrade is meant to keep on, so clamp them for a non-granted owner. buildGeminiCommand
|
||||
* defaults an ABSENT approvalMode to yolo, so the gemini config must be MATERIALIZED
|
||||
* (auto_edit) even when the request sent none. No-op in single-user mode / for a granted
|
||||
* owner (canUsernameRunPrivilegedCommands returns true when !isMultiUserMode()).
|
||||
*/
|
||||
async function clampExternalCliBypassForOwner(
|
||||
owner: string | undefined,
|
||||
codexConfig: CodexConfig | undefined,
|
||||
geminiConfig: GeminiConfig | undefined
|
||||
): Promise<{ codexConfig: CodexConfig | undefined; geminiConfig: GeminiConfig | undefined }> {
|
||||
const granted = await canUsernameRunPrivilegedCommands(owner);
|
||||
if (granted) return { codexConfig, geminiConfig };
|
||||
// Non-granted: force codex bypass off (only meaningful when a config was sent) and
|
||||
// materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
|
||||
const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
|
||||
const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
|
||||
return { codexConfig: clampedCodex, geminiConfig: clampedGemini };
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||
@@ -276,24 +318,39 @@ export function registerSessionRoutes(
|
||||
|
||||
// ========== Session Listing ==========
|
||||
|
||||
app.get('/api/sessions', async () => {
|
||||
return ctx.getLightSessionsState();
|
||||
app.get('/api/sessions', async (req) => {
|
||||
const list = ctx.getLightSessionsState();
|
||||
if (!isMultiUserMode()) return list;
|
||||
const user = getAuthUser(req);
|
||||
if (user.role === 'admin') return list;
|
||||
return (list as Array<{ owner?: string }>).filter((s) => canAccessOwned(user, s.owner));
|
||||
});
|
||||
|
||||
// ========== Session Creation ==========
|
||||
|
||||
app.post('/api/sessions', async (req) => {
|
||||
// Prevent unbounded session creation
|
||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`
|
||||
);
|
||||
}
|
||||
const owner = ownerFor(req);
|
||||
// Global + per-user session cap.
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
|
||||
|
||||
const body = parseBody(CreateSessionSchema, req.body);
|
||||
const workingDir = body.workingDir || process.cwd();
|
||||
|
||||
// Multi-user: shell mode is arbitrary command execution as the host account,
|
||||
// gated behind the same grant as bypass (section 6.3). Resolve the owner's grant
|
||||
// from the store so a GRANTED regular user is not wrongly denied (AuthUser role alone can't tell).
|
||||
if (body.mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
||||
}
|
||||
|
||||
// Multi-user linchpin (section 6.2): a non-admin's workingDir must resolve
|
||||
// inside their own case space. Enforced BEFORE any disk-mutating call below so
|
||||
// a foreign path can never be written into.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), workingDir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||||
}
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
if (body.workingDir) {
|
||||
try {
|
||||
@@ -311,16 +368,18 @@ export function registerSessionRoutes(
|
||||
// For keys the caller is actively setting, strip any stale disk entry a prior
|
||||
// Codeman version may have written. Scope limited to:
|
||||
// - Claude mode (OpenCode/Codex/Gemini don't read .claude/settings.local.json)
|
||||
// - workingDir inside CASES_DIR (Codeman's managed territory — we never mutate
|
||||
// .claude/settings.local.json in arbitrary user repos that POST /api/sessions
|
||||
// can target, because those may have hand-authored values).
|
||||
// - workingDir inside CASES_DIR / the per-user case space (Codeman's managed
|
||||
// territory — we never mutate .claude/settings.local.json in arbitrary user
|
||||
// repos that POST /api/sessions can target, as those may have hand-authored
|
||||
// values).
|
||||
const managedCasesBase = resolveCasesDir(getAuthUser(req));
|
||||
const canStripDisk =
|
||||
body.mode !== 'opencode' &&
|
||||
body.mode !== 'codex' &&
|
||||
body.mode !== 'gemini' &&
|
||||
body.envOverrides &&
|
||||
Object.keys(body.envOverrides).length > 0 &&
|
||||
workingDir.startsWith(CASES_DIR + '/');
|
||||
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
|
||||
if (canStripDisk) {
|
||||
await stripCaseEnvKeys(workingDir, Object.keys(body.envOverrides!));
|
||||
}
|
||||
@@ -429,6 +488,14 @@ export function registerSessionRoutes(
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
// Section 6.3: force non-granted users to a classifier-guarded mode.
|
||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
|
||||
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
const { codexConfig: gatedCodexConfig, geminiConfig: gatedGeminiConfig } = await clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
body.codexConfig,
|
||||
body.geminiConfig
|
||||
);
|
||||
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
@@ -438,15 +505,16 @@ export function registerSessionRoutes(
|
||||
useMux: true,
|
||||
niceConfig: globalNice,
|
||||
model,
|
||||
claudeMode: claudeModeConfig.claudeMode,
|
||||
claudeMode: effectiveClaudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? body.codexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? body.geminiConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: body.envOverrides,
|
||||
effort: body.effort,
|
||||
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
||||
owner,
|
||||
});
|
||||
|
||||
ctx.addSession(session);
|
||||
@@ -467,7 +535,7 @@ export function registerSessionRoutes(
|
||||
app.put('/api/sessions/:id/name', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(SessionNameSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
||||
session.name = name;
|
||||
@@ -482,7 +550,7 @@ export function registerSessionRoutes(
|
||||
app.put('/api/sessions/:id/color', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(SessionColorSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
||||
if (!validColors.includes(body.color)) {
|
||||
@@ -501,18 +569,22 @@ export function registerSessionRoutes(
|
||||
const query = req.query as { killMux?: string };
|
||||
const killMux = query.killMux !== 'false'; // Default to true
|
||||
|
||||
if (!ctx.sessions.has(id)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
// Security: owner-scoped lookup 404s foreign/missing sessions uniformly (no existence leak, no cross-user kill).
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
await ctx.cleanupSession(id, killMux, 'user_delete');
|
||||
await ctx.cleanupSession(session.id, killMux, 'user_delete');
|
||||
return {};
|
||||
});
|
||||
|
||||
// ========== Delete All Sessions ==========
|
||||
|
||||
app.delete('/api/sessions', async (): Promise<ApiResponse<{ killed: number }>> => {
|
||||
const sessionIds = Array.from(ctx.sessions.keys());
|
||||
app.delete('/api/sessions', async (req): Promise<ApiResponse<{ killed: number }>> => {
|
||||
// Security: scope the bulk sweep to sessions the caller can access — a non-admin
|
||||
// must not wipe other users' sessions (canAccessOwned is allow-all for admin/single-user).
|
||||
const user = getAuthUser(req);
|
||||
const sessionIds = Array.from(ctx.sessions.values())
|
||||
.filter((s) => canAccessOwned(user, s.owner))
|
||||
.map((s) => s.id);
|
||||
let killed = 0;
|
||||
|
||||
for (const id of sessionIds) {
|
||||
@@ -529,7 +601,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
|
||||
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
|
||||
@@ -544,7 +616,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/output', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -560,7 +632,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/ralph-state', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -576,7 +648,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/run-summary', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = ctx.runSummaryTrackers.get(id);
|
||||
if (!tracker) {
|
||||
@@ -596,7 +668,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/active-tools', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -615,7 +687,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/run', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { prompt } = parseBody(RunPromptSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -642,7 +714,7 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { clearBreaker } = bodyResult.data;
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -698,7 +770,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/shell', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -731,7 +803,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/input', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { input, useMux, seq, clientId } = parseBody(SessionInputWithLimitSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const inputStr = String(input);
|
||||
if (inputStr.length > MAX_INPUT_LENGTH) {
|
||||
@@ -790,7 +862,7 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const muxName = session.muxName;
|
||||
if (!muxName) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
|
||||
@@ -822,7 +894,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/resize', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.resize(cols, rows, { viewportType, force });
|
||||
return {};
|
||||
@@ -908,7 +980,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/last-response', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Codex sessions don't write to ~/.claude/projects — their transcripts
|
||||
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
|
||||
@@ -1359,7 +1431,7 @@ export function registerSessionRoutes(
|
||||
app.get('/api/sessions/:id/terminal', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const query = req.query as { tail?: string; full?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
|
||||
// the page and wants the whole scroll history back, so we capture the ENTIRE
|
||||
@@ -1492,7 +1564,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.setAutoClear(body.enabled, body.threshold);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -1513,7 +1585,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/auto-compact', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -1535,7 +1607,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/auto-resume', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.setAutoResume(body.enabled);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -1556,7 +1628,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (body.enabled) {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
@@ -1581,7 +1653,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.flickerFilterEnabled = body.enabled;
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -1601,13 +1673,9 @@ export function registerSessionRoutes(
|
||||
// ========== Quick Run ==========
|
||||
|
||||
app.post('/api/run', async (req) => {
|
||||
// Prevent unbounded session creation
|
||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.SESSION_BUSY,
|
||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`
|
||||
);
|
||||
}
|
||||
const runOwner = ownerFor(req);
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, runOwner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||
|
||||
const {
|
||||
prompt,
|
||||
@@ -1620,6 +1688,11 @@ export function registerSessionRoutes(
|
||||
}
|
||||
const dir = workingDir || process.cwd();
|
||||
|
||||
// Multi-user: confine a non-admin's one-shot working dir to their space.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), dir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||||
}
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
if (workingDir) {
|
||||
try {
|
||||
@@ -1632,7 +1705,17 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const session = new Session({ workingDir: dir, envOverrides: runEnvOverrides });
|
||||
// Section 6.3: the one-shot spawn path (runPrompt/buildPromptArgs) respects the
|
||||
// session's claudeMode, so resolve it for the owner (bypass -> auto for non-granted).
|
||||
const runClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const runClaudeMode = await resolveClaudeModeForUsername(runClaudeModeConfig.claudeMode, runOwner);
|
||||
const session = new Session({
|
||||
workingDir: dir,
|
||||
envOverrides: runEnvOverrides,
|
||||
claudeMode: runClaudeMode,
|
||||
allowedTools: runClaudeModeConfig.allowedTools,
|
||||
owner: runOwner,
|
||||
});
|
||||
ctx.addSession(session);
|
||||
ctx.store.incrementSessionsCreated();
|
||||
ctx.persistSessionState(session);
|
||||
@@ -1662,16 +1745,13 @@ export function registerSessionRoutes(
|
||||
// ========== Quick Start ==========
|
||||
|
||||
app.post('/api/quick-start', async (req) => {
|
||||
// Prevent unbounded session creation
|
||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.SESSION_BUSY,
|
||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`
|
||||
);
|
||||
}
|
||||
const owner = ownerFor(req);
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||
|
||||
const {
|
||||
caseName = 'testcase',
|
||||
sessionName,
|
||||
mode = 'claude',
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
@@ -1680,13 +1760,33 @@ export function registerSessionRoutes(
|
||||
effort,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
|
||||
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
|
||||
// Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
|
||||
if (mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
||||
}
|
||||
|
||||
// Resolve the remote case FIRST — the CLI executes on the REMOTE host over ssh,
|
||||
// so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and
|
||||
// would wrongly reject a machine that hasn't got the CLI installed locally.
|
||||
let remote = undefined;
|
||||
let docker = undefined;
|
||||
let dockerResumeId: string | undefined;
|
||||
let casePath: string | null = null;
|
||||
// Security: fold ownership INTO the match (don't early-return) so a NON-OWNED
|
||||
// same-named remote/docker case is skipped and control falls through to the caller's
|
||||
// own LOCAL case — remote/docker names are globally unique but local names are
|
||||
// per-user, so a name collision must not shadow the caller's own case. canAccessOwned
|
||||
// is allow-all for admins/single-user, so flag-OFF stays byte-identical.
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCase = remoteCases.find((item) => item.name === caseName);
|
||||
const remoteCase = remoteCases.find(
|
||||
(item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner)
|
||||
);
|
||||
const dockerCase = remoteCase
|
||||
? undefined
|
||||
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
|
||||
(item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner)
|
||||
);
|
||||
if (remoteCase) {
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
@@ -1718,6 +1818,63 @@ export function registerSessionRoutes(
|
||||
|
||||
casePath = remoteCase.remotePath;
|
||||
remote = toSessionRemote(host, remoteCase);
|
||||
} else if (dockerCase) {
|
||||
// Docker case: the CLI executes INSIDE a container via local tmux + `docker
|
||||
// exec`, so the LOCAL availability gates below don't apply. Mirror the remote
|
||||
// branch's rejection of per-session config that would not cross into the
|
||||
// container (it would silently no-op). (Ownership is enforced in the .find above.)
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
if (
|
||||
(envOverrides && Object.keys(envOverrides).length > 0) ||
|
||||
effort ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'envOverrides, effort, and per-CLI config are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
|
||||
);
|
||||
}
|
||||
|
||||
const availability = await checkDockerAvailable(host.engine);
|
||||
if (!availability.ok) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
availability.error || 'docker daemon is not available'
|
||||
);
|
||||
}
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
// Ensure the base image exists, auto-building the default image on first use so
|
||||
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
|
||||
// this awaits the SAME in-flight build rather than starting a second one.
|
||||
const ensured = await ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
|
||||
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available');
|
||||
}
|
||||
if (ensured.built) {
|
||||
ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image });
|
||||
}
|
||||
// tmux is a hard prerequisite (the in-container tmux makes reconnect durable).
|
||||
// Skip the extra container-run probe for our OWN default image (the baked
|
||||
// Dockerfile always contains tmux); still verify a custom image.
|
||||
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
|
||||
}
|
||||
}
|
||||
|
||||
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
|
||||
docker = sessionDocker;
|
||||
// Seed resume so a relaunch resumes the case's last conversation from the
|
||||
// bind-mounted transcript (decision: resume-on-start default ON).
|
||||
if (sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) {
|
||||
dockerResumeId = dockerCase.lastClaudeSessionId;
|
||||
}
|
||||
} else {
|
||||
// Check OpenCode availability if requested
|
||||
if (mode === 'opencode') {
|
||||
@@ -1762,7 +1919,11 @@ export function registerSessionRoutes(
|
||||
} catch {
|
||||
// File missing or unparseable — treat as empty registry
|
||||
}
|
||||
casePath = linkedCases[caseName] || validatePathWithinBase(caseName, CASES_DIR);
|
||||
// Multi-user: the linked-cases registry is ownerless/global, so only admins may
|
||||
// resolve a name to an arbitrary linked path. A non-admin resolves inside their
|
||||
// OWN case space only (single-user: isAdmin true, so linked cases still honoured).
|
||||
const linked = isAdmin(req) ? linkedCases[caseName] : undefined;
|
||||
casePath = linked || validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)));
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
@@ -1772,8 +1933,18 @@ export function registerSessionRoutes(
|
||||
// for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
|
||||
const resolvedCasePath = casePath as string;
|
||||
|
||||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote cases)
|
||||
if (!remote && !existsSync(resolvedCasePath)) {
|
||||
// Multi-user linchpin (section 6.2): confine the resolved workingDir to the caller's
|
||||
// own case space BEFORE any mkdir/scaffold below creates or mutates it. Applies to
|
||||
// LOCAL and DOCKER cases (docker.hostWorkspacePath is a real host dir the file routes
|
||||
// trust); skipped for REMOTE, whose path is an ssh path that would spuriously fail
|
||||
// realpath confinement. No-op for admins / single-user mode.
|
||||
if (!remote && !isWorkingDirAllowed(getAuthUser(req), resolvedCasePath)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'case path is outside your workspace');
|
||||
}
|
||||
|
||||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
|
||||
// non-docker cases — docker workspaces are scaffolded in their own block below)
|
||||
if (!remote && !docker && !existsSync(resolvedCasePath)) {
|
||||
try {
|
||||
mkdirSync(resolvedCasePath, { recursive: true });
|
||||
mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });
|
||||
@@ -1793,7 +1964,7 @@ export function registerSessionRoutes(
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
} else if (!remote && mode !== 'opencode') {
|
||||
} else if (!remote && !docker && mode !== 'opencode') {
|
||||
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
|
||||
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
|
||||
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
|
||||
@@ -1801,6 +1972,26 @@ export function registerSessionRoutes(
|
||||
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
|
||||
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
||||
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
|
||||
// hook-idle detection fire (decision: wire hooks now). Never clobbers an existing
|
||||
// configured project. Skipped for external CLIs (they use their own systems).
|
||||
if (docker && docker.hooksEnabled && mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
|
||||
try {
|
||||
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
|
||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), generateClaudeMd(caseName, '', templatePath));
|
||||
}
|
||||
if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) {
|
||||
await writeHooksConfig(resolvedCasePath);
|
||||
} else {
|
||||
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal — the session still runs, hooks may be degraded */
|
||||
}
|
||||
}
|
||||
|
||||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||
// see POST /api/sessions for full rationale).
|
||||
if (
|
||||
@@ -1829,28 +2020,39 @@ export function registerSessionRoutes(
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
|
||||
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig } = await clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
codexConfig,
|
||||
geminiConfig
|
||||
);
|
||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
workingDir: resolvedCasePath,
|
||||
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
|
||||
mux: ctx.mux,
|
||||
useMux: true,
|
||||
mode: mode,
|
||||
niceConfig: niceConfig,
|
||||
model: qsModel,
|
||||
claudeMode: qsClaudeModeConfig.claudeMode,
|
||||
claudeMode: qsEffectiveClaudeMode,
|
||||
allowedTools: qsClaudeModeConfig.allowedTools,
|
||||
owner,
|
||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? codexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
|
||||
envOverrides,
|
||||
effort,
|
||||
remote,
|
||||
docker,
|
||||
resumeSessionId: dockerResumeId,
|
||||
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
||||
});
|
||||
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||
// so the initial state already has the phrase configured (only if globally enabled)
|
||||
if (mode === 'claude' && !remote && ctx.store.getConfig().ralphEnabled) {
|
||||
if (mode === 'claude' && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
|
||||
autoConfigureRalph(session, resolvedCasePath, ctx);
|
||||
if (!session.ralphTracker.enabled) {
|
||||
session.ralphTracker.enable();
|
||||
@@ -2182,6 +2384,12 @@ export function registerSessionRoutes(
|
||||
const query = req.query as { projectKey?: string; offset?: string; limit?: string };
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const headBuf = Buffer.alloc(16384);
|
||||
// Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin
|
||||
// must only see history whose decoded workingDir is inside their own case space.
|
||||
// Do NOT trust the caller-supplied projectKey — confine on the decoded path.
|
||||
// No-op for admins / single-user mode.
|
||||
const user = getAuthUser(req);
|
||||
const scopeHistory = isMultiUserMode() && user.role !== 'admin';
|
||||
|
||||
// Single-folder drill-down: when projectKey is provided, scan only that
|
||||
// directory, bypass the 50-cap, and honor offset/limit pagination.
|
||||
@@ -2193,13 +2401,15 @@ export function registerSessionRoutes(
|
||||
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
|
||||
const projPath = join(projectsDir, query.projectKey);
|
||||
const all = await scanProjectDir(projPath, query.projectKey, headBuf);
|
||||
let all = await scanProjectDir(projPath, query.projectKey, headBuf);
|
||||
// Confine to the caller's workspace (a projectKey maps to a single foreign cwd).
|
||||
if (scopeHistory) all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
||||
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
return { sessions: all.slice(offset, offset + limit), total: all.length };
|
||||
}
|
||||
|
||||
// Global overview: scan all projects, return up to 50 most-recent sessions.
|
||||
const results: HistorySession[] = [];
|
||||
let results: HistorySession[] = [];
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
@@ -2211,6 +2421,8 @@ export function registerSessionRoutes(
|
||||
// Projects dir may not exist
|
||||
}
|
||||
|
||||
// Multi-user: drop rows outside the non-admin caller's own case space.
|
||||
if (scopeHistory) results = results.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
||||
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
return { sessions: results.slice(0, 50) };
|
||||
});
|
||||
@@ -2318,7 +2530,37 @@ export function registerSessionRoutes(
|
||||
// Mux stats are optional.
|
||||
}
|
||||
|
||||
const merged = mergeUnifiedSessions({ live, persisted, lifecycle, history, mux });
|
||||
// Multi-user: a non-admin only sees their own sessions; host-wide transcript
|
||||
// history (not tied to an owned session) is admin-only.
|
||||
let sLive = live;
|
||||
let sPersisted = persisted;
|
||||
let sLifecycle = lifecycle;
|
||||
let sHistory = history;
|
||||
const uUser = getAuthUser(req);
|
||||
if (isMultiUserMode() && uUser.role !== 'admin') {
|
||||
const ownedLive = new Set(
|
||||
[...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id)
|
||||
);
|
||||
const stored = ctx.store.getState().sessions as Record<string, { id: string; owner?: string }>;
|
||||
const ownedPersisted = new Set(
|
||||
Object.values(stored)
|
||||
.filter((p) => canAccessOwned(uUser, p.owner))
|
||||
.map((p) => p.id)
|
||||
);
|
||||
const isOwned = (id: string) => ownedLive.has(id) || ownedPersisted.has(id);
|
||||
sLive = live.filter((l) => isOwned(l.id));
|
||||
sPersisted = persisted.filter((p) => isOwned(p.id));
|
||||
sLifecycle = lifecycle.filter((e) => isOwned(e.sessionId));
|
||||
sHistory = [];
|
||||
}
|
||||
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: sLive,
|
||||
persisted: sPersisted,
|
||||
lifecycle: sLifecycle,
|
||||
history: sHistory,
|
||||
mux,
|
||||
});
|
||||
const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
|
||||
const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
|
||||
return filterAndPaginate(merged, {
|
||||
@@ -2377,7 +2619,7 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!req.isMultipart()) {
|
||||
reply.code(400);
|
||||
|
||||
+112
-30
@@ -15,6 +15,9 @@ import { randomBytes } from 'node:crypto';
|
||||
import { dataPath } from '../../config/instance.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
||||
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser } from '../../user-store.js';
|
||||
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
|
||||
import {
|
||||
ConfigUpdateSchema,
|
||||
SettingsUpdateSchema,
|
||||
@@ -136,7 +139,7 @@ export function registerSystemRoutes(
|
||||
|
||||
// ========== Status ==========
|
||||
|
||||
app.get('/api/status', async () => ctx.getLightState());
|
||||
app.get('/api/status', async (req) => ctx.getLightState(req.authUser));
|
||||
|
||||
// ========== Tunnel ==========
|
||||
|
||||
@@ -159,12 +162,19 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/tunnel/qr', async (_req, reply) => {
|
||||
app.get('/api/tunnel/qr', async (req, reply) => {
|
||||
const url = ctx.tunnelManager.getUrl();
|
||||
if (!url) {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running'));
|
||||
}
|
||||
try {
|
||||
if (isMultiUserMode()) {
|
||||
// A rotating global token cannot carry identity — mint a single-use token
|
||||
// bound to the requesting user so the scanned code logs THEM in.
|
||||
const shortCode = ctx.tunnelManager.mintUserToken(getAuthUser(req).username);
|
||||
const svg = await ctx.tunnelManager.getQrSvgForCode(url, shortCode);
|
||||
return { svg, authEnabled: true };
|
||||
}
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
if (authPassword) {
|
||||
// Auth enabled — use cached SVG with embedded short code
|
||||
@@ -188,10 +198,11 @@ export function registerSystemRoutes(
|
||||
|
||||
app.get('/q/:code', async (req, reply) => {
|
||||
const shortCode = (req.params as { code: string }).code;
|
||||
const multiUser = isMultiUserMode();
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
|
||||
// No point if auth isn't enabled — just redirect
|
||||
if (!authPassword) {
|
||||
// No point if auth isn't enabled — just redirect. Multi-user is always "enabled".
|
||||
if (!multiUser && !authPassword) {
|
||||
return reply.redirect('/');
|
||||
}
|
||||
|
||||
@@ -203,12 +214,28 @@ export function registerSystemRoutes(
|
||||
return reply.code(429).send('Too Many Requests');
|
||||
}
|
||||
|
||||
// Validate and atomically consume the token
|
||||
if (!shortCode || !ctx.tunnelManager.consumeToken(shortCode)) {
|
||||
// Validate and atomically consume the token (with any bound identity).
|
||||
const consumed = shortCode ? ctx.tunnelManager.consumeTokenWithIdentity(shortCode) : { ok: false };
|
||||
// In multi-user mode a token MUST carry an identity (an identity-less rotating
|
||||
// token can't create a scoped session), so reject those too.
|
||||
if (!consumed.ok || (multiUser && !consumed.username)) {
|
||||
ctx.qrAuthFailures?.set(clientIp, qrFailures + 1);
|
||||
return reply.code(401).send('Invalid or expired QR code');
|
||||
}
|
||||
|
||||
// Resolve the role for the bound user (disabled/deleted users fail closed).
|
||||
// Carry the bound user's real mustChangePassword flag out of this block so the
|
||||
// minted cookie enforces the lockbox instead of hardcoding false.
|
||||
let identity: { username: string; role: 'admin' | 'user'; mustChangePassword: boolean } | undefined;
|
||||
if (multiUser && consumed.username) {
|
||||
const user = await findUser(consumed.username);
|
||||
if (!user || user.disabled) {
|
||||
ctx.qrAuthFailures?.set(clientIp, qrFailures + 1);
|
||||
return reply.code(401).send('Invalid or expired QR code');
|
||||
}
|
||||
identity = { username: user.username, role: user.role, mustChangePassword: !!user.mustChangePassword };
|
||||
}
|
||||
|
||||
// Issue session cookie (same pattern as Basic Auth success path)
|
||||
const sessionToken = randomBytes(32).toString('hex');
|
||||
const clientUA = req.headers['user-agent'] ?? '';
|
||||
@@ -217,6 +244,9 @@ export function registerSystemRoutes(
|
||||
ua: clientUA,
|
||||
createdAt: Date.now(),
|
||||
method: 'qr',
|
||||
username: identity?.username,
|
||||
role: identity?.role,
|
||||
mustChangePassword: !!identity?.mustChangePassword,
|
||||
});
|
||||
ctx.qrAuthFailures?.delete(clientIp);
|
||||
|
||||
@@ -465,23 +495,52 @@ export function registerSystemRoutes(
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values()).map((session) => ({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
status: session.status,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
}));
|
||||
// Multi-user: scope the digest's aggregated activity to sessions the caller
|
||||
// owns (canAccessOwned is a no-op allow-all for admins/single-user).
|
||||
const user = getAuthUser(req);
|
||||
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values())
|
||||
.filter((session) => canAccessOwned(user, session.owner))
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
status: session.status,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
}));
|
||||
|
||||
const runSummaries = Array.from(ctx.runSummaryTrackers.values()).map((tracker) => tracker.getSummary());
|
||||
// Run-summary trackers are keyed by Codeman session id → filter by that session's owner.
|
||||
const runSummaries = Array.from(ctx.runSummaryTrackers.entries())
|
||||
.filter(([id]) => canAccessOwned(user, ctx.sessions.get(id)?.owner))
|
||||
.map(([, tracker]) => tracker.getSummary());
|
||||
|
||||
// Map each subagent's Claude conversation id back to its owning session so the
|
||||
// recent-subagent lookback is owner-scoped too (fails closed when unattributable).
|
||||
const ownerByClaudeSessionId = new Map<string, string | undefined>();
|
||||
for (const s of ctx.sessions.values()) {
|
||||
if (s.claudeSessionId) ownerByClaudeSessionId.set(s.claudeSessionId, s.owner);
|
||||
}
|
||||
const subagents = subagentWatcher
|
||||
.getRecentSubagents(60)
|
||||
.filter((sa) => canAccessOwned(user, ownerByClaudeSessionId.get(sa.sessionId))) as AwayDigestSubagent[];
|
||||
|
||||
// Multi-user: the lifecycle log and daily token stats carry no owner, so scope them
|
||||
// for a non-admin: keep only lifecycle entries attributable to an owned LIVE session
|
||||
// (fail closed — an ended session's owner can't be resolved, so it is dropped rather
|
||||
// than leaked), and withhold the machine-wide daily token totals entirely (they can't
|
||||
// be per-user attributed, same as globalStats in #29). Admins/single-user keep all
|
||||
// (canAccessOwned allow-all, role check false → byte-identical).
|
||||
const scopedLifecycle = lifecycleEntries.filter((e) =>
|
||||
canAccessOwned(user, ctx.sessions.get(e.sessionId ?? '')?.owner)
|
||||
);
|
||||
const nonAdminScoped = isMultiUserMode() && user.role !== 'admin';
|
||||
const digest = buildAwayDigest({
|
||||
range,
|
||||
lifecycleEntries,
|
||||
lifecycleEntries: scopedLifecycle,
|
||||
runSummaries,
|
||||
sessions,
|
||||
dailyTokenStats: ctx.store.getDailyStats(30),
|
||||
subagents: subagentWatcher.getRecentSubagents(60) as AwayDigestSubagent[],
|
||||
dailyTokenStats: nonAdminScoped ? [] : ctx.store.getDailyStats(30),
|
||||
subagents,
|
||||
now: range.until,
|
||||
});
|
||||
|
||||
@@ -585,7 +644,10 @@ export function registerSystemRoutes(
|
||||
// letting an operator opt in from the browser without setting the env var.
|
||||
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
|
||||
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) {
|
||||
const acknowledged = isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
|
||||
// Multi-user mode makes the tunnel authenticated (every person has their own
|
||||
// credential), so it satisfies the same requirement as CODEMAN_PASSWORD.
|
||||
const acknowledged =
|
||||
isMultiUserMode() || isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
|
||||
if (!acknowledged) {
|
||||
const msg =
|
||||
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
|
||||
@@ -727,7 +789,7 @@ export function registerSystemRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/cpu-limit', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
return {
|
||||
nice: session.niceConfig,
|
||||
};
|
||||
@@ -735,7 +797,7 @@ export function registerSystemRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/cpu-limit', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const body = parseBody(CpuLimitSchema, req.body, 'Invalid request body') as Partial<NiceConfig>;
|
||||
|
||||
@@ -796,7 +858,10 @@ export function registerSystemRoutes(
|
||||
// ========== Workflow Run Monitoring (ultracode) ==========
|
||||
|
||||
// LEFT-pane list: lightweight run summaries (no agents[]).
|
||||
app.get('/api/workflows', async (req) => {
|
||||
app.get('/api/workflows', async (req, reply) => {
|
||||
// Multi-user stopgap: these aggregates are process-wide (no owner concept), so
|
||||
// restrict cross-user reads to admins (no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { minutes } = req.query as { minutes?: string };
|
||||
const runs = minutes
|
||||
? workflowRunWatcher.getRecentRunSummaries(parseInt(minutes, 10))
|
||||
@@ -805,7 +870,9 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
// RIGHT-pane detail: full run incl. agents[] (tokens/toolCalls/state per agent).
|
||||
app.get('/api/workflows/:runId', async (req) => {
|
||||
app.get('/api/workflows/:runId', async (req, reply) => {
|
||||
// Multi-user stopgap: cross-user run detail is admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { runId } = req.params as { runId: string };
|
||||
const run = workflowRunWatcher.getRun(runId);
|
||||
if (!run) {
|
||||
@@ -816,7 +883,10 @@ export function registerSystemRoutes(
|
||||
|
||||
// ========== Subagent Monitoring ==========
|
||||
|
||||
app.get('/api/subagents', async (req) => {
|
||||
app.get('/api/subagents', async (req, reply) => {
|
||||
// Multi-user stopgap: the global subagent list spans all users → admin-only
|
||||
// (no-op allow-all in single-user mode). Per-session variant below stays scoped.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { minutes } = req.query as { minutes?: string };
|
||||
const subagents = minutes
|
||||
? subagentWatcher.getRecentSubagents(parseInt(minutes, 10))
|
||||
@@ -826,12 +896,14 @@ export function registerSystemRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/subagents', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const subagents = subagentWatcher.getSubagentsForSession(session.workingDir);
|
||||
return { success: true, data: subagents };
|
||||
});
|
||||
|
||||
app.get('/api/subagents/:agentId', async (req) => {
|
||||
app.get('/api/subagents/:agentId', async (req, reply) => {
|
||||
// Multi-user stopgap: cross-user subagent metadata is admin-only (no-op single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const info = subagentWatcher.getSubagent(agentId);
|
||||
if (!info) {
|
||||
@@ -840,7 +912,10 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: info };
|
||||
});
|
||||
|
||||
app.get('/api/subagents/:agentId/transcript', async (req) => {
|
||||
app.get('/api/subagents/:agentId/transcript', async (req, reply) => {
|
||||
// Multi-user stopgap: transcript CONTENT of any user's subagent is admin-only
|
||||
// (no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const { limit, format } = req.query as { limit?: string; format?: 'raw' | 'formatted' };
|
||||
const limitNum = limit ? parseInt(limit, 10) : undefined;
|
||||
@@ -854,7 +929,10 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: transcript };
|
||||
});
|
||||
|
||||
app.delete('/api/subagents/:agentId', async (req) => {
|
||||
app.delete('/api/subagents/:agentId', async (req, reply) => {
|
||||
// Multi-user stopgap: killing any user's subagent is a cross-user write → admin-only
|
||||
// (no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const info = subagentWatcher.getSubagent(agentId);
|
||||
if (!info) {
|
||||
@@ -868,12 +946,16 @@ export function registerSystemRoutes(
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Subagent not found or already completed');
|
||||
});
|
||||
|
||||
app.post('/api/subagents/cleanup', async () => {
|
||||
app.post('/api/subagents/cleanup', async (req, reply) => {
|
||||
// Multi-user stopgap: process-wide cleanup affects every user → admin-only (no-op single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const removed = subagentWatcher.cleanupNow();
|
||||
return { success: true, data: { removed, remaining: subagentWatcher.getSubagents().length } };
|
||||
});
|
||||
|
||||
app.delete('/api/subagents', async () => {
|
||||
app.delete('/api/subagents', async (req, reply) => {
|
||||
// Multi-user stopgap: clearing ALL users' subagents is a cross-user write → admin-only (no-op single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const cleared = subagentWatcher.clearAll();
|
||||
return { success: true, data: { cleared } };
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { SessionPort } from '../ports/session-port.js';
|
||||
import { MAX_INPUT_LENGTH } from '../../config/terminal-limits.js';
|
||||
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
|
||||
import { WsConnectionRegistry } from '../ws-connection-registry.js';
|
||||
import { canAccessOwned, getAuthUser } from '../route-helpers.js';
|
||||
|
||||
/** Micro-batch interval for terminal output (ms). Short enough for low latency,
|
||||
* long enough to group Ink's rapid cursor-up redraw sequences into single frames. */
|
||||
@@ -93,6 +94,17 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-user owner gate: writing to this socket injects keystrokes into the
|
||||
// agent, so a non-admin may only attach to their OWN session. The global auth
|
||||
// hook already ran on the upgrade request and decorated req.authUser (an
|
||||
// unauthenticated upgrade never reaches here — the hook 401s the handshake).
|
||||
// findSessionOrFail throws an HTTP-shaped error, so the check is inlined here
|
||||
// as a 4003 close. No-op in single-user mode (canAccessOwned returns true).
|
||||
if (!canAccessOwned(getAuthUser(req), session.owner)) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Structured transport logging — surfaces WS open/close/timeout churn so the
|
||||
// tunnel-flap behavior (COD-134) is observable in the server logs. Fastify is
|
||||
// configured logger:false, so we log via console (→ journald under systemd).
|
||||
|
||||
@@ -359,6 +359,172 @@ export const RemoteCaseLinkSchema = z.object({
|
||||
.regex(NO_SHELL_META, 'Invalid characters in remote path'),
|
||||
});
|
||||
|
||||
// ========== Docker cases ==========
|
||||
//
|
||||
// Docker mode is a location overlay on cases (see docs/docker-cases-plan.md),
|
||||
// the analog of the remote-SSH schemas above. `image`, `hostWorkspacePath`,
|
||||
// `containerWorkdir`, and `container` all reach the outer `bash -c "..."` launch
|
||||
// layer, so they carry NO_SHELL_META (rejects `$`/backtick that survive the
|
||||
// double-quote layer) exactly like remotePath/identityFile. `--privileged` and
|
||||
// any docker-socket mount are structurally unrepresentable (never accepted).
|
||||
|
||||
const DockerResourceLimitsSchema = z
|
||||
.object({
|
||||
memory: z
|
||||
.string()
|
||||
.regex(/^\d+[bkmg]?$/i, 'Memory must be like 512m / 4g')
|
||||
.optional(),
|
||||
cpus: z
|
||||
.string()
|
||||
.regex(/^\d+(\.\d+)?$/, 'CPUs must be a number')
|
||||
.optional(),
|
||||
pidsLimit: z.number().int().positive().max(100000).optional(),
|
||||
nofile: z
|
||||
.string()
|
||||
.regex(/^\d+:\d+$/, 'nofile must be soft:hard')
|
||||
.optional(),
|
||||
shmSize: z
|
||||
.string()
|
||||
.regex(/^\d+[bkmg]?$/i, 'shm-size must be like 256m')
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const DockerHostSchema = z.object({
|
||||
id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
label: z.string().min(1).max(100),
|
||||
engine: z.enum(['docker', 'podman']).optional(),
|
||||
image: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(512)
|
||||
.regex(/^[a-zA-Z0-9][\w./:@-]*$/, 'Invalid image reference')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in image reference'),
|
||||
daemonHost: z.string().max(512).regex(NO_SHELL_META, 'Invalid daemon host').optional(),
|
||||
context: z
|
||||
.string()
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9._-]+$/, 'Invalid docker context')
|
||||
.optional(),
|
||||
network: z.enum(['bridge', 'none', 'custom']).optional(),
|
||||
networkName: z
|
||||
.string()
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid network name')
|
||||
.optional(),
|
||||
resources: DockerResourceLimitsSchema.optional(),
|
||||
gpus: z
|
||||
.string()
|
||||
.max(128)
|
||||
.regex(/^(all|\d+|device=[a-zA-Z0-9,:._-]+)$/, 'GPUs must be all / a count / device=...')
|
||||
.optional(),
|
||||
mountCredentials: z.boolean().optional(),
|
||||
hooksEnabled: z.boolean().optional(),
|
||||
resumeOnStart: z.boolean().optional(),
|
||||
commands: RemoteCommandOverridesSchema, // same shell/claude/opencode/codex/gemini shape
|
||||
extraCreateArgs: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.regex(NO_SHELL_INJECTION, 'Invalid characters in create arg')
|
||||
.refine(noCommandSubstitution, 'Invalid characters in create arg')
|
||||
)
|
||||
.max(32)
|
||||
.optional(),
|
||||
extraExecArgs: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.regex(NO_SHELL_INJECTION, 'Invalid characters in exec arg')
|
||||
.refine(noCommandSubstitution, 'Invalid characters in exec arg')
|
||||
)
|
||||
.max(32)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const DockerCaseLinkSchema = z.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
hostWorkspacePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Workspace path must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in workspace path'),
|
||||
containerWorkdir: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Container workdir must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in container workdir')
|
||||
.optional(),
|
||||
container: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const DockerExportSchema = z.object({
|
||||
mode: z.enum(['full', 'workspace']).optional(),
|
||||
});
|
||||
|
||||
export const DockerImportSchema = z.object({
|
||||
// A bare filename resolved WITHIN the exports dir (never an arbitrary path).
|
||||
bundle: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(300)
|
||||
.regex(/^[a-zA-Z0-9._-]+\.tgz$/, 'Invalid bundle filename'),
|
||||
newCaseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
destWorkspacePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Destination path must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in destination path'),
|
||||
});
|
||||
|
||||
// One-click "Run in Docker" case creation. name/description behave like a normal
|
||||
// case; the docker fields are OPTIONAL overrides of the predefined defaults (the
|
||||
// checkbox alone, with no overrides, uses the shared `default` host).
|
||||
export const DockerQuickCreateSchema = z.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
description: z.string().max(1000).optional(),
|
||||
image: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(512)
|
||||
.regex(/^[a-zA-Z0-9][\w./:@-]*$/, 'Invalid image reference')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in image reference')
|
||||
.optional(),
|
||||
network: z.enum(['bridge', 'none', 'custom']).optional(),
|
||||
networkName: z
|
||||
.string()
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid network name')
|
||||
.optional(),
|
||||
memory: z
|
||||
.string()
|
||||
.regex(/^\d+[bkmg]?$/i, 'Memory must be like 512m / 4g')
|
||||
.optional(),
|
||||
cpus: z
|
||||
.string()
|
||||
.regex(/^\d+(\.\d+)?$/, 'CPUs must be a number')
|
||||
.optional(),
|
||||
gpus: z
|
||||
.string()
|
||||
.max(128)
|
||||
.regex(/^(all|\d+|device=[a-zA-Z0-9,:._-]+)$/, 'GPUs must be all / a count / device=...')
|
||||
.optional(),
|
||||
mountCredentials: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ========== Quick Start ==========
|
||||
|
||||
/**
|
||||
@@ -370,6 +536,9 @@ export const QuickStartSchema = z.object({
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
|
||||
.optional(),
|
||||
/** Display name for the created session tab (e.g. w1-mycase). Cosmetic; the durable
|
||||
* mux/container names derive from the session id, not this. Defaults server-side. */
|
||||
sessionName: z.string().max(128).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
|
||||
+291
-11
@@ -40,7 +40,7 @@ import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from
|
||||
import fs from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { hostname as getHostname } from 'node:os';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
||||
import { getHookSecret } from '../config/hook-secret.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
|
||||
@@ -135,6 +135,8 @@ import { SseEvent } from './sse-events.js';
|
||||
import { getLatestPlanUsage } from './plan-usage-latest.js';
|
||||
import type { ScheduledRun } from './ports/index.js';
|
||||
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
|
||||
import { isMultiUserMode } from '../config/multiuser.js';
|
||||
import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
|
||||
import { installRouteErrorHandler } from './route-error-handler.js';
|
||||
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
|
||||
import {
|
||||
@@ -155,6 +157,8 @@ import {
|
||||
registerSearchRoutes,
|
||||
registerOrchestratorRoutes,
|
||||
registerCronRoutes,
|
||||
registerMeRoutes,
|
||||
registerAdminRoutes,
|
||||
registerWsRoutes,
|
||||
} from './routes/index.js';
|
||||
import { CronService } from '../cron/cron-service.js';
|
||||
@@ -279,6 +283,7 @@ export class WebServer extends EventEmitter {
|
||||
private authFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private userFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||
@@ -288,6 +293,8 @@ export class WebServer extends EventEmitter {
|
||||
private readonly allowUnauthenticatedNetwork: boolean;
|
||||
private _pasteImageGcStop: (() => void) | null = null;
|
||||
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
|
||||
/** Opt-in hooks-only listener on the docker bridge gateway (CODEMAN_DOCKER_BRIDGE_HOOKS). */
|
||||
private _dockerBridgeServer: import('node:http').Server | import('node:https').Server | null = null;
|
||||
private teamWatcherHandlers: {
|
||||
teamCreated: (config: unknown) => void;
|
||||
teamUpdated: (config: unknown) => void;
|
||||
@@ -328,6 +335,7 @@ export class WebServer extends EventEmitter {
|
||||
const session = this.sessions.get(sessionId);
|
||||
return session ? this.getSessionStateWithRespawn(session) : null;
|
||||
},
|
||||
resolveSessionOwner: (sessionId) => this.sessions.get(sessionId)?.owner,
|
||||
},
|
||||
this.cleanup
|
||||
);
|
||||
@@ -678,6 +686,7 @@ export class WebServer extends EventEmitter {
|
||||
this.authFailures = authState.authFailures;
|
||||
this.qrAuthFailures = authState.qrAuthFailures;
|
||||
this.hookSecretFailures = authState.hookSecretFailures;
|
||||
this.userFailures = authState.userFailures;
|
||||
}
|
||||
|
||||
// WebSocket support (terminal I/O — low-latency bidirectional channel)
|
||||
@@ -788,12 +797,12 @@ export class WebServer extends EventEmitter {
|
||||
// Track tunnel clients — cloudflared proxies locally so req.ip is always
|
||||
// 127.0.0.1; detect tunnel traffic via Cf-Connecting-Ip header instead.
|
||||
const isRemote = !!req.headers['cf-connecting-ip'];
|
||||
this.sse.addClient(reply, sessionFilter, isRemote, clientId);
|
||||
this.sse.addClient(reply, sessionFilter, isRemote, clientId, req.authUser);
|
||||
|
||||
// Send initial state
|
||||
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
||||
// Buffers are fetched on-demand when switching tabs
|
||||
this.sse.sendSSE(reply, SseEvent.Init, this.getLightState());
|
||||
this.sse.sendSSE(reply, SseEvent.Init, this.getLightState(req.authUser));
|
||||
// Flush Cloudflare tunnel buffer with padding — ensures the init event
|
||||
// (and any immediately following events) are delivered without proxy delay.
|
||||
this.sse.sendPadding(reply);
|
||||
@@ -897,6 +906,8 @@ export class WebServer extends EventEmitter {
|
||||
registerPlanRoutes(this.app, ctx);
|
||||
registerClipboardRoutes(this.app, ctx);
|
||||
registerSearchRoutes(this.app, ctx);
|
||||
registerMeRoutes(this.app, ctx);
|
||||
registerAdminRoutes(this.app, ctx);
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
|
||||
// Cron: build the service from the same context, recompute
|
||||
@@ -1512,7 +1523,12 @@ export class WebServer extends EventEmitter {
|
||||
const claudeMode = settings.claudeMode as string | undefined;
|
||||
const allowedTools = settings.allowedTools as string | undefined;
|
||||
// Only return valid modes
|
||||
if (claudeMode === 'dangerously-skip-permissions' || claudeMode === 'normal' || claudeMode === 'allowedTools') {
|
||||
if (
|
||||
claudeMode === 'dangerously-skip-permissions' ||
|
||||
claudeMode === 'auto' ||
|
||||
claudeMode === 'normal' ||
|
||||
claudeMode === 'allowedTools'
|
||||
) {
|
||||
return { claudeMode, allowedTools };
|
||||
}
|
||||
return {};
|
||||
@@ -1538,7 +1554,12 @@ export class WebServer extends EventEmitter {
|
||||
);
|
||||
}
|
||||
|
||||
private async startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun> {
|
||||
private async startScheduledRun(
|
||||
prompt: string,
|
||||
workingDir: string,
|
||||
durationMinutes: number,
|
||||
owner?: string
|
||||
): Promise<ScheduledRun> {
|
||||
const id = uuidv4();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -1554,6 +1575,9 @@ export class WebServer extends EventEmitter {
|
||||
completedTasks: 0,
|
||||
totalCost: 0,
|
||||
logs: [`[${new Date().toISOString()}] Scheduled run started`],
|
||||
// Multi-user: stamp the requesting user so the spawned Session is owned +
|
||||
// permission-downgraded, and list/delete stay owner-scoped.
|
||||
owner,
|
||||
};
|
||||
|
||||
this.scheduledRuns.set(id, run);
|
||||
@@ -1592,8 +1616,23 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
let session: Session | null = null;
|
||||
try {
|
||||
// Create a session for this iteration
|
||||
session = new Session({ workingDir: run.workingDir });
|
||||
// Create a session for this iteration.
|
||||
if (isMultiUserMode()) {
|
||||
// §6.3: resolve the permission mode with the RUN OWNER (a non-granted user
|
||||
// must not regain --dangerously-skip-permissions here) and stamp the owner so
|
||||
// list/delete stay scoped. owner + mode + allowedTools mirror quick-start.
|
||||
const scheduledClaudeCfg = await this.getClaudeModeConfig();
|
||||
session = new Session({
|
||||
workingDir: run.workingDir,
|
||||
owner: run.owner,
|
||||
claudeMode: await resolveClaudeModeForUsername(scheduledClaudeCfg.claudeMode, run.owner),
|
||||
allowedTools: scheduledClaudeCfg.allowedTools,
|
||||
});
|
||||
} else {
|
||||
// Single-user: build EXACTLY as master (bare workingDir → Session's default
|
||||
// mode) so the flag-off path stays byte-identical.
|
||||
session = new Session({ workingDir: run.workingDir });
|
||||
}
|
||||
this.sessions.set(session.id, session);
|
||||
this.store.incrementSessionsCreated();
|
||||
this.persistSessionState(session);
|
||||
@@ -1738,7 +1777,54 @@ export class WebServer extends EventEmitter {
|
||||
* Get lightweight state for SSE init - excludes full terminal buffers
|
||||
* to prevent browser freezes. Terminal buffers are fetched on-demand.
|
||||
*/
|
||||
private getLightState() {
|
||||
private getLightState(identity?: import('../types/user.js').AuthUser) {
|
||||
const base = this.computeLightState();
|
||||
// Multi-user: filter the shared cached blob per connection identity (the plan's
|
||||
// "filter AFTER the cache" approach). No-op for admins / single-user.
|
||||
if (isMultiUserMode() && identity && identity.role !== 'admin') {
|
||||
return this.filterLightStateForUser(base, identity.username);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Shallow-filter the light-state blob to what a non-admin user may see. */
|
||||
private filterLightStateForUser(base: Record<string, unknown>, username: string): Record<string, unknown> {
|
||||
const ownedIds = new Set<string>();
|
||||
const ownedClaudeIds = new Set<string>();
|
||||
for (const [id, s] of this.sessions) {
|
||||
if (s.owner === username) {
|
||||
ownedIds.add(id);
|
||||
if (s.claudeSessionId) ownedClaudeIds.add(s.claudeSessionId);
|
||||
}
|
||||
}
|
||||
const sessions = Array.isArray(base.sessions)
|
||||
? (base.sessions as Array<{ owner?: string }>).filter((s) => s.owner === username)
|
||||
: base.sessions;
|
||||
const respawnStatus: Record<string, unknown> = {};
|
||||
for (const [id, v] of Object.entries((base.respawnStatus as Record<string, unknown>) ?? {})) {
|
||||
if (ownedIds.has(id)) respawnStatus[id] = v;
|
||||
}
|
||||
const bySession = (arr: unknown, key: 'sessionId' | 'sessionUuid') =>
|
||||
Array.isArray(arr)
|
||||
? (arr as Array<Record<string, unknown>>).filter((x) => ownedClaudeIds.has(String(x[key])))
|
||||
: arr;
|
||||
const filtered: Record<string, unknown> = {
|
||||
...base,
|
||||
sessions,
|
||||
respawnStatus,
|
||||
scheduledRuns: [], // legacy ScheduledRun has no owner yet → admin-only
|
||||
subagents: bySession(base.subagents, 'sessionId'),
|
||||
workflowRuns: bySession(base.workflowRuns, 'sessionUuid'),
|
||||
planUsage: null, // host-plan telemetry is admin-only
|
||||
};
|
||||
// #29: globalStats is a machine-wide aggregate (all users' tokens/cost + active
|
||||
// count) with no per-user attribution — never expose it to a non-admin. The
|
||||
// header falls back to per-active-session totals when it is absent.
|
||||
delete filtered.globalStats;
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private computeLightState() {
|
||||
const now = Date.now();
|
||||
if (this.cachedLightState && now - this.cachedLightState.timestamp < WebServer.LIGHT_STATE_CACHE_TTL_MS) {
|
||||
return this.cachedLightState.data;
|
||||
@@ -1783,7 +1869,65 @@ export class WebServer extends EventEmitter {
|
||||
this.cachedLightState = null;
|
||||
this.cachedSessionsList = null;
|
||||
}
|
||||
this.sse.broadcast(event, data);
|
||||
// Multi-user: derive an ownership routing hint so an event only reaches the
|
||||
// clients entitled to it (no-op in single-user — hint stays undefined).
|
||||
this.sse.broadcast(event, data, isMultiUserMode() ? this.deriveSseHint(event, data) : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an SSE event + payload to a routing hint (multi-user). Session-scoped
|
||||
* families resolve the owner from a sessionId in the payload (fail closed if it
|
||||
* can't be resolved); machine-level families are admin-only; host-plan telemetry
|
||||
* is admin-only; everything else stays global. Default is fail-closed for the
|
||||
* session-scoped prefixes so a missed field starves rather than leaks.
|
||||
*/
|
||||
private deriveSseHint(event: string, data: unknown): import('./sse-stream-manager.js').SseRoutingHint | undefined {
|
||||
// Machine-level / host-wide: admins only.
|
||||
if (
|
||||
event.startsWith('docker:') ||
|
||||
event.startsWith('tunnel:') ||
|
||||
event.startsWith('update:') ||
|
||||
event.startsWith('system:') ||
|
||||
event.startsWith('cron:') ||
|
||||
event === SseEvent.SessionStatusTelemetry
|
||||
) {
|
||||
return { adminOnly: true };
|
||||
}
|
||||
// Session-scoped families: resolve the owner from the payload's session id.
|
||||
const SESSION_PREFIXES = [
|
||||
'session:',
|
||||
'ralph:',
|
||||
'respawn:',
|
||||
'subagent:',
|
||||
'workflow:',
|
||||
'attachment:',
|
||||
'task:',
|
||||
'mux:',
|
||||
'transcript:',
|
||||
'plan:',
|
||||
'orchestrator:',
|
||||
'hook:',
|
||||
'image:',
|
||||
'scheduled:',
|
||||
'team:',
|
||||
'case:',
|
||||
];
|
||||
if (SESSION_PREFIXES.some((p) => event.startsWith(p))) {
|
||||
const d = (data ?? {}) as { sessionId?: string; id?: string; session?: { id?: string } };
|
||||
const sessionId = d.sessionId ?? d.id ?? d.session?.id;
|
||||
const owner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
|
||||
return { owner, sessionScoped: true };
|
||||
}
|
||||
// #20/#38: clipboard:write writes into the receiver's OS clipboard — route it to
|
||||
// the POSTING user's own tabs only (never other users). The route stamps the
|
||||
// trusted caller identity as `callerUsername`. sessionScoped:true fails closed
|
||||
// (withhold from non-admins) if the caller identity is somehow unresolved, rather
|
||||
// than falling through to global delivery.
|
||||
if (event.startsWith('clipboard:')) {
|
||||
return { username: (data as { callerUsername?: string }).callerUsername, sessionScoped: true };
|
||||
}
|
||||
// Unrecognized / genuinely global events (connection status, needsRefresh): all.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private batchTerminalData(sessionId: string, data: string): void {
|
||||
@@ -1840,6 +1984,13 @@ export class WebServer extends EventEmitter {
|
||||
const sessionName = (data.sessionName as string) || '';
|
||||
const sessionId = (data.sessionId as string) || '';
|
||||
|
||||
// Multi-user: a session-scoped push (all PUSH_EVENT_MAP events carry a sessionId)
|
||||
// must reach only the owner's devices (+ admins) — the body embeds the session
|
||||
// name + activity, so cross-user delivery would leak it. Resolved once here; the
|
||||
// per-subscription gate below is a no-op in single-user (send to all).
|
||||
const multiUserPush = isMultiUserMode();
|
||||
const pushSessionOwner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
|
||||
|
||||
// Build body text from event data
|
||||
let body = sessionName ? `[${sessionName}]` : '';
|
||||
if (event === SseEvent.SessionError && data.error) {
|
||||
@@ -1876,6 +2027,16 @@ export class WebServer extends EventEmitter {
|
||||
// Check per-subscription preferences
|
||||
if (sub.pushPreferences[event] === false) continue;
|
||||
|
||||
// Multi-user recipient scoping: admins receive all; a session-scoped event
|
||||
// reaches only subscriptions owned by the session owner (fail closed if the
|
||||
// owner is unresolved — legacy subs with no stamped username are excluded);
|
||||
// a genuinely session-less event reaches everyone.
|
||||
if (multiUserPush && sub.role !== 'admin') {
|
||||
if (sessionId) {
|
||||
if (sub.username === undefined || sub.username !== pushSessionOwner) continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validate the stored endpoint before fetching it server-side (SSRF, M7).
|
||||
// Defense-in-depth: subscribe-time validation already rejects unsafe URLs.
|
||||
if (!isSafePushEndpoint(sub.endpoint)) {
|
||||
@@ -1923,6 +2084,24 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Multi-user first boot: create the initial admin from CODEMAN_USERNAME/PASSWORD
|
||||
// if there are no users yet, else refuse to start (there would be no way in).
|
||||
if (isMultiUserMode() && !this.testMode) {
|
||||
const boot = await bootstrapInitialAdmin();
|
||||
if (boot.status === 'missing-env') {
|
||||
throw new Error(
|
||||
'Multi-user mode is enabled but users.json has no users. Create the first admin with ' +
|
||||
'`codeman users add <name> --admin` (or set CODEMAN_USERNAME/CODEMAN_PASSWORD for one-time bootstrap).'
|
||||
);
|
||||
}
|
||||
if (boot.status === 'created') {
|
||||
console.log(
|
||||
`✓ Multi-user: bootstrapped initial admin "${boot.username}" from CODEMAN_USERNAME/CODEMAN_PASSWORD`
|
||||
);
|
||||
}
|
||||
console.log('✓ Multi-user mode active (per-user accounts in users.json; CODEMAN_PASSWORD is ignored for login)');
|
||||
}
|
||||
|
||||
await this.setupRoutes();
|
||||
|
||||
const lifecycleLog = getLifecycleLog();
|
||||
@@ -1941,6 +2120,20 @@ export class WebServer extends EventEmitter {
|
||||
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
|
||||
if (!this.testMode) {
|
||||
await this.restoreMuxSessions();
|
||||
|
||||
// Instance-scoped reaper: after restore, `docker rm -f` managed containers of
|
||||
// THIS instance whose case is gone from docker-cases.json (best-effort, never
|
||||
// touches another instance's containers). Runs after restore so containers
|
||||
// still referenced by a restored session are preserved.
|
||||
void import('../docker-hosts.js')
|
||||
.then(({ reapOrphanedDockerContainers }) => reapOrphanedDockerContainers(getDataDir(), CODEMAN_INSTANCE))
|
||||
.then((reaped) => {
|
||||
if (reaped.length > 0)
|
||||
console.log(`[Docker] reaped ${reaped.length} orphaned container(s): ${reaped.join(', ')}`);
|
||||
})
|
||||
.catch(() => {
|
||||
/* best-effort — daemon may be absent */
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up stale sessions from state file that don't have active mux sessions
|
||||
@@ -1961,6 +2154,15 @@ export class WebServer extends EventEmitter {
|
||||
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
|
||||
console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`);
|
||||
|
||||
// Opt-in: also serve the HOOK endpoints on the docker bridge gateway so
|
||||
// in-container hooks (permission/idle/stop callbacks) can reach a loopback-bound
|
||||
// server. Hooks-only + secret-gated, and the bridge is host-internal (not the LAN).
|
||||
if (!this.testMode) {
|
||||
await this._startDockerBridgeHooksListener().catch((err) =>
|
||||
console.error(`[Docker] bridge-hooks listener error: ${err?.message || err}`)
|
||||
);
|
||||
}
|
||||
|
||||
// Anti-DNS-rebinding Host allowlist is always on. Localhost, any bare IP, the
|
||||
// bind host, *.ts.net / *.trycloudflare.com / *.cfargotunnel.com, and the active
|
||||
// managed tunnel are accepted automatically; add any other domain you front this
|
||||
@@ -1976,7 +2178,10 @@ export class WebServer extends EventEmitter {
|
||||
// "just worked" before. Instead we start and warn loudly, pointing at the ways
|
||||
// to secure it. --allow-unauthenticated-network just acknowledges the risk (a
|
||||
// terser note). See docs/security-architecture.md.
|
||||
if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD) {
|
||||
// Multi-user mode with >= 1 enabled user satisfies the auth requirement even
|
||||
// without CODEMAN_PASSWORD (every person has their own credential).
|
||||
const authActive = !!process.env.CODEMAN_PASSWORD || (isMultiUserMode() && (await hasUsers()));
|
||||
if (!isLoopbackBindHost(this.host) && !authActive) {
|
||||
if (this.allowUnauthenticatedNetwork) {
|
||||
console.warn(
|
||||
`\n⚠ Codeman is reachable WITHOUT a password on ${displayHost}:${this.port} ` +
|
||||
@@ -2181,7 +2386,16 @@ export class WebServer extends EventEmitter {
|
||||
const sessionName = savedState?.name || muxSession.name || muxSession.muxName;
|
||||
|
||||
// Create a session object for this mux session
|
||||
const recoveryClaudeMode = await this.getClaudeModeConfig();
|
||||
// Owner round-trips like remote/docker: mux-sessions.json carries
|
||||
// MuxSession.owner, state.json carries SessionState.owner. Recovery must
|
||||
// re-resolve the permission mode with the RECOVERED owner or a reboot
|
||||
// would silently un-downgrade a non-granted user's restored session.
|
||||
const recoveredOwner = muxSession.owner ?? savedState?.owner;
|
||||
const recoveryClaudeModeConfig = await this.getClaudeModeConfig();
|
||||
const recoveryClaudeMode = {
|
||||
claudeMode: await resolveClaudeModeForUsername(recoveryClaudeModeConfig.claudeMode, recoveredOwner),
|
||||
allowedTools: recoveryClaudeModeConfig.allowedTools,
|
||||
};
|
||||
// Recover envOverrides from the internal __envOverrides field written by
|
||||
// session-manager (see updateSessionState). Cast to read the non-public field.
|
||||
// Note: a legacy CLAUDE_CODE_EFFORT_LEVEL entry is auto-migrated to `effort`
|
||||
@@ -2214,6 +2428,11 @@ export class WebServer extends EventEmitter {
|
||||
// erasing `remote` from state.json on the next persist. mux-sessions.json
|
||||
// round-trips MuxSession.remote; state.json carries SessionState.remote.
|
||||
remote: muxSession.remote ?? savedState?.remote,
|
||||
// Docker metadata round-trips the same way (mux-sessions.json carries
|
||||
// MuxSession.docker; state.json carries SessionState.docker), so recovery
|
||||
// rebuilds the `docker exec` launch instead of a broken local command.
|
||||
docker: muxSession.docker ?? savedState?.docker,
|
||||
owner: recoveredOwner,
|
||||
});
|
||||
|
||||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
||||
@@ -2399,6 +2618,58 @@ export class WebServer extends EventEmitter {
|
||||
return this._orchestratorLoop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in (CODEMAN_DOCKER_BRIDGE_HOOKS=1): start a SECOND listener on the docker
|
||||
* bridge gateway IP that serves ONLY the hook endpoints and delegates them into
|
||||
* the main Fastify pipeline. This lets in-container hooks reach a loopback-bound
|
||||
* server (they call back via host.docker.internal = the bridge gateway) without
|
||||
* exposing the full API or the LAN. Bind IP is auto-detected (default bridge
|
||||
* gateway) or set via CODEMAN_DOCKER_BRIDGE_HOST.
|
||||
*/
|
||||
private async _startDockerBridgeHooksListener(): Promise<void> {
|
||||
if (!isExplicitlyEnabled(process.env.CODEMAN_DOCKER_BRIDGE_HOOKS)) return;
|
||||
const { detectDockerBridgeGateway } = await import('../docker-hosts.js');
|
||||
const bridgeHost = (process.env.CODEMAN_DOCKER_BRIDGE_HOST || '').trim() || (await detectDockerBridgeGateway());
|
||||
if (!bridgeHost) {
|
||||
console.log('[Docker] CODEMAN_DOCKER_BRIDGE_HOOKS set but no docker bridge gateway found — skipping');
|
||||
return;
|
||||
}
|
||||
// Only the hook endpoints are served on the bridge — never the full API.
|
||||
const HOOK_PATHS = new Set([
|
||||
'/api/hook-event',
|
||||
'/api/status-telemetry',
|
||||
'/api/v1/hook-event',
|
||||
'/api/v1/status-telemetry',
|
||||
]);
|
||||
const handler = (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse): void => {
|
||||
const path = (req.url || '').split('?')[0];
|
||||
if (!HOOK_PATHS.has(path)) {
|
||||
res.statusCode = 403;
|
||||
res.end('forbidden: the docker bridge listener serves hook endpoints only');
|
||||
return;
|
||||
}
|
||||
// Delegate into Fastify (host-guard, Origin/CSRF, and hook-secret gate all apply).
|
||||
(this.app as unknown as { routing: (r: unknown, s: unknown) => void }).routing(req, res);
|
||||
};
|
||||
let server: import('node:http').Server | import('node:https').Server;
|
||||
if (this.https) {
|
||||
const https = await import('node:https');
|
||||
const { key, cert } = getOrCreateSelfSignedCert();
|
||||
server = https.createServer({ key, cert }, handler);
|
||||
} else {
|
||||
const http = await import('node:http');
|
||||
server = http.createServer(handler);
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(this.port, bridgeHost, () => resolve());
|
||||
});
|
||||
this._dockerBridgeServer = server;
|
||||
console.log(
|
||||
`[Docker] in-container hooks reachable at ${this.https ? 'https' : 'http'}://${bridgeHost}:${this.port} (hook endpoints only)`
|
||||
);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
getLifecycleLog().log({ event: 'server_stopped', sessionId: '*' });
|
||||
// Set stopping flag to prevent new timer creation during shutdown
|
||||
@@ -2414,6 +2685,11 @@ export class WebServer extends EventEmitter {
|
||||
this._eventLoopMonitor = null;
|
||||
}
|
||||
|
||||
if (this._dockerBridgeServer) {
|
||||
this._dockerBridgeServer.close();
|
||||
this._dockerBridgeServer = null;
|
||||
}
|
||||
|
||||
// Dispose all managed timers (intervals + resettable timeouts)
|
||||
this.cleanup.dispose();
|
||||
|
||||
@@ -2546,6 +2822,10 @@ export class WebServer extends EventEmitter {
|
||||
this.hookSecretFailures.dispose();
|
||||
this.hookSecretFailures = null;
|
||||
}
|
||||
if (this.userFailures) {
|
||||
this.userFailures.dispose();
|
||||
this.userFailures = null;
|
||||
}
|
||||
this.activePlanOrchestrators.clear();
|
||||
this.cleaningUp.clear();
|
||||
|
||||
|
||||
@@ -370,6 +370,29 @@ export const CaseDeleted = 'case:deleted' as const;
|
||||
/** Case ordering changed. */
|
||||
export const CaseOrderChanged = 'case:order-changed' as const;
|
||||
|
||||
// ─── Docker cases ────────────────────────────────────────────────────────────
|
||||
/** A docker case export bundle finished writing. */
|
||||
export const DockerExportComplete = 'docker:exportComplete' as const;
|
||||
/** A docker case export failed. */
|
||||
export const DockerExportFailed = 'docker:exportFailed' as const;
|
||||
/** A docker bundle was imported into a new case. */
|
||||
export const DockerImportComplete = 'docker:importComplete' as const;
|
||||
/** The agent base image started building (first Docker case; auto-build on first use). */
|
||||
export const DockerImageBuildStarted = 'docker:imageBuildStarted' as const;
|
||||
/** A line of agent base-image build output (progress surfacing). */
|
||||
export const DockerImageBuildProgress = 'docker:imageBuildProgress' as const;
|
||||
/** The agent base image finished building successfully. */
|
||||
export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const;
|
||||
/** The agent base image build failed. */
|
||||
export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const;
|
||||
|
||||
// ─── Multi-user (admin-only / targeted) ──────────────────────────────────────
|
||||
|
||||
/** The user roster changed (admin-only); the Users panel re-fetches. */
|
||||
export const AdminUsersChanged = 'admin:usersChanged' as const;
|
||||
/** A user must change their password (targeted); the frontend shows the modal. */
|
||||
export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const;
|
||||
|
||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -551,4 +574,15 @@ export const SseEvent = {
|
||||
CaseLinked,
|
||||
CaseDeleted,
|
||||
CaseOrderChanged,
|
||||
|
||||
// Docker cases
|
||||
DockerExportComplete,
|
||||
DockerExportFailed,
|
||||
DockerImportComplete,
|
||||
DockerImageBuildStarted,
|
||||
DockerImageBuildProgress,
|
||||
DockerImageBuildComplete,
|
||||
DockerImageBuildFailed,
|
||||
AdminUsersChanged,
|
||||
AuthPasswordChangeRequired,
|
||||
} as const;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import type { BackgroundTask } from '../session.js';
|
||||
import type { AuthUser } from '../types.js';
|
||||
import { CleanupManager, StaleExpirationMap } from '../utils/index.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import {
|
||||
@@ -38,6 +39,26 @@ const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
|
||||
interface SseStreamManagerDeps {
|
||||
/** Get session state with respawn info for session:updated broadcasts */
|
||||
getSessionStateWithRespawn(sessionId: string): unknown;
|
||||
/** Resolve a session's owner (multi-user) for SSE routing; undefined = unknown. */
|
||||
resolveSessionOwner?(sessionId: string): string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional per-broadcast routing hint (multi-user). Resolved by WebServer.broadcast
|
||||
* before delegation. When absent, an event is delivered to all clients (global).
|
||||
*/
|
||||
export interface SseRoutingHint {
|
||||
/** Deliver only to this session's owner (+ admins). */
|
||||
owner?: string;
|
||||
/** Deliver only to admins (machine-level events: docker builds, tunnel, update). */
|
||||
adminOnly?: boolean;
|
||||
/** Deliver only to this exact user (+ admins). */
|
||||
username?: string;
|
||||
/**
|
||||
* The event is session-scoped but the owner could not be resolved — non-admins
|
||||
* are starved (fail closed) rather than leaked to.
|
||||
*/
|
||||
sessionScoped?: boolean;
|
||||
}
|
||||
|
||||
export class SseStreamManager {
|
||||
@@ -50,6 +71,8 @@ export class SseStreamManager {
|
||||
private sseClients: Map<FastifyReply, Set<string> | null> = new Map();
|
||||
/** Optional client-supplied IDs → reply, for live filter updates without reconnecting */
|
||||
private sseClientsById: Map<string, FastifyReply> = new Map();
|
||||
/** Per-client identity (multi-user); absent for single-user clients → no filtering. */
|
||||
private sseClientIdentity: Map<FastifyReply, AuthUser> = new Map();
|
||||
/** SSE clients connecting from non-localhost (i.e. through tunnel) */
|
||||
private remoteSseClients: Set<FastifyReply> = new Set();
|
||||
/** Clients with backpressure — skip writes until 'drain' fires */
|
||||
@@ -105,8 +128,15 @@ export class SseStreamManager {
|
||||
this._isTunnelActive = active;
|
||||
}
|
||||
|
||||
addClient(reply: FastifyReply, sessionFilter: Set<string> | null, isRemote: boolean, clientId?: string): void {
|
||||
addClient(
|
||||
reply: FastifyReply,
|
||||
sessionFilter: Set<string> | null,
|
||||
isRemote: boolean,
|
||||
clientId?: string,
|
||||
identity?: AuthUser
|
||||
): void {
|
||||
this.sseClients.set(reply, sessionFilter);
|
||||
if (identity) this.sseClientIdentity.set(reply, identity);
|
||||
if (isRemote) {
|
||||
this.remoteSseClients.add(reply);
|
||||
}
|
||||
@@ -117,6 +147,7 @@ export class SseStreamManager {
|
||||
this.sseClients.delete(prev);
|
||||
this.remoteSseClients.delete(prev);
|
||||
this.backpressuredClients.delete(prev);
|
||||
this.sseClientIdentity.delete(prev);
|
||||
}
|
||||
this.sseClientsById.set(clientId, reply);
|
||||
}
|
||||
@@ -126,12 +157,31 @@ export class SseStreamManager {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
this.backpressuredClients.delete(reply);
|
||||
this.sseClientIdentity.delete(reply);
|
||||
// Clear any clientId mappings pointing at this reply
|
||||
for (const [id, r] of this.sseClientsById) {
|
||||
if (r === reply) this.sseClientsById.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an SSE event carrying `hint` may be delivered to `reply`. Clients with
|
||||
* no identity (single-user) always receive everything. Admins receive everything.
|
||||
* A non-admin receives an event only when the hint targets them (owner/username)
|
||||
* or the event is unrouted/global; session-scoped events with an unresolved owner
|
||||
* are withheld (fail closed).
|
||||
*/
|
||||
private canDeliver(reply: FastifyReply, hint?: SseRoutingHint): boolean {
|
||||
const identity = this.sseClientIdentity.get(reply);
|
||||
if (!identity || identity.role === 'admin') return true;
|
||||
if (!hint) return true;
|
||||
if (hint.adminOnly) return false;
|
||||
if (hint.username !== undefined) return hint.username === identity.username;
|
||||
if (hint.owner !== undefined) return hint.owner === identity.username;
|
||||
if (hint.sessionScoped) return false; // session-scoped but owner unknown → fail closed
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing client's session subscription filter without forcing
|
||||
* an SSE reconnect. Returns true if the client was found and updated.
|
||||
@@ -197,7 +247,7 @@ export class SseStreamManager {
|
||||
|
||||
// ========== Broadcasting ==========
|
||||
|
||||
broadcast(event: string, data: unknown): void {
|
||||
broadcast(event: string, data: unknown, hint?: SseRoutingHint): void {
|
||||
// Skip serialization entirely when no clients are listening
|
||||
if (this.sseClients.size === 0) return;
|
||||
|
||||
@@ -224,6 +274,8 @@ export class SseStreamManager {
|
||||
// active session's terminal output. Terminal events bypass this method
|
||||
// entirely (see flushSessionTerminalBatch — it applies the filter).
|
||||
for (const [client] of this.sseClients) {
|
||||
// Multi-user ownership routing (no-op for identity-less single-user clients).
|
||||
if (!this.canDeliver(client, hint)) continue;
|
||||
this.sendSSEPreformatted(client, message);
|
||||
}
|
||||
}
|
||||
@@ -314,9 +366,15 @@ export class SseStreamManager {
|
||||
// terminal data is high-frequency and latency-sensitive.
|
||||
const padding = this._isTunnelActive ? SSE_PADDING : '';
|
||||
const message = `event: session:terminal\ndata: {"id":"${sessionId}","data":${escapedData}}\n\n` + padding;
|
||||
// Raw terminal bytes are the highest-value payload: resolve the session owner
|
||||
// ONCE and withhold the batch from any non-admin who is not the owner (fail
|
||||
// closed if the owner is unknown). No-op for identity-less single-user clients.
|
||||
const owner = this.deps.resolveSessionOwner?.(sessionId);
|
||||
const termHint: SseRoutingHint = { owner, sessionScoped: true };
|
||||
for (const [client, filter] of this.sseClients) {
|
||||
// Skip clients that have a session filter and aren't subscribed to this session
|
||||
if (filter && !filter.has(sessionId)) continue;
|
||||
if (!this.canDeliver(client, termHint)) continue;
|
||||
this.sendSSEPreformatted(client, message);
|
||||
}
|
||||
}
|
||||
@@ -355,7 +413,11 @@ export class SseStreamManager {
|
||||
return;
|
||||
}
|
||||
for (const [, { sessionId, task }] of this.taskUpdateBatches) {
|
||||
this.broadcast(SseEvent.TaskUpdated, { sessionId, task });
|
||||
// Multi-user: batched task updates carry session state — route to the owner
|
||||
// only (fail closed if unknown), matching flushSessionTerminalBatch. No-op for
|
||||
// identity-less single-user clients (canDeliver short-circuits on no identity).
|
||||
const owner = this.deps.resolveSessionOwner?.(sessionId);
|
||||
this.broadcast(SseEvent.TaskUpdated, { sessionId, task }, { owner, sessionScoped: true });
|
||||
}
|
||||
this.taskUpdateBatches.clear();
|
||||
}
|
||||
@@ -395,7 +457,11 @@ export class SseStreamManager {
|
||||
// Single expensive serialization per batch interval
|
||||
const state = this.deps.getSessionStateWithRespawn(sessionId);
|
||||
if (state) {
|
||||
this.broadcast(SseEvent.SessionUpdated, state);
|
||||
// Multi-user: the debounced session:updated blob carries name/workingDir/
|
||||
// tokens/cost — route to the session owner only (fail closed if unknown),
|
||||
// matching flushSessionTerminalBatch. No-op for single-user clients.
|
||||
const owner = this.deps.resolveSessionOwner?.(sessionId);
|
||||
this.broadcast(SseEvent.SessionUpdated, state, { owner, sessionScoped: true });
|
||||
}
|
||||
}
|
||||
this.stateUpdatePending.clear();
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @fileoverview Phase 5 admin API tests (live server, port 3173).
|
||||
*
|
||||
* Covers the admin user-management endpoints: multi-user gate, requireAdmin,
|
||||
* create (one-time password), patch + last-admin invariant, reset-password,
|
||||
* disable-revokes-sessions, and delete (last-admin refusal + delete-space).
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const PORT = 3173;
|
||||
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
|
||||
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
||||
const admin = { Authorization: basic('root', 'rootpass123'), 'Content-Type': 'application/json' };
|
||||
const adminNoBody = { Authorization: basic('root', 'rootpass123') };
|
||||
const regular = { Authorization: basic('joe', 'joepass1234'), 'Content-Type': 'application/json' };
|
||||
|
||||
let server: WebServer;
|
||||
let dataDir: string;
|
||||
let spacesDir: string;
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'admin-data-'));
|
||||
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'admin-spaces-'));
|
||||
for (const k of [
|
||||
'CODEMAN_DATA_DIR',
|
||||
'CODEMAN_USER_SPACES_DIR',
|
||||
'CODEMAN_MULTIUSER',
|
||||
'CODEMAN_PASSWORD',
|
||||
'CODEMAN_USERNAME',
|
||||
]) {
|
||||
saved[k] = process.env[k];
|
||||
}
|
||||
process.env.CODEMAN_DATA_DIR = dataDir;
|
||||
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
invalidateUsersCache();
|
||||
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
|
||||
await createUser({ username: 'joe', role: 'user', password: 'joepass1234' });
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
invalidateUsersCache();
|
||||
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
describe('admin API', () => {
|
||||
it('rejects a non-admin (403)', async () => {
|
||||
const res = await fetch(url('/api/admin/users'), { headers: regular });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('lists users for an admin', async () => {
|
||||
const res = await fetch(url('/api/admin/users'), { headers: admin });
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await res.json();
|
||||
expect(data.map((u: { username: string }) => u.username).sort()).toEqual(['joe', 'root']);
|
||||
expect(data[0]).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('creates a user with a one-time password', async () => {
|
||||
const res = await fetch(url('/api/admin/users'), {
|
||||
method: 'POST',
|
||||
headers: admin,
|
||||
body: JSON.stringify({ username: 'newbie', role: 'user' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await res.json();
|
||||
expect(data.oneTimePassword).toBeTypeOf('string');
|
||||
expect(data.user).toMatchObject({ username: 'newbie', mustChangePassword: true });
|
||||
});
|
||||
|
||||
it('toggles canBypassPermissions via PATCH', async () => {
|
||||
const res = await fetch(url('/api/admin/users/joe'), {
|
||||
method: 'PATCH',
|
||||
headers: admin,
|
||||
body: JSON.stringify({ canBypassPermissions: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.user.canBypassPermissions).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to demote the last admin (409)', async () => {
|
||||
const res = await fetch(url('/api/admin/users/root'), {
|
||||
method: 'PATCH',
|
||||
headers: admin,
|
||||
body: JSON.stringify({ role: 'user' }),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).errorCode).toBe('LAST_ADMIN');
|
||||
});
|
||||
|
||||
it('resets a password (one-time) and forces change', async () => {
|
||||
const res = await fetch(url('/api/admin/users/joe/reset-password'), { method: 'POST', headers: adminNoBody });
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await res.json();
|
||||
expect(data.oneTimePassword).toBeTypeOf('string');
|
||||
// joe must now change password before other actions.
|
||||
const gated = await fetch(url('/api/status'), { headers: { Authorization: basic('joe', data.oneTimePassword) } });
|
||||
expect(gated.status).toBe(403);
|
||||
expect((await gated.json()).errorCode).toBe('PASSWORD_CHANGE_REQUIRED');
|
||||
});
|
||||
|
||||
it('refuses to delete the last admin, deletes a regular user + space', async () => {
|
||||
const del = await fetch(url('/api/admin/users/root'), { method: 'DELETE', headers: adminNoBody });
|
||||
expect(del.status).toBe(409);
|
||||
|
||||
await fs.mkdir(path.join(spacesDir, 'newbie', 'cases'), { recursive: true });
|
||||
const del2 = await fetch(url('/api/admin/users/newbie'), {
|
||||
method: 'DELETE',
|
||||
headers: admin,
|
||||
body: JSON.stringify({ deleteSpace: true }),
|
||||
});
|
||||
expect(del2.status).toBe(200);
|
||||
await expect(fs.stat(path.join(spacesDir, 'newbie'))).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
it('404s admin routes in single-user mode', async () => {
|
||||
// Flip the flag off for one request path check.
|
||||
process.env.CODEMAN_MULTIUSER = '';
|
||||
try {
|
||||
const res = await fetch(url('/api/admin/users'), { headers: admin });
|
||||
expect(res.status).toBe(404);
|
||||
} finally {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @fileoverview Frontend test for admin-ui.js (multi-user identity boot + admin
|
||||
* Users tab + change-password modal). Builds a JSDOM window in-test under the
|
||||
* default node env (constructing the DOM in-test avoids the vitest environment
|
||||
* comment-directive gotcha) and evaluates the real module against it.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const ADMIN_UI = readFileSync(new URL('../src/web/public/admin-ui.js', import.meta.url), 'utf-8');
|
||||
const INDEX_HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
|
||||
|
||||
function resp(status: number, body: unknown) {
|
||||
const r = {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => body,
|
||||
clone() {
|
||||
return r;
|
||||
},
|
||||
};
|
||||
return r;
|
||||
}
|
||||
|
||||
async function bootWith(me: Record<string, unknown>) {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><body>
|
||||
<div class="modal" id="appSettingsModal"><div class="modal-tabs"></div><div class="modal-body"></div></div>
|
||||
</body>`,
|
||||
{ url: 'http://localhost/', runScripts: 'outside-only' }
|
||||
);
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { __codemanUser?: Record<string, unknown> };
|
||||
win.fetch = (async (path: string) => {
|
||||
if (path === '/api/me') return resp(200, { success: true, data: me });
|
||||
if (path === '/api/admin/users') return resp(200, { success: true, data: [] });
|
||||
return resp(200, { success: true });
|
||||
}) as unknown as typeof fetch;
|
||||
(win as unknown as { eval: (s: string) => void }).eval(ADMIN_UI);
|
||||
// Let the async boot() (fetch /api/me → DOM inject) settle.
|
||||
for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
return { dom, win };
|
||||
}
|
||||
|
||||
describe('admin-ui boot', () => {
|
||||
it('exposes the identity and injects the Users tab for a multi-user admin', async () => {
|
||||
const { win } = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
|
||||
expect(win.__codemanUser).toMatchObject({ username: 'root', role: 'admin', multiUser: true });
|
||||
const btn = win.document.querySelector('[data-tab="settings-users"]');
|
||||
expect(btn).toBeTruthy();
|
||||
expect(win.document.getElementById('settings-users')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does NOT inject the Users tab for a regular user', async () => {
|
||||
const { win } = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
|
||||
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does NOT inject the Users tab in single-user mode', async () => {
|
||||
const { win } = await bootWith({ username: 'admin', role: 'admin', multiUser: false, mustChangePassword: false });
|
||||
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('shows the change-password modal when mustChangePassword is set', async () => {
|
||||
const { win } = await bootWith({ username: 'dave', role: 'user', multiUser: true, mustChangePassword: true });
|
||||
const modal = win.document.getElementById('changePasswordModal') as HTMLElement | null;
|
||||
expect(modal).toBeTruthy();
|
||||
expect(modal!.style.display).toBe('flex');
|
||||
// Forced: the cancel button is hidden.
|
||||
expect((modal!.querySelector('#cpCancel') as HTMLElement).style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('index.html wiring', () => {
|
||||
it('loads admin-ui.js after settings-ui.js and before session-ui.js', () => {
|
||||
const settings = INDEX_HTML.indexOf('settings-ui.js');
|
||||
const admin = INDEX_HTML.indexOf('admin-ui.js');
|
||||
const session = INDEX_HTML.indexOf('session-ui.js');
|
||||
expect(admin).toBeGreaterThan(settings);
|
||||
expect(session).toBeGreaterThan(admin);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @fileoverview Tests for Claude CLI startup permission modes, focused on the
|
||||
* 'auto' mode (`--permission-mode auto`, Anthropic's recommended low-prompt mode)
|
||||
* added alongside the default `--dangerously-skip-permissions`.
|
||||
*
|
||||
* Covers BOTH spawn paths, which build the permission flags independently:
|
||||
* - session-cli-builder.buildInteractiveArgs (direct PTY, non-mux fallback)
|
||||
* - tmux-manager.buildSpawnCommand (tmux pane command string)
|
||||
* The default must stay 'dangerously-skip-permissions' when the setting is unset.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildInteractiveArgs } from '../src/session-cli-builder.js';
|
||||
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
|
||||
describe('buildInteractiveArgs permission modes (direct PTY path)', () => {
|
||||
it('keeps --dangerously-skip-permissions as the skip-mode flag', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'dangerously-skip-permissions');
|
||||
expect(args).toContain('--dangerously-skip-permissions');
|
||||
expect(args).not.toContain('--permission-mode');
|
||||
});
|
||||
|
||||
it('auto mode emits --permission-mode auto and never the skip flag', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'auto');
|
||||
const idx = args.indexOf('--permission-mode');
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
expect(args[idx + 1]).toBe('auto');
|
||||
expect(args).not.toContain('--dangerously-skip-permissions');
|
||||
});
|
||||
|
||||
it('normal mode emits no permission flag at all', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'normal');
|
||||
expect(args).not.toContain('--dangerously-skip-permissions');
|
||||
expect(args).not.toContain('--permission-mode');
|
||||
});
|
||||
|
||||
it('allowedTools mode is unchanged by the auto addition', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'allowedTools', undefined, 'Read,Grep');
|
||||
expect(args).toEqual(expect.arrayContaining(['--allowedTools', 'Read,Grep']));
|
||||
expect(args).not.toContain('--permission-mode');
|
||||
});
|
||||
|
||||
it('auto mode composes with model and effort flags', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'auto', 'opus', undefined, 'high');
|
||||
expect(args).toEqual(expect.arrayContaining(['--permission-mode', 'auto', '--model', 'opus', '--effort', 'high']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSpawnCommand permission modes (tmux path)', () => {
|
||||
it('unset claudeMode defaults to --dangerously-skip-permissions', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1' });
|
||||
expect(cmd).toContain('claude --dangerously-skip-permissions --session-id "sid-1"');
|
||||
expect(cmd).not.toContain('--permission-mode');
|
||||
});
|
||||
|
||||
it('auto mode emits --permission-mode auto and never the skip flag', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', claudeMode: 'auto' });
|
||||
expect(cmd).toContain('claude --permission-mode auto --session-id "sid-1"');
|
||||
expect(cmd).not.toContain('--dangerously-skip-permissions');
|
||||
});
|
||||
|
||||
it('auto mode carries into BOTH legs of the resume fallback command', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
claudeMode: 'auto',
|
||||
resumeSessionId: 'abc-123',
|
||||
});
|
||||
const [resumeLeg, fallbackLeg] = cmd.split('||');
|
||||
expect(resumeLeg).toContain('--permission-mode auto');
|
||||
expect(resumeLeg).toContain('--resume "abc-123"');
|
||||
expect(fallbackLeg).toContain('--permission-mode auto');
|
||||
expect(fallbackLeg).toContain('--session-id "sid-1"');
|
||||
expect(cmd).not.toContain('--dangerously-skip-permissions');
|
||||
});
|
||||
|
||||
it('normal mode emits no permission flag', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', claudeMode: 'normal' });
|
||||
expect(cmd).toContain('claude --session-id "sid-1"');
|
||||
expect(cmd).not.toContain('--permission-mode');
|
||||
expect(cmd).not.toContain('--dangerously-skip-permissions');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Unit tests for the docker launch/kill command builders in tmux-manager.ts
|
||||
* (mirror of test/remote-ssh-options.test.ts). Pure string assertions: the
|
||||
* escaping must survive bash -c -> docker exec -> sh -lc -> tmux.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildDockerLaunchCommand,
|
||||
buildDockerKillCommand,
|
||||
buildDockerStopCommand,
|
||||
buildDockerRemoveCommand,
|
||||
dockerTmuxSessionName,
|
||||
type DockerLaunchOptions,
|
||||
} from '../src/tmux-manager.js';
|
||||
import { DEFAULT_AGENT_IMAGE, toSessionDocker, type DockerCreateContext } from '../src/docker-hosts.js';
|
||||
import type { DockerCase, DockerHost, SessionMode } from '../src/types.js';
|
||||
|
||||
// The exact adopt-guard the in-container Codeman would use to discover its own sessions.
|
||||
const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/;
|
||||
|
||||
const HOST: DockerHost = { id: 'local', label: 'Local', image: DEFAULT_AGENT_IMAGE };
|
||||
const CASE: DockerCase = {
|
||||
name: 'myproj',
|
||||
type: 'docker',
|
||||
hostId: 'local',
|
||||
hostWorkspacePath: '/home/arkon/cases/myproj',
|
||||
};
|
||||
|
||||
function launchOpts(overrides: Partial<DockerLaunchOptions> = {}): DockerLaunchOptions {
|
||||
const docker = overrides.docker ?? toSessionDocker(HOST, CASE);
|
||||
const createContext: DockerCreateContext = {
|
||||
docker,
|
||||
sessionId: '1a2b3c4d5e6f',
|
||||
instance: '',
|
||||
userArgs: ['--user', '1000:0'],
|
||||
credentialMounts: [{ src: '/home/arkon/.claude', dst: '/home/agent/.claude' }],
|
||||
extraMounts: [],
|
||||
envCreate: { HOME: '/home/agent', CODEMAN_API_URL: 'https://host.docker.internal:3000' },
|
||||
addHostGateway: true,
|
||||
gatewayAlias: 'host.docker.internal',
|
||||
};
|
||||
return {
|
||||
mode: 'claude',
|
||||
docker,
|
||||
sessionId: '1a2b3c4d5e6f',
|
||||
createContext,
|
||||
execEnv: { TERM: 'xterm-256color', CODEMAN_SESSION_ID: '1a2b3c4d', CODEMAN_MUX: '1' },
|
||||
execEnvNames: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('dockerTmuxSessionName', () => {
|
||||
it('is stable from the first 8 chars of the sessionId', () => {
|
||||
expect(dockerTmuxSessionName('1a2b3c4d5e6f')).toBe('codeman-dkr-1a2b3c4d');
|
||||
});
|
||||
it('deliberately FAILS the in-container adopt guard', () => {
|
||||
// 'k'/'r' are not hex, so an in-container Codeman never adopts our session
|
||||
expect(SAFE_MUX_NAME_PATTERN.test(dockerTmuxSessionName('1a2b3c4d5e6f'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDockerLaunchCommand', () => {
|
||||
it('image-check precedes ensure precedes start precedes exec', () => {
|
||||
const cmd = buildDockerLaunchCommand(launchOpts());
|
||||
const iImage = cmd.indexOf('docker image inspect');
|
||||
const iEnsure = cmd.indexOf('docker inspect');
|
||||
const iStart = cmd.indexOf('docker start');
|
||||
const iExec = cmd.indexOf('exec docker exec -it');
|
||||
expect(iImage).toBeGreaterThanOrEqual(0);
|
||||
expect(iImage).toBeLessThan(iEnsure);
|
||||
expect(iEnsure).toBeLessThan(iStart);
|
||||
expect(iStart).toBeLessThan(iExec);
|
||||
});
|
||||
|
||||
it('ensures the container idempotently (inspect-or-create) with --pull=never', () => {
|
||||
const cmd = buildDockerLaunchCommand(launchOpts());
|
||||
expect(cmd).toContain("docker inspect 'codeman-case-myproj' >/dev/null 2>&1 || docker create");
|
||||
expect(cmd).toContain('--pull=never');
|
||||
expect(cmd).toContain("docker start 'codeman-case-myproj'");
|
||||
});
|
||||
|
||||
it('execs a TTY into the durable in-container tmux', () => {
|
||||
const cmd = buildDockerLaunchCommand(launchOpts());
|
||||
expect(cmd).toContain("exec docker exec -it --workdir '/home/arkon/cases/myproj'");
|
||||
expect(cmd).toContain('tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID');
|
||||
expect(cmd).toContain('new-session -A -s codeman-dkr-1a2b3c4d');
|
||||
expect(cmd).toContain("sh -lc '");
|
||||
});
|
||||
|
||||
it('injects the resume flag ONLY when a resume id is passed', () => {
|
||||
const withResume = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'abc-123-def' }));
|
||||
expect(withResume).toContain('exec claude --dangerously-skip-permissions --resume abc-123-def');
|
||||
const without = buildDockerLaunchCommand(launchOpts());
|
||||
expect(without).not.toContain('--resume');
|
||||
});
|
||||
|
||||
it('uses codex resume syntax and drops an unsafe resume id', () => {
|
||||
const codex = buildDockerLaunchCommand(
|
||||
launchOpts({ mode: 'codex' as SessionMode, resumeSessionId: '01H-codex-id' })
|
||||
);
|
||||
expect(codex).toContain('exec codex resume 01H-codex-id');
|
||||
const unsafe = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'x; rm -rf /' }));
|
||||
expect(unsafe).not.toContain('--resume');
|
||||
expect(unsafe).not.toContain('rm -rf');
|
||||
});
|
||||
|
||||
it('forwards codex/gemini keys NAME-ONLY (no value in argv)', () => {
|
||||
const codex = buildDockerLaunchCommand(
|
||||
launchOpts({ mode: 'codex' as SessionMode, execEnvNames: ['OPENAI_API_KEY', 'CODEX_API_KEY'] })
|
||||
);
|
||||
expect(codex).toContain('--env OPENAI_API_KEY');
|
||||
expect(codex).not.toMatch(/--env OPENAI_API_KEY=/); // never a value
|
||||
});
|
||||
|
||||
it('primes CODEMAN_SESSION_ID / CODEMAN_MUX at exec time', () => {
|
||||
const cmd = buildDockerLaunchCommand(launchOpts());
|
||||
expect(cmd).toContain("--env 'CODEMAN_SESSION_ID=1a2b3c4d'");
|
||||
expect(cmd).toContain("--env 'CODEMAN_MUX=1'");
|
||||
});
|
||||
|
||||
it('keeps a workspace path with spaces a single token through every layer', () => {
|
||||
const docker = toSessionDocker(HOST, { ...CASE, hostWorkspacePath: '/home/arkon/my cases/proj' });
|
||||
const cmd = buildDockerLaunchCommand(launchOpts({ docker }));
|
||||
// workdir single-quoted at the docker exec layer
|
||||
expect(cmd).toContain("--workdir '/home/arkon/my cases/proj'");
|
||||
// and the cd inside the (nested-escaped) paneCommand still references the spaced path
|
||||
expect(cmd).toContain('/home/arkon/my cases/proj');
|
||||
});
|
||||
|
||||
it('honors a per-host command override', () => {
|
||||
const docker = { ...toSessionDocker(HOST, CASE), commands: { claude: 'exec claude --model opus' } };
|
||||
const cmd = buildDockerLaunchCommand(launchOpts({ docker }));
|
||||
expect(cmd).toContain('exec claude --model opus');
|
||||
});
|
||||
|
||||
it('seeds writable config (guarded copies, mkdir -p parent) from the read-only seed mounts', () => {
|
||||
const cmd = buildDockerLaunchCommand(
|
||||
launchOpts({
|
||||
seedCopies: [
|
||||
{ from: '/home/agent/.codeman/claude.seed.json', to: '/home/agent/.claude.json' },
|
||||
{ from: '/home/agent/.codeman/claude-creds.seed.json', to: '/home/agent/.claude/.credentials.json' },
|
||||
// whole-dir credential seed → cp -a
|
||||
{ from: '/home/agent/.codeman/cred-seeds/.gemini', to: '/home/agent/.gemini', recursive: true },
|
||||
],
|
||||
})
|
||||
);
|
||||
// Each copy mkdir -p's its parent then is guarded so a reconnect never clobbers config.
|
||||
expect(cmd).toContain(
|
||||
'mkdir -p /home/agent 2>/dev/null; [ -e /home/agent/.claude.json ] || cp /home/agent/.codeman/claude.seed.json /home/agent/.claude.json'
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
'mkdir -p /home/agent/.claude 2>/dev/null; [ -e /home/agent/.claude/.credentials.json ] || cp /home/agent/.codeman/claude-creds.seed.json /home/agent/.claude/.credentials.json'
|
||||
);
|
||||
// recursive whole-dir seed uses cp -a
|
||||
expect(cmd).toContain(
|
||||
'[ -e /home/agent/.gemini ] || cp -a /home/agent/.codeman/cred-seeds/.gemini /home/agent/.gemini'
|
||||
);
|
||||
expect(cmd.indexOf('.claude.json')).toBeLessThan(cmd.indexOf('tmux -L codeman-docker'));
|
||||
});
|
||||
|
||||
it('omits the seed-copy step when there are no seedCopies', () => {
|
||||
const cmd = buildDockerLaunchCommand(launchOpts());
|
||||
expect(cmd).not.toContain('claude.seed.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDockerKillCommand (multi-session safe)', () => {
|
||||
it('kills ONLY this session in-container tmux, never the shared container', () => {
|
||||
const docker = toSessionDocker(HOST, CASE);
|
||||
const cmd = buildDockerKillCommand({ docker, sessionId: '1a2b3c4d5e6f' });
|
||||
expect(cmd).toBe("docker exec 'codeman-case-myproj' tmux -L codeman-docker kill-session -t 'codeman-dkr-1a2b3c4d'");
|
||||
expect(cmd).not.toContain('docker stop');
|
||||
expect(cmd).not.toContain('docker rm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('explicit teardown commands', () => {
|
||||
it('stop and remove target the whole container', () => {
|
||||
const docker = toSessionDocker(HOST, CASE);
|
||||
expect(buildDockerStopCommand(docker)).toBe("docker stop -t 10 'codeman-case-myproj'");
|
||||
expect(buildDockerRemoveCommand(docker)).toBe("docker rm -f 'codeman-case-myproj'");
|
||||
});
|
||||
|
||||
it('uses the podman engine prefix when configured', () => {
|
||||
const docker = toSessionDocker({ ...HOST, engine: 'podman' }, CASE);
|
||||
expect(buildDockerStopCommand(docker)).toBe("podman stop -t 10 'codeman-case-myproj'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Unit tests for the pure docker export/import helpers (src/docker-export.ts).
|
||||
* The IO paths no-op under VITEST; these cover the naming, tar-traversal guard,
|
||||
* load-output parsing, and the sealed-mode refusal.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
dockerArgv,
|
||||
exportBundleName,
|
||||
exportImageTag,
|
||||
importedImageTag,
|
||||
isSafeTarMember,
|
||||
parseLoadedImageRef,
|
||||
exportDockerCase,
|
||||
DOCKER_EXPORT_SCHEMA,
|
||||
} from '../src/docker-export.js';
|
||||
import { toSessionDocker } from '../src/docker-hosts.js';
|
||||
import type { DockerCase, DockerHost } from '../src/types.js';
|
||||
|
||||
const HOST: DockerHost = { id: 'local', label: 'Local', image: 'codeman/agent:base' };
|
||||
const CASE: DockerCase = {
|
||||
name: 'myproj',
|
||||
type: 'docker',
|
||||
hostId: 'local',
|
||||
hostWorkspacePath: '/home/arkon/cases/myproj',
|
||||
};
|
||||
|
||||
describe('dockerArgv', () => {
|
||||
it('is raw (unescaped) argv for spawn', () => {
|
||||
expect(dockerArgv({ engine: 'docker' })).toEqual(['docker']);
|
||||
expect(dockerArgv({ engine: 'podman', context: 'ctx', daemonHost: 'ssh://h' })).toEqual([
|
||||
'podman',
|
||||
'--context',
|
||||
'ctx',
|
||||
'-H',
|
||||
'ssh://h',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bundle / tag naming', () => {
|
||||
it('names bundles by case + timestamp + mode', () => {
|
||||
expect(exportBundleName('myproj', 1234, 'full')).toBe('myproj-1234.codeman-container.tgz');
|
||||
expect(exportBundleName('myproj', 1234, 'workspace')).toBe('myproj-1234.codeman-workspace.tgz');
|
||||
});
|
||||
it('quarantines imported images and tags export intermediates uniquely', () => {
|
||||
expect(importedImageTag('myproj', 99)).toBe('codeman/imported-myproj:99');
|
||||
expect(exportImageTag('myproj', 99)).toBe('codeman/export-myproj:99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSafeTarMember (import traversal guard)', () => {
|
||||
it('accepts normal relative members', () => {
|
||||
expect(isSafeTarMember('./')).toBe(true);
|
||||
expect(isSafeTarMember('src/index.ts')).toBe(true);
|
||||
expect(isSafeTarMember('./a/b/c.txt')).toBe(true);
|
||||
});
|
||||
it('rejects absolute and parent-escaping members', () => {
|
||||
expect(isSafeTarMember('/etc/passwd')).toBe(false);
|
||||
expect(isSafeTarMember('../outside')).toBe(false);
|
||||
expect(isSafeTarMember('a/../../b')).toBe(false);
|
||||
expect(isSafeTarMember('./../../x')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLoadedImageRef', () => {
|
||||
it('parses "Loaded image ID: sha256:..."', () => {
|
||||
expect(parseLoadedImageRef('Loaded image ID: sha256:abc123def')).toBe('sha256:abc123def');
|
||||
});
|
||||
it('parses "Loaded image: repo:tag"', () => {
|
||||
expect(parseLoadedImageRef('Loaded image: codeman/export-x:1234')).toBe('codeman/export-x:1234');
|
||||
});
|
||||
it('returns null on unrecognized output', () => {
|
||||
expect(parseLoadedImageRef('some other text')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportDockerCase (VITEST stub)', () => {
|
||||
it('returns a deterministic stub manifest without touching docker', async () => {
|
||||
const docker = toSessionDocker(HOST, CASE);
|
||||
const res = await exportDockerCase({
|
||||
docker,
|
||||
caseName: 'myproj',
|
||||
timestamp: 42,
|
||||
exportsDir: '/tmp/exports',
|
||||
mode: 'full',
|
||||
codemanVersion: '9.9.9',
|
||||
});
|
||||
expect(res.manifest.schemaVersion).toBe(DOCKER_EXPORT_SCHEMA);
|
||||
expect(res.manifest.caseName).toBe('myproj');
|
||||
expect(res.manifest.mode).toBe('full');
|
||||
expect(res.bundlePath).toBe('/tmp/exports/myproj-42.codeman-container.tgz');
|
||||
});
|
||||
|
||||
it('refuses a full-image export for a sealed container', async () => {
|
||||
const docker = toSessionDocker({ ...HOST, mountCredentials: false }, CASE);
|
||||
await expect(
|
||||
exportDockerCase({
|
||||
docker,
|
||||
caseName: 'myproj',
|
||||
timestamp: 42,
|
||||
exportsDir: '/tmp/exports',
|
||||
mode: 'full',
|
||||
codemanVersion: '9.9.9',
|
||||
})
|
||||
).rejects.toThrow(/sealed/);
|
||||
});
|
||||
|
||||
it('allows a workspace-only export for a sealed container', async () => {
|
||||
const docker = toSessionDocker({ ...HOST, mountCredentials: false }, CASE);
|
||||
const res = await exportDockerCase({
|
||||
docker,
|
||||
caseName: 'myproj',
|
||||
timestamp: 42,
|
||||
exportsDir: '/tmp/exports',
|
||||
mode: 'workspace',
|
||||
codemanVersion: '9.9.9',
|
||||
});
|
||||
expect(res.manifest.mode).toBe('workspace');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Unit tests for the Docker cases storage + pure command-arg builders + probes
|
||||
* (src/docker-hosts.ts). Mirrors test/remote-hosts.test.ts. All docker IO no-ops
|
||||
* under VITEST, so probes return canned values and never spawn a real daemon.
|
||||
*/
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
agentImageBuildArgs,
|
||||
buildDockerBaseArgs,
|
||||
buildDockerCreateArgs,
|
||||
buildSeamlessClaudeConfig,
|
||||
checkDockerAvailable,
|
||||
checkDockerImagePresent,
|
||||
checkDockerTmuxAvailable,
|
||||
CLAUDE_JSON_SEED,
|
||||
containerApiUrl,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
DEFAULT_DOCKER_RESOURCES,
|
||||
dockerConfigHash,
|
||||
dockerContainerName,
|
||||
dockerDisplayPath,
|
||||
defaultDockerCommandForMode,
|
||||
ensureAgentBaseImage,
|
||||
hostGatewayAlias,
|
||||
probeDockerCliVersion,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
resolveClaudeJsonSeedMount,
|
||||
resolveDockerClaudeArtifacts,
|
||||
resolveDockerCredentialArtifacts,
|
||||
toSessionDocker,
|
||||
writeDockerCases,
|
||||
writeDockerHosts,
|
||||
type DockerCreateContext,
|
||||
} from '../src/docker-hosts.js';
|
||||
import type { DockerCase, DockerHost, SessionDocker } from '../src/types.js';
|
||||
|
||||
const HOST: DockerHost = { id: 'local', label: 'Local Docker', image: DEFAULT_AGENT_IMAGE };
|
||||
const CASE: DockerCase = {
|
||||
name: 'myproj',
|
||||
type: 'docker',
|
||||
hostId: 'local',
|
||||
hostWorkspacePath: '/home/arkon/cases/myproj',
|
||||
};
|
||||
|
||||
describe('docker-hosts storage', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-docker-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips hosts and cases through JSON storage', async () => {
|
||||
await writeDockerHosts(dir, [HOST]);
|
||||
await writeDockerCases(dir, [{ ...CASE, lastClaudeSessionId: 'abc-123' }]);
|
||||
expect(await readDockerHosts(dir)).toEqual([HOST]);
|
||||
const cases = await readDockerCases(dir);
|
||||
expect(cases[0].lastClaudeSessionId).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('returns [] for a missing file', async () => {
|
||||
expect(await readDockerHosts(dir)).toEqual([]);
|
||||
expect(await readDockerCases(dir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('naming / display / defaults', () => {
|
||||
it('derives a valid per-case container name', () => {
|
||||
expect(dockerContainerName('myproj')).toBe('codeman-case-myproj');
|
||||
// valid docker name charset: starts alnum, then [a-zA-Z0-9_.-]
|
||||
expect(dockerContainerName('my_proj-2')).toMatch(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/);
|
||||
});
|
||||
|
||||
it('maps each mode to a default pane command', () => {
|
||||
expect(defaultDockerCommandForMode('claude')).toBe('exec claude --dangerously-skip-permissions');
|
||||
expect(defaultDockerCommandForMode('shell')).toBe('exec bash -l');
|
||||
expect(defaultDockerCommandForMode('codex')).toBe('exec codex');
|
||||
expect(defaultDockerCommandForMode('gemini')).toBe('exec gemini');
|
||||
});
|
||||
|
||||
it('formats a container:workdir display path from both shapes', () => {
|
||||
expect(dockerDisplayPath({ container: 'codeman-case-x', path: '/w' })).toBe('codeman-case-x:/w');
|
||||
const sd = toSessionDocker(HOST, CASE);
|
||||
expect(dockerDisplayPath(sd)).toBe('codeman-case-myproj:/home/arkon/cases/myproj');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostGatewayAlias / containerApiUrl', () => {
|
||||
it('returns the engine-specific gateway alias', () => {
|
||||
expect(hostGatewayAlias('docker')).toBe('host.docker.internal');
|
||||
expect(hostGatewayAlias('podman')).toBe('host.containers.internal');
|
||||
});
|
||||
|
||||
it('swaps only the hostname, preserving scheme and port', () => {
|
||||
expect(containerApiUrl('https://127.0.0.1:3000', 'docker')).toBe('https://host.docker.internal:3000');
|
||||
expect(containerApiUrl('http://127.0.0.1:3000', 'docker')).toBe('http://host.docker.internal:3000');
|
||||
expect(containerApiUrl('https://127.0.0.1:8443', 'docker')).toBe('https://host.docker.internal:8443');
|
||||
expect(containerApiUrl('https://127.0.0.1:3000', 'podman')).toBe('https://host.containers.internal:3000');
|
||||
});
|
||||
|
||||
it('falls back to https://<alias>:3000 for absent or unparseable input', () => {
|
||||
expect(containerApiUrl(undefined, 'docker')).toBe('https://host.docker.internal:3000');
|
||||
expect(containerApiUrl('not a url', 'podman')).toBe('https://host.containers.internal:3000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSessionDocker / dockerConfigHash', () => {
|
||||
it('resolves every default (convenient, bridge, resume-on-start)', () => {
|
||||
const sd = toSessionDocker(HOST, CASE);
|
||||
expect(sd.engine).toBe('docker');
|
||||
expect(sd.image).toBe(DEFAULT_AGENT_IMAGE);
|
||||
expect(sd.containerName).toBe('codeman-case-myproj');
|
||||
expect(sd.hostWorkspacePath).toBe('/home/arkon/cases/myproj');
|
||||
expect(sd.containerWorkdir).toBe('/home/arkon/cases/myproj'); // mirror
|
||||
expect(sd.network).toBe('bridge');
|
||||
expect(sd.resources).toEqual(DEFAULT_DOCKER_RESOURCES);
|
||||
expect(sd.mountCredentials).toBe(true);
|
||||
expect(sd.hooksEnabled).toBe(true);
|
||||
expect(sd.resumeOnStart).toBe(true);
|
||||
expect(sd.configHash).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it('honors host overrides and a custom container workdir', () => {
|
||||
const host: DockerHost = {
|
||||
...HOST,
|
||||
engine: 'podman',
|
||||
network: 'none',
|
||||
mountCredentials: false,
|
||||
hooksEnabled: false,
|
||||
resumeOnStart: false,
|
||||
};
|
||||
const sd = toSessionDocker(host, { ...CASE, containerWorkdir: '/work', container: 'my-box' });
|
||||
expect(sd.engine).toBe('podman');
|
||||
expect(sd.network).toBe('none');
|
||||
expect(sd.mountCredentials).toBe(false);
|
||||
expect(sd.containerName).toBe('my-box');
|
||||
expect(sd.containerWorkdir).toBe('/work');
|
||||
});
|
||||
|
||||
it('hash is stable for equal inputs and changes when a drift field changes', () => {
|
||||
const a = toSessionDocker(HOST, CASE);
|
||||
const b = toSessionDocker(HOST, CASE);
|
||||
expect(a.configHash).toBe(b.configHash);
|
||||
const c = toSessionDocker({ ...HOST, image: 'codeman/agent:other' }, CASE);
|
||||
expect(c.configHash).not.toBe(a.configHash);
|
||||
// lastClaudeSessionId is NOT a drift field
|
||||
expect(dockerConfigHash(a)).toBe(dockerConfigHash({ ...a }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDockerBaseArgs', () => {
|
||||
it('defaults to docker with no extra flags', () => {
|
||||
expect(buildDockerBaseArgs({ engine: 'docker' })).toEqual(['docker']);
|
||||
});
|
||||
it('emits podman + context + daemon host', () => {
|
||||
const args = buildDockerBaseArgs({ engine: 'podman', context: 'remote', daemonHost: 'ssh://u@h' });
|
||||
expect(args[0]).toBe('podman');
|
||||
expect(args.join(' ')).toContain("--context 'remote'");
|
||||
expect(args.join(' ')).toContain("-H 'ssh://u@h'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDockerCreateArgs', () => {
|
||||
function ctx(overrides: Partial<DockerCreateContext> = {}): DockerCreateContext {
|
||||
return {
|
||||
docker: toSessionDocker(HOST, CASE),
|
||||
sessionId: '1a2b3c4d5e6f',
|
||||
instance: '',
|
||||
userArgs: ['--user', '1000:0'],
|
||||
credentialMounts: [{ src: '/home/arkon/.claude', dst: '/home/agent/.claude' }],
|
||||
extraMounts: [
|
||||
{ src: '/home/arkon/.codeman/hook-secret', dst: '/home/agent/.codeman/hook-secret', readonly: true },
|
||||
],
|
||||
envCreate: { HOME: '/home/agent', CODEMAN_API_URL: 'https://host.docker.internal:3000' },
|
||||
addHostGateway: true,
|
||||
gatewayAlias: 'host.docker.internal',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('bakes in the security + lifecycle invariants', () => {
|
||||
const s = buildDockerCreateArgs(ctx()).join(' ');
|
||||
expect(s).toContain('--cap-drop ALL');
|
||||
expect(s).toContain('--security-opt no-new-privileges');
|
||||
expect(s).toContain('--pull=never');
|
||||
expect(s).toContain('--init');
|
||||
expect(s).toContain('--restart no');
|
||||
expect(s).toContain('--memory 4g --memory-swap 4g');
|
||||
expect(s).toContain('--pids-limit 512');
|
||||
expect(s).toContain('--ulimit nofile=4096:8192');
|
||||
expect(s).toContain('codeman.managed=1');
|
||||
expect(s).toContain("'codeman.session=1a2b3c4d'"); // first 8 chars only
|
||||
expect(s).toContain('--network bridge');
|
||||
});
|
||||
|
||||
it('NEVER emits privileged mode or a docker-socket mount', () => {
|
||||
const s = buildDockerCreateArgs(ctx()).join(' ');
|
||||
expect(s).not.toContain('--privileged');
|
||||
expect(s).not.toContain('docker.sock');
|
||||
});
|
||||
|
||||
it('ends with the image then the sleep-infinity CMD', () => {
|
||||
const args = buildDockerCreateArgs(ctx());
|
||||
expect(args.slice(-3)).toEqual([`'${DEFAULT_AGENT_IMAGE}'`, 'sleep', 'infinity']);
|
||||
});
|
||||
|
||||
it('includes the resolved user args and the workspace bind', () => {
|
||||
const s = buildDockerCreateArgs(ctx()).join(' ');
|
||||
expect(s).toContain('--user 1000:0');
|
||||
expect(s).toContain("--mount 'type=bind,src=/home/arkon/cases/myproj,dst=/home/arkon/cases/myproj'");
|
||||
});
|
||||
|
||||
it('shell-escapes a workspace path containing spaces into a single token', () => {
|
||||
const docker = toSessionDocker(HOST, { ...CASE, hostWorkspacePath: '/home/arkon/my cases/proj' });
|
||||
const args = buildDockerCreateArgs(ctx({ docker }));
|
||||
// the whole mount spec (with the space) is ONE single-quoted token
|
||||
expect(args).toContain("'type=bind,src=/home/arkon/my cases/proj,dst=/home/arkon/my cases/proj'");
|
||||
// and the workdir is single-quoted too
|
||||
expect(args).toContain("'/home/arkon/my cases/proj'");
|
||||
});
|
||||
|
||||
it('adds the host-gateway only when requested', () => {
|
||||
expect(buildDockerCreateArgs(ctx({ addHostGateway: true })).join(' ')).toContain(
|
||||
'--add-host host.docker.internal:host-gateway'
|
||||
);
|
||||
expect(buildDockerCreateArgs(ctx({ addHostGateway: false })).join(' ')).not.toContain('--add-host');
|
||||
});
|
||||
|
||||
it('omits credential mounts in sealed mode', () => {
|
||||
const s = buildDockerCreateArgs(ctx({ credentialMounts: [] })).join(' ');
|
||||
expect(s).not.toContain('/home/agent/.claude');
|
||||
});
|
||||
|
||||
it('emits create-time env flags', () => {
|
||||
const s = buildDockerCreateArgs(ctx()).join(' ');
|
||||
expect(s).toContain("--env 'HOME=/home/agent'");
|
||||
expect(s).toContain("--env 'CODEMAN_API_URL=https://host.docker.internal:3000'");
|
||||
});
|
||||
|
||||
it('uses the custom network name for custom mode', () => {
|
||||
const docker: SessionDocker = { ...toSessionDocker(HOST, CASE), network: 'custom', networkName: 'codeman-net-x' };
|
||||
expect(buildDockerCreateArgs(ctx({ docker })).join(' ')).toContain('--network codeman-net-x');
|
||||
});
|
||||
|
||||
it('emits --gpus only when GPUs are requested (and never a storage cap)', () => {
|
||||
const withGpu: SessionDocker = { ...toSessionDocker(HOST, CASE), gpus: 'all' };
|
||||
const s = buildDockerCreateArgs(ctx({ docker: withGpu })).join(' ');
|
||||
expect(s).toContain("--gpus 'all'");
|
||||
// elastic disk: no fixed storage cap is ever emitted
|
||||
expect(s).not.toContain('--storage-opt');
|
||||
expect(buildDockerCreateArgs(ctx()).join(' ')).not.toContain('--gpus');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencode)', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('NEVER whole-dir RW-mounts a credential store (no host pollution)', () => {
|
||||
mkdirSync(join(home, '.codex'), { recursive: true });
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
const { mounts } = resolveDockerCredentialArtifacts(home);
|
||||
// no wholesale RW mount at the store's HOME path
|
||||
expect(mounts.some((m) => m.dst === '/home/agent/.codex' && !m.readonly)).toBe(false);
|
||||
expect(mounts.some((m) => m.dst === '/home/agent/.gemini' && !m.readonly)).toBe(false);
|
||||
});
|
||||
|
||||
it('codex: shares sessions/+history.jsonl RW, seeds auth.json/config.toml', () => {
|
||||
mkdirSync(join(home, '.codex', 'sessions'), { recursive: true });
|
||||
writeFileSync(join(home, '.codex', 'history.jsonl'), '');
|
||||
writeFileSync(join(home, '.codex', 'auth.json'), '{}');
|
||||
writeFileSync(join(home, '.codex', 'config.toml'), '');
|
||||
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
|
||||
expect(mounts).toContainEqual({ src: join(home, '.codex', 'sessions'), dst: '/home/agent/.codex/sessions' });
|
||||
expect(mounts).toContainEqual({
|
||||
src: join(home, '.codex', 'history.jsonl'),
|
||||
dst: '/home/agent/.codex/history.jsonl',
|
||||
});
|
||||
const dests = seedCopies.map((s) => s.to);
|
||||
expect(dests).toContain('/home/agent/.codex/auth.json');
|
||||
expect(dests).toContain('/home/agent/.codex/config.toml');
|
||||
// seed copies of individual files are NOT recursive
|
||||
expect(seedCopies.filter((s) => s.to.startsWith('/home/agent/.codex')).every((s) => !s.recursive)).toBe(true);
|
||||
});
|
||||
|
||||
it('gemini/gcloud/opencode: whole-dir seed-copy (cp -a, recursive)', () => {
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
mkdirSync(join(home, '.config', 'gcloud'), { recursive: true });
|
||||
mkdirSync(join(home, '.config', 'opencode'), { recursive: true });
|
||||
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
|
||||
// each is mounted read-only at a seed staging path and cp -a'd into the container HOME
|
||||
expect(seedCopies).toContainEqual({
|
||||
from: '/home/agent/.codeman/cred-seeds/.gemini',
|
||||
to: '/home/agent/.gemini',
|
||||
recursive: true,
|
||||
});
|
||||
expect(seedCopies).toContainEqual({
|
||||
from: '/home/agent/.codeman/cred-seeds/.config-gcloud',
|
||||
to: '/home/agent/.config/gcloud',
|
||||
recursive: true,
|
||||
});
|
||||
expect(mounts.filter((m) => m.readonly && m.dst.includes('cred-seeds')).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('gates every artifact on existsSync (absent stores contribute nothing)', () => {
|
||||
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
|
||||
expect(mounts).toEqual([]);
|
||||
expect(seedCopies).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDockerClaudeArtifacts (isolated claude state)', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('shares only projects (RW) and seeds .claude.json + credentials + settings + stats-cache', () => {
|
||||
mkdirSync(join(home, '.claude', 'projects'), { recursive: true });
|
||||
writeFileSync(join(home, '.claude.json'), JSON.stringify({ oauthAccount: { id: 1 } }));
|
||||
writeFileSync(join(home, '.claude', '.credentials.json'), '{"claudeAiOauth":{}}');
|
||||
writeFileSync(join(home, '.claude', 'settings.json'), JSON.stringify({ theme: 'dark' }));
|
||||
writeFileSync(join(home, '.claude', 'stats-cache.json'), '{}');
|
||||
|
||||
const art = resolveDockerClaudeArtifacts(home, 'codeman-case-x', '/ws/x');
|
||||
// transcripts shared RW (no readonly), whole ~/.claude never mounted
|
||||
expect(art.mounts).toContainEqual({ src: join(home, '.claude', 'projects'), dst: '/home/agent/.claude/projects' });
|
||||
expect(art.mounts.some((m) => m.dst === '/home/agent/.claude')).toBe(false);
|
||||
// credentials + settings + stats-cache + .claude.json seeded (copied into the container's own HOME)
|
||||
const dests = art.seedCopies.map((s) => s.to);
|
||||
expect(dests).toContain('/home/agent/.claude.json');
|
||||
expect(dests).toContain('/home/agent/.claude/.credentials.json');
|
||||
expect(dests).toContain('/home/agent/.claude/settings.json');
|
||||
expect(dests).toContain('/home/agent/.claude/stats-cache.json'); // restores the model/effort status indicator
|
||||
// the seed mounts are read-only
|
||||
expect(art.mounts.filter((m) => m.readonly).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('omits mounts/seeds for artifacts that do not exist', () => {
|
||||
const art = resolveDockerClaudeArtifacts(home, 'codeman-case-y', '/ws/y');
|
||||
expect(art.mounts).toEqual([]);
|
||||
expect(art.seedCopies).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClaudeJsonSeedMount', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns a read-only seed mount when ~/.claude.json exists', () => {
|
||||
writeFileSync(join(home, '.claude.json'), '{}');
|
||||
expect(resolveClaudeJsonSeedMount(home)).toEqual({
|
||||
src: join(home, '.claude.json'),
|
||||
dst: CLAUDE_JSON_SEED,
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when ~/.claude.json is absent', () => {
|
||||
expect(resolveClaudeJsonSeedMount(home)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSeamlessClaudeConfig', () => {
|
||||
it('forces onboarding-complete + theme + workspace trust while preserving host fields', () => {
|
||||
const merged = buildSeamlessClaudeConfig({ oauthAccount: { id: 1 }, projects: {} }, '/ws/proj');
|
||||
expect(merged.hasCompletedOnboarding).toBe(true);
|
||||
expect(merged.theme).toBe('dark');
|
||||
expect(merged.oauthAccount).toEqual({ id: 1 }); // host auth account preserved
|
||||
const proj = (merged.projects as Record<string, Record<string, unknown>>)['/ws/proj'];
|
||||
expect(proj.hasTrustDialogAccepted).toBe(true);
|
||||
expect(proj.hasCompletedProjectOnboarding).toBe(true);
|
||||
expect(proj.projectOnboardingSeenCount).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps an existing theme and merges into an existing project entry', () => {
|
||||
const merged = buildSeamlessClaudeConfig(
|
||||
{ theme: 'light', projects: { '/ws/proj': { allowedTools: ['a'], projectOnboardingSeenCount: 5 } } },
|
||||
'/ws/proj',
|
||||
'dark'
|
||||
);
|
||||
expect(merged.theme).toBe('light'); // not overwritten when already set
|
||||
const proj = (merged.projects as Record<string, Record<string, unknown>>)['/ws/proj'];
|
||||
expect(proj.allowedTools).toEqual(['a']); // existing project fields kept
|
||||
expect(proj.hasTrustDialogAccepted).toBe(true);
|
||||
expect(proj.projectOnboardingSeenCount).toBe(5); // preserved, not reset to 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentImageBuildArgs', () => {
|
||||
it('builds the docker build argv in order', () => {
|
||||
expect(agentImageBuildArgs('/repo/docker/agent.Dockerfile', 'codeman/agent:base', '/repo')).toEqual([
|
||||
'build',
|
||||
'-f',
|
||||
'/repo/docker/agent.Dockerfile',
|
||||
'-t',
|
||||
'codeman/agent:base',
|
||||
'/repo',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds --no-cache before the context dir when requested', () => {
|
||||
const args = agentImageBuildArgs('/df', 'img', '/ctx', true);
|
||||
expect(args).toContain('--no-cache');
|
||||
expect(args.indexOf('--no-cache')).toBeLessThan(args.indexOf('/ctx'));
|
||||
expect(args[args.length - 1]).toBe('/ctx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureAgentBaseImage (no-op under VITEST)', () => {
|
||||
it('reports the image as already present without spawning a build', async () => {
|
||||
const r = await ensureAgentBaseImage('docker', DEFAULT_AGENT_IMAGE);
|
||||
expect(r).toEqual({ ok: true, built: false, alreadyPresent: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('daemon probes (no-op under VITEST)', () => {
|
||||
it('checkDockerAvailable returns a canned available result', async () => {
|
||||
const a = await checkDockerAvailable();
|
||||
expect(a.ok).toBe(true);
|
||||
expect(a.capsEnforced).toBe(true);
|
||||
expect(a.engine).toBe('docker');
|
||||
});
|
||||
|
||||
it('checkDockerTmuxAvailable + image present are canned-true', async () => {
|
||||
expect((await checkDockerTmuxAvailable({ engine: 'docker', image: DEFAULT_AGENT_IMAGE })).ok).toBe(true);
|
||||
expect(await checkDockerImagePresent('docker', DEFAULT_AGENT_IMAGE)).toBe(true);
|
||||
});
|
||||
|
||||
it('probeDockerCliVersion is undefined under test', async () => {
|
||||
expect(
|
||||
await probeDockerCliVersion({ engine: 'docker', containerName: 'codeman-case-x' }, 'claude')
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -221,7 +221,10 @@ describe('Edge Cases and Error Handling', () => {
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.error).toBe('Respawn controller not found');
|
||||
// respawn/stop now owner-gates via findSessionOrFail first (multi-user #18), so a
|
||||
// non-existent session id 404s as "Session ... not found" (same not-found semantics,
|
||||
// matching the sibling start/config/enable handlers).
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle updating config on non-existent session', async () => {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @fileoverview Phase 2 multi-user auth integration tests (live server, port 3170+).
|
||||
*
|
||||
* Verifies the multi-user auth branch end to end: per-user Basic verify, cookie
|
||||
* identity, wrong-password / disabled-user rejection, the mustChangePassword
|
||||
* lockbox + self-service change, per-account rate limiting, and QR identity binding
|
||||
* (tunnel-manager unit level). Single-user auth is covered by auth-security.test.ts.
|
||||
*
|
||||
* Ports: 3170 (multi-user server), 3171 (rate-limit server).
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { TunnelManager } from '../src/tunnel-manager.js';
|
||||
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
||||
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const PORT = 3170;
|
||||
const RATE_PORT = 3171;
|
||||
|
||||
function basic(user: string, pass: string): string {
|
||||
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
}
|
||||
|
||||
function cookieFrom(res: Response): string | null {
|
||||
const raw = res.headers.get('set-cookie');
|
||||
const m = raw?.match(/codeman_session=([^;]+)/);
|
||||
return m ? `codeman_session=${m[1]}` : null;
|
||||
}
|
||||
|
||||
let server: WebServer;
|
||||
let rateServer: WebServer;
|
||||
let dataDir: string;
|
||||
let spacesDir: string;
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mu-auth-data-'));
|
||||
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mu-auth-spaces-'));
|
||||
for (const k of [
|
||||
'CODEMAN_DATA_DIR',
|
||||
'CODEMAN_USER_SPACES_DIR',
|
||||
'CODEMAN_MULTIUSER',
|
||||
'CODEMAN_PASSWORD',
|
||||
'CODEMAN_USERNAME',
|
||||
]) {
|
||||
saved[k] = process.env[k];
|
||||
}
|
||||
process.env.CODEMAN_DATA_DIR = dataDir;
|
||||
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
invalidateUsersCache();
|
||||
|
||||
await createUser({ username: 'alice', role: 'admin', password: 'alicepass1' });
|
||||
await createUser({ username: 'bob', role: 'user', password: 'bobpass123' });
|
||||
await createUser({ username: 'carol', role: 'user', password: 'carolpass1' });
|
||||
await createUser({ username: 'carol', role: 'user', password: 'x' }).catch(() => {}); // no-op dup guard
|
||||
await createUser({ username: 'dave', role: 'user', password: 'davepass12', mustChangePassword: true });
|
||||
// Disable carol after creation.
|
||||
const { updateUser } = await import('../src/user-store.js');
|
||||
await updateUser('carol', { disabled: true });
|
||||
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
await rateServer?.stop().catch(() => {});
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
invalidateUsersCache();
|
||||
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
||||
|
||||
describe('multi-user auth', () => {
|
||||
it('rejects unauthenticated requests', async () => {
|
||||
const res = await fetch(url('/api/status'));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('authenticates a valid user and issues an identity cookie', async () => {
|
||||
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('alice', 'alicepass1') } });
|
||||
expect(res.status).toBe(200);
|
||||
const cookie = cookieFrom(res);
|
||||
expect(cookie).toBeTruthy();
|
||||
|
||||
const me = await fetch(url('/api/me'), { headers: { Cookie: cookie! } });
|
||||
expect(me.status).toBe(200);
|
||||
const body = await me.json();
|
||||
expect(body.data).toMatchObject({ username: 'alice', role: 'admin', mustChangePassword: false });
|
||||
});
|
||||
|
||||
it('reports role for a regular user', async () => {
|
||||
const res = await fetch(url('/api/me'), { headers: { Authorization: basic('bob', 'bobpass123') } });
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data).toMatchObject({ username: 'bob', role: 'user' });
|
||||
});
|
||||
|
||||
it('rejects a wrong password', async () => {
|
||||
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('bob', 'wrongwrong') } });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a disabled user even with the correct password', async () => {
|
||||
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('carol', 'carolpass1') } });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('is case-insensitive on the username', async () => {
|
||||
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('ALICE', 'alicepass1') } });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('enforces the mustChangePassword lockbox and clears it on self-service change', async () => {
|
||||
// Basic auth as dave succeeds (cookie issued) but non-exempt routes 403.
|
||||
const authed = await fetch(url('/api/status'), { headers: { Authorization: basic('dave', 'davepass12') } });
|
||||
expect(authed.status).toBe(403);
|
||||
const body = await authed.json();
|
||||
expect(body.errorCode).toBe('PASSWORD_CHANGE_REQUIRED');
|
||||
const cookie = cookieFrom(authed);
|
||||
expect(cookie).toBeTruthy();
|
||||
|
||||
// /api/me is exempt.
|
||||
const me = await fetch(url('/api/me'), { headers: { Cookie: cookie! } });
|
||||
expect(me.status).toBe(200);
|
||||
expect((await me.json()).data.mustChangePassword).toBe(true);
|
||||
|
||||
// Wrong current password is refused.
|
||||
const bad = await fetch(url('/api/me/password'), {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie!, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword: 'nope', newPassword: 'brandnew123' }),
|
||||
});
|
||||
expect(bad.status).toBe(403);
|
||||
|
||||
// Correct change clears the flag.
|
||||
const ok = await fetch(url('/api/me/password'), {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie!, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword: 'davepass12', newPassword: 'brandnew123' }),
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
// Same cookie now reaches a non-exempt route.
|
||||
const after = await fetch(url('/api/status'), { headers: { Cookie: cookie! } });
|
||||
expect(after.status).toBe(200);
|
||||
});
|
||||
|
||||
it('verify-first: a correct password is never rate-limited and self-heals failures (#17)', async () => {
|
||||
rateServer = new WebServer(RATE_PORT, false, true);
|
||||
await rateServer.start();
|
||||
const rurl = (p: string) => `http://localhost:${RATE_PORT}${p}`;
|
||||
|
||||
// Nine wrong passwords (one below the cap) are each rejected 401 — not throttled yet.
|
||||
for (let i = 0; i < AUTH_FAILURE_MAX - 1; i++) {
|
||||
const res = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', `bad-${i}`) } });
|
||||
expect(res.status).toBe(401);
|
||||
}
|
||||
// Finding #17: the CORRECT password must ALWAYS win (verified BEFORE the per-username
|
||||
// throttle) — the accumulated failures can never lock the account out — and success
|
||||
// clears the failure buckets. Previously this returned 429 (the DoS being fixed).
|
||||
const good = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'bobpass123') } });
|
||||
expect(good.status).toBe(200);
|
||||
// Self-heal: a fresh wrong attempt is 401 again (the counter was reset by the success).
|
||||
const afterReset = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'nope') } });
|
||||
expect(afterReset.status).toBe(401);
|
||||
|
||||
// Sustained wrong passwords ARE still throttled: 429 once the cap is reached.
|
||||
let limited = false;
|
||||
for (let i = 0; i < AUTH_FAILURE_MAX + 1 && !limited; i++) {
|
||||
const res = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', `x-${i}`) } });
|
||||
limited = res.status === 429;
|
||||
}
|
||||
expect(limited).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QR token identity (tunnel-manager)', () => {
|
||||
it('binds a minted token to a user and returns it on consume (single-use)', () => {
|
||||
const tm = new TunnelManager();
|
||||
const code = tm.mintUserToken('alice');
|
||||
expect(code).toHaveLength(6);
|
||||
const first = tm.consumeTokenWithIdentity(code);
|
||||
expect(first).toEqual({ ok: true, username: 'alice' });
|
||||
// single-use
|
||||
expect(tm.consumeTokenWithIdentity(code)).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it('unknown code is rejected', () => {
|
||||
const tm = new TunnelManager();
|
||||
expect(tm.consumeTokenWithIdentity('ZZZZZZ')).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,13 @@ describe('isAllowedRequestHost — anti-DNS-rebinding', () => {
|
||||
expect(isAllowedRequestHost('eviltrycloudflare.com', loopback)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the docker/podman container-to-host gateway aliases (in-container hooks)', () => {
|
||||
expect(isAllowedRequestHost('host.docker.internal:3000', loopback)).toBe(true);
|
||||
expect(isAllowedRequestHost('host.containers.internal:3000', loopback)).toBe(true);
|
||||
// a lookalike is still rejected (exact match only)
|
||||
expect(isAllowedRequestHost('host.docker.internal.evil.com', loopback)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the configured bind host when it is a hostname', () => {
|
||||
const policy: HostPolicy = { bindHost: 'mybox.local', allowedHosts: [], tunnelHost: null };
|
||||
expect(isAllowedRequestHost('mybox.local:3000', policy)).toBe(true);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @fileoverview Phase 3 ownership-scoping tests (live server, port 3172).
|
||||
*
|
||||
* Verifies multi-user isolation at the API level: case lists are disjoint per user,
|
||||
* a non-admin cannot read/kill another user's session, workingDir confinement +
|
||||
* shell gate + host-CRUD admin gate are enforced, and admins see everything.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
||||
import { canAccessOwned, findSessionOrFail, sessionCapacityState } from '../src/web/route-helpers.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const PORT = 3172;
|
||||
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
|
||||
|
||||
let server: WebServer;
|
||||
let dataDir: string;
|
||||
let spacesDir: string;
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
||||
|
||||
// Route returns are wrapped in the {success,data} envelope; unwrap to the payload.
|
||||
async function getJson(p: string, headers: Record<string, string>): Promise<unknown> {
|
||||
const body = await (await fetch(url(p), { headers })).json();
|
||||
return body && typeof body === 'object' && 'data' in body ? (body as { data: unknown }).data : body;
|
||||
}
|
||||
const alice = { Authorization: basic('alice', 'alicepass1') };
|
||||
const bob = { Authorization: basic('bob', 'bobpass1234') };
|
||||
const admin = { Authorization: basic('root', 'rootpass123') };
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'own-data-'));
|
||||
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'own-spaces-'));
|
||||
for (const k of [
|
||||
'CODEMAN_DATA_DIR',
|
||||
'CODEMAN_USER_SPACES_DIR',
|
||||
'CODEMAN_MULTIUSER',
|
||||
'CODEMAN_PASSWORD',
|
||||
'CODEMAN_USERNAME',
|
||||
]) {
|
||||
saved[k] = process.env[k];
|
||||
}
|
||||
process.env.CODEMAN_DATA_DIR = dataDir;
|
||||
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
invalidateUsersCache();
|
||||
|
||||
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
|
||||
await createUser({ username: 'alice', role: 'user', password: 'alicepass1' });
|
||||
await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' });
|
||||
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
invalidateUsersCache();
|
||||
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
describe('case scoping', () => {
|
||||
it('creates cases in per-user spaces and lists them disjointly', async () => {
|
||||
const mk = await fetch(url('/api/cases'), {
|
||||
method: 'POST',
|
||||
headers: { ...alice, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'aliceproj' }),
|
||||
});
|
||||
expect(mk.status).toBe(200);
|
||||
|
||||
// Case folder is under alice's space.
|
||||
expect(await exists(path.join(spacesDir, 'alice', 'cases', 'aliceproj'))).toBe(true);
|
||||
|
||||
const aliceList = (await getJson('/api/cases', alice)) as Array<{ name: string }>;
|
||||
expect(aliceList.map((c) => c.name)).toContain('aliceproj');
|
||||
|
||||
const bobList = (await getJson('/api/cases', bob)) as Array<{ name: string }>;
|
||||
expect(bobList.map((c) => c.name)).not.toContain('aliceproj');
|
||||
});
|
||||
});
|
||||
|
||||
describe('host CRUD is admin-only', () => {
|
||||
it('rejects a non-admin defining a docker host', async () => {
|
||||
const res = await fetch(url('/api/docker-hosts'), {
|
||||
method: 'POST',
|
||||
headers: { ...bob, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: 'h1', label: 'x', image: 'codeman/agent:base' }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows an admin to list docker hosts', async () => {
|
||||
const res = await fetch(url('/api/docker-hosts'), { headers: admin });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session creation gates', () => {
|
||||
it('confines a non-admin workingDir to their space', async () => {
|
||||
const foreign = path.join(spacesDir, 'alice', 'cases', 'aliceproj');
|
||||
const res = await fetch(url('/api/sessions'), {
|
||||
method: 'POST',
|
||||
headers: { ...bob, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: foreign }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses shell mode for a non-granted user', async () => {
|
||||
const mine = path.join(spacesDir, 'bob', 'cases');
|
||||
await fs.mkdir(mine, { recursive: true });
|
||||
const res = await fetch(url('/api/sessions'), {
|
||||
method: 'POST',
|
||||
headers: { ...bob, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: mine, mode: 'shell' }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// The session-scoping logic (findSessionOrFail owner check, list filter, per-user
|
||||
// cap) is tested directly against the helpers under the same multi-user env, since
|
||||
// real session spawning is no-op'd in test mode and does not durably populate the
|
||||
// live map. These are the exact functions every session route uses.
|
||||
describe('session-scoping helpers (multi-user)', () => {
|
||||
const fakeSession = (owner?: string) => ({ owner }) as unknown as import('../src/session.js').Session;
|
||||
const ctxWith = (map: Map<string, unknown>) => ({ sessions: map }) as never;
|
||||
const reqAs = (username: string, role: 'admin' | 'user') => ({ authUser: { username, role } }) as never;
|
||||
|
||||
it('canAccessOwned isolates non-admins to their own', () => {
|
||||
expect(canAccessOwned({ username: 'alice', role: 'user' }, 'alice')).toBe(true);
|
||||
expect(canAccessOwned({ username: 'alice', role: 'user' }, 'bob')).toBe(false);
|
||||
expect(canAccessOwned({ username: 'alice', role: 'user' }, undefined)).toBe(false);
|
||||
expect(canAccessOwned({ username: 'root', role: 'admin' }, 'bob')).toBe(true);
|
||||
});
|
||||
|
||||
it('findSessionOrFail 404s a foreign session for a non-admin, returns it for owner/admin', () => {
|
||||
const map = new Map<string, unknown>([['s1', fakeSession('alice')]]);
|
||||
expect(() => findSessionOrFail(ctxWith(map), 's1', reqAs('bob', 'user'))).toThrow();
|
||||
expect(findSessionOrFail(ctxWith(map), 's1', reqAs('alice', 'user'))).toBeDefined();
|
||||
expect(findSessionOrFail(ctxWith(map), 's1', reqAs('root', 'admin'))).toBeDefined();
|
||||
});
|
||||
|
||||
it('per-user session cap counts only the owner sessions', () => {
|
||||
const map = new Map<string, unknown>([
|
||||
['a', fakeSession('alice')],
|
||||
['b', fakeSession('alice')],
|
||||
['c', fakeSession('bob')],
|
||||
]);
|
||||
process.env.CODEMAN_MAX_SESSIONS_PER_USER = '2';
|
||||
expect(sessionCapacityState(map as never, 'alice').atUserCap).toBe(true);
|
||||
expect(sessionCapacityState(map as never, 'bob').atUserCap).toBe(false);
|
||||
delete process.env.CODEMAN_MAX_SESSIONS_PER_USER;
|
||||
});
|
||||
});
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -174,8 +174,8 @@ describe('scheduled-routes', () => {
|
||||
// Bare { run } return (envelope-wrapped to { success:true, data:{ run } }
|
||||
// in production; harness sees the bare return).
|
||||
expect(body.run).toBeDefined();
|
||||
// Should default to 60 minutes
|
||||
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60);
|
||||
// Should default to 60 minutes; 4th arg is the multi-user owner (undefined in single-user).
|
||||
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -225,7 +225,11 @@ describe('system-routes', () => {
|
||||
harness.ctx._session.status = 'working';
|
||||
harness.ctx.store.getDailyStats.mockReturnValue([
|
||||
{
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
// LOCAL date (not toISOString/UTC): away-digest's dayOverlapsRange parses
|
||||
// the date as local midnight, so a UTC date near the local-midnight boundary
|
||||
// (e.g. running at 01:xx CEST = prior-day UTC) would fall outside the 1h
|
||||
// window and make this assertion TZ/hour-flaky.
|
||||
date: new Date().toLocaleDateString('en-CA'),
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
estimatedCost: 0.02,
|
||||
|
||||
@@ -138,6 +138,8 @@ describe('Codex quick start settings', () => {
|
||||
expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
|
||||
caseName: 'codex-case',
|
||||
mode: 'codex',
|
||||
// tabs follow the w<n>-<case> naming convention (quick-start would otherwise auto-name codeman-<id>)
|
||||
sessionName: 'w1-codex-case',
|
||||
codexConfig: { dangerouslyBypassApprovals: true, renderMode: 'hybrid' },
|
||||
});
|
||||
expect(selected).toEqual(['sess-1']);
|
||||
@@ -179,6 +181,30 @@ describe('case selector refresh', () => {
|
||||
expect(app.filterCasePickerOptions(options, 'plex').map((option: any) => option.name)).toEqual(['plex-previews']);
|
||||
});
|
||||
|
||||
it('labels dockerized cases with a short "(docker)" tag (or the custom host id)', () => {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: () => null },
|
||||
console,
|
||||
});
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
const app = new (CodemanApp as any)();
|
||||
|
||||
const label = (c: any) => app.formatCasePickerLabel(c);
|
||||
// default one-click host, the 'local' Docker-tab default, and per-case override
|
||||
// hosts all collapse to the short "(docker)" tag.
|
||||
expect(
|
||||
label({ name: 'sandbox', location: 'docker', docker: { hostId: 'default', container: 'codeman-case-sandbox' } })
|
||||
).toBe('sandbox (docker)');
|
||||
expect(label({ name: 'sandbox', location: 'docker', docker: { hostId: 'local' } })).toBe('sandbox (docker)');
|
||||
expect(label({ name: 'sandbox', location: 'docker', docker: { hostId: 'q-sandbox' } })).toBe('sandbox (docker)');
|
||||
// a user-named docker host shows its id
|
||||
expect(label({ name: 'ml', location: 'docker', docker: { hostId: 'gpu-box' } })).toBe('ml (gpu-box)');
|
||||
});
|
||||
|
||||
it('launches the highlighted case with the current run mode when pressing Enter in the picker', () => {
|
||||
const elements: Record<string, any> = {};
|
||||
const listeners: Record<string, (event: any) => void> = {};
|
||||
|
||||
+10
-2
@@ -375,9 +375,17 @@ describe('types utility functions', () => {
|
||||
expect(ApiErrorCode.INTERNAL_ERROR).toBe('INTERNAL_ERROR');
|
||||
});
|
||||
|
||||
it('should have 9 error codes', () => {
|
||||
it('should have 14 error codes', () => {
|
||||
const codes = Object.values(ApiErrorCode);
|
||||
expect(codes).toHaveLength(9);
|
||||
expect(codes).toHaveLength(14);
|
||||
});
|
||||
|
||||
it('includes the multi-user error codes', () => {
|
||||
expect(ApiErrorCode.FORBIDDEN).toBe('FORBIDDEN');
|
||||
expect(ApiErrorCode.PASSWORD_CHANGE_REQUIRED).toBe('PASSWORD_CHANGE_REQUIRED');
|
||||
expect(ApiErrorCode.USER_EXISTS).toBe('USER_EXISTS');
|
||||
expect(ApiErrorCode.USER_NOT_FOUND).toBe('USER_NOT_FOUND');
|
||||
expect(ApiErrorCode.LAST_ADMIN).toBe('LAST_ADMIN');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the multi-user store (src/user-store.ts).
|
||||
*
|
||||
* Pure helpers (hashing/verify/params-upgrade/username validation/6.3 resolvers)
|
||||
* plus the IO layer against a per-test temp data dir (CODEMAN_DATA_DIR) so nothing
|
||||
* touches the real ~/.codeman. No server, no tmux.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
bootstrapInitialAdmin,
|
||||
canRunPrivilegedCommands,
|
||||
countEnabledAdmins,
|
||||
createUser,
|
||||
DEFAULT_SCRYPT_PARAMS,
|
||||
deleteUser,
|
||||
deleteUserSpace,
|
||||
findUser,
|
||||
generateOneTimePassword,
|
||||
hashPassword,
|
||||
hasUsers,
|
||||
invalidateUsersCache,
|
||||
isValidUsername,
|
||||
needsRehash,
|
||||
normalizeUsername,
|
||||
readUsers,
|
||||
resolveClaudeModeForUser,
|
||||
setPassword,
|
||||
toPublicUser,
|
||||
touchLastLogin,
|
||||
updateUser,
|
||||
UserStoreError,
|
||||
verifyPasswordHash,
|
||||
} from '../src/user-store.js';
|
||||
|
||||
let tmpDir: string;
|
||||
let spacesDir: string;
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-users-'));
|
||||
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-spaces-'));
|
||||
for (const k of [
|
||||
'CODEMAN_DATA_DIR',
|
||||
'CODEMAN_USER_SPACES_DIR',
|
||||
'CODEMAN_MULTIUSER',
|
||||
'CODEMAN_MAX_USERS',
|
||||
'CODEMAN_USERNAME',
|
||||
'CODEMAN_PASSWORD',
|
||||
]) {
|
||||
savedEnv[k] = process.env[k];
|
||||
}
|
||||
process.env.CODEMAN_DATA_DIR = tmpDir;
|
||||
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
||||
delete process.env.CODEMAN_MAX_USERS;
|
||||
invalidateUsersCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const [k, v] of Object.entries(savedEnv)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
invalidateUsersCache();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
describe('username validation', () => {
|
||||
it('accepts valid slugs', () => {
|
||||
for (const n of ['alice', 'bob99', 'a1', 'x_y-z', 'user-name_1']) {
|
||||
expect(isValidUsername(n)).toBe(true);
|
||||
}
|
||||
});
|
||||
it('rejects invalid slugs', () => {
|
||||
for (const n of [
|
||||
'',
|
||||
'a',
|
||||
'A',
|
||||
'1',
|
||||
'_leading',
|
||||
'-leading',
|
||||
'has space',
|
||||
'has.dot',
|
||||
'a/b',
|
||||
'..',
|
||||
'toolongxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
]) {
|
||||
expect(isValidUsername(n)).toBe(false);
|
||||
}
|
||||
});
|
||||
it('accepts mixed-case input by normalizing (case-insensitive usernames)', () => {
|
||||
expect(isValidUsername('Alice')).toBe(true);
|
||||
expect(normalizeUsername(' ALICE ')).toBe('alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('password hashing', () => {
|
||||
it('round-trips a correct password and rejects a wrong one', async () => {
|
||||
const h = await hashPassword('correct horse');
|
||||
expect(h.algo).toBe('scrypt');
|
||||
expect(h.salt).toMatch(/^[0-9a-f]+$/);
|
||||
expect(await verifyPasswordHash('correct horse', h)).toBe(true);
|
||||
expect(await verifyPasswordHash('wrong password', h)).toBe(false);
|
||||
});
|
||||
it('produces a distinct salt each time', async () => {
|
||||
const a = await hashPassword('same');
|
||||
const b = await hashPassword('same');
|
||||
expect(a.salt).not.toBe(b.salt);
|
||||
expect(a.hash).not.toBe(b.hash);
|
||||
});
|
||||
it('never throws on a malformed record', async () => {
|
||||
expect(await verifyPasswordHash('x', { algo: 'scrypt', N: 1, r: 1, p: 1, salt: 'zz', hash: '' })).toBe(false);
|
||||
// @ts-expect-error deliberately malformed
|
||||
expect(await verifyPasswordHash('x', { algo: 'bogus' })).toBe(false);
|
||||
});
|
||||
it('needsRehash detects weaker params', async () => {
|
||||
const h = await hashPassword('pw', DEFAULT_SCRYPT_PARAMS);
|
||||
expect(needsRehash(h)).toBe(false);
|
||||
expect(needsRehash({ ...h, N: 1024 })).toBe(true);
|
||||
expect(needsRehash({ ...h, algo: 'md5' as unknown as 'scrypt' })).toBe(true);
|
||||
});
|
||||
it('generateOneTimePassword returns a >=8 char url-safe string', () => {
|
||||
const pw = generateOneTimePassword();
|
||||
expect(pw.length).toBeGreaterThanOrEqual(8);
|
||||
expect(pw).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClaudeModeForUser (section 6.3)', () => {
|
||||
it('admins are unrestricted', () => {
|
||||
expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'admin' })).toBe(
|
||||
'dangerously-skip-permissions'
|
||||
);
|
||||
});
|
||||
it('granted regular users keep bypass', () => {
|
||||
expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'user', canBypassPermissions: true })).toBe(
|
||||
'dangerously-skip-permissions'
|
||||
);
|
||||
});
|
||||
it('non-granted regular users downgrade skip -> auto', () => {
|
||||
expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'user' })).toBe('auto');
|
||||
expect(resolveClaudeModeForUser(undefined, { role: 'user' })).toBe('auto');
|
||||
});
|
||||
it('non-granted regular users pass through modes already <= auto', () => {
|
||||
expect(resolveClaudeModeForUser('auto', { role: 'user' })).toBe('auto');
|
||||
expect(resolveClaudeModeForUser('normal', { role: 'user' })).toBe('normal');
|
||||
expect(resolveClaudeModeForUser('allowedTools', { role: 'user' })).toBe('allowedTools');
|
||||
});
|
||||
it('canRunPrivilegedCommands follows the same grant', () => {
|
||||
expect(canRunPrivilegedCommands({ role: 'admin' })).toBe(true);
|
||||
expect(canRunPrivilegedCommands({ role: 'user', canBypassPermissions: true })).toBe(true);
|
||||
expect(canRunPrivilegedCommands({ role: 'user' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('user store IO', () => {
|
||||
it('creates, reads back, and writes users.json at mode 0600 atomically', async () => {
|
||||
expect(await hasUsers()).toBe(false);
|
||||
const u = await createUser({ username: 'Alice', role: 'admin', password: 'password1' });
|
||||
expect(u.username).toBe('alice');
|
||||
expect(u.role).toBe('admin');
|
||||
expect(await hasUsers()).toBe(true);
|
||||
|
||||
const file = path.join(tmpDir, 'users.json');
|
||||
expect(existsSync(file)).toBe(true);
|
||||
// 0600 on POSIX
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(file).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
// no leftover tmp file
|
||||
expect(existsSync(file + '.tmp')).toBe(false);
|
||||
|
||||
const found = await findUser('ALICE');
|
||||
expect(found?.username).toBe('alice');
|
||||
expect(toPublicUser(found!)).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('rejects duplicate usernames case-insensitively', async () => {
|
||||
await createUser({ username: 'bob', role: 'user', password: 'password1' });
|
||||
await expect(createUser({ username: 'BOB', role: 'user', password: 'password2' })).rejects.toMatchObject({
|
||||
code: 'USER_EXISTS',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid username and short password', async () => {
|
||||
await expect(createUser({ username: 'Bad Name', role: 'user', password: 'password1' })).rejects.toBeInstanceOf(
|
||||
UserStoreError
|
||||
);
|
||||
await expect(createUser({ username: 'good', role: 'user', password: 'short' })).rejects.toMatchObject({
|
||||
code: 'INVALID_INPUT',
|
||||
});
|
||||
});
|
||||
|
||||
it('enforces MAX_USERS', async () => {
|
||||
process.env.CODEMAN_MAX_USERS = '2';
|
||||
await createUser({ username: 'a1', role: 'admin', password: 'password1' });
|
||||
await createUser({ username: 'a2', role: 'user', password: 'password1' });
|
||||
await expect(createUser({ username: 'a3', role: 'user', password: 'password1' })).rejects.toMatchObject({
|
||||
code: 'INVALID_INPUT',
|
||||
});
|
||||
});
|
||||
|
||||
it('setPassword changes the hash and can clear mustChangePassword', async () => {
|
||||
await createUser({ username: 'carol', role: 'user', password: 'password1', mustChangePassword: true });
|
||||
const before = await findUser('carol');
|
||||
expect(before?.mustChangePassword).toBe(true);
|
||||
await setPassword('carol', 'password2', { mustChangePassword: false });
|
||||
const after = await findUser('carol');
|
||||
expect(after?.mustChangePassword).toBe(false);
|
||||
expect(await verifyPasswordHash('password2', after!.password)).toBe(true);
|
||||
expect(await verifyPasswordHash('password1', after!.password)).toBe(false);
|
||||
});
|
||||
|
||||
it('touchLastLogin records a timestamp', async () => {
|
||||
await createUser({ username: 'dave', role: 'user', password: 'password1' });
|
||||
expect((await findUser('dave'))?.lastLoginAt).toBeUndefined();
|
||||
await touchLastLogin('dave');
|
||||
expect((await findUser('dave'))?.lastLoginAt).toBeTypeOf('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('last-admin invariants', () => {
|
||||
it('cannot demote the last enabled admin', async () => {
|
||||
await createUser({ username: 'root', role: 'admin', password: 'password1' });
|
||||
await createUser({ username: 'joe', role: 'user', password: 'password1' });
|
||||
expect(countEnabledAdmins(await readUsers(true))).toBe(1);
|
||||
await expect(updateUser('root', { role: 'user' })).rejects.toMatchObject({ code: 'LAST_ADMIN' });
|
||||
await expect(updateUser('root', { disabled: true })).rejects.toMatchObject({ code: 'LAST_ADMIN' });
|
||||
});
|
||||
|
||||
it('cannot delete the last enabled admin', async () => {
|
||||
await createUser({ username: 'root', role: 'admin', password: 'password1' });
|
||||
await expect(deleteUser('root')).rejects.toMatchObject({ code: 'LAST_ADMIN' });
|
||||
});
|
||||
|
||||
it('allows demote/delete when another admin remains', async () => {
|
||||
await createUser({ username: 'root', role: 'admin', password: 'password1' });
|
||||
await createUser({ username: 'root2', role: 'admin', password: 'password1' });
|
||||
await expect(updateUser('root', { role: 'user' })).resolves.toMatchObject({ role: 'user' });
|
||||
await createUser({ username: 'root3', role: 'admin', password: 'password1' });
|
||||
await expect(deleteUser('root2')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('updateUser toggles canBypassPermissions', async () => {
|
||||
await createUser({ username: 'grantee', role: 'user', password: 'password1' });
|
||||
const updated = await updateUser('grantee', { canBypassPermissions: true });
|
||||
expect(updated.canBypassPermissions).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUserSpace guards (section 8)', () => {
|
||||
it('deletes a real space dir inside USER_SPACES_DIR', async () => {
|
||||
const dir = path.join(spacesDir, 'ed', 'cases', 'proj');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(spacesDir, 'ed', 'cases', 'proj', 'f.txt'), 'x');
|
||||
expect(existsSync(path.join(spacesDir, 'ed'))).toBe(true);
|
||||
await deleteUserSpace('ed');
|
||||
expect(existsSync(path.join(spacesDir, 'ed'))).toBe(false);
|
||||
});
|
||||
|
||||
it('is a no-op when the space does not exist', async () => {
|
||||
await expect(deleteUserSpace('ghost')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses to delete a symlinked user space', async () => {
|
||||
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-outside-'));
|
||||
await fs.symlink(outside, path.join(spacesDir, 'evil'));
|
||||
await expect(deleteUserSpace('evil')).rejects.toMatchObject({ code: 'INVALID_INPUT' });
|
||||
// the symlink target still exists (was not followed + removed)
|
||||
expect(existsSync(outside)).toBe(true);
|
||||
await fs.rm(outside, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('bootstrapInitialAdmin', () => {
|
||||
it('creates the initial admin from env when no users exist', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
process.env.CODEMAN_USERNAME = 'boss';
|
||||
process.env.CODEMAN_PASSWORD = 'password1';
|
||||
const r = await bootstrapInitialAdmin();
|
||||
expect(r).toMatchObject({ status: 'created', username: 'boss' });
|
||||
expect((await findUser('boss'))?.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('reports missing-env when no users and no credentials', async () => {
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
expect(await bootstrapInitialAdmin()).toMatchObject({ status: 'missing-env' });
|
||||
});
|
||||
|
||||
it('reports exists when users already present', async () => {
|
||||
await createUser({ username: 'someone', role: 'admin', password: 'password1' });
|
||||
expect(await bootstrapInitialAdmin()).toMatchObject({ status: 'exists' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user