diff --git a/.changeset/multiuser-mode.md b/.changeset/multiuser-mode.md new file mode 100644 index 00000000..2b48b54c --- /dev/null +++ b/.changeset/multiuser-mode.md @@ -0,0 +1,11 @@ +--- +'aicodeman': minor +--- + +Opt-in multi-user mode (`--multiuser` / `CODEMAN_MULTIUSER=1`, off by default). + +Named users with individually scrypt-hashed passwords in `~/.codeman/users.json`, per-user case spaces under `~/codeman-users//cases`, and ownership scoping of sessions (create/list/delete/mutate, incl. bulk delete), cases, cron jobs + run history, scheduled runs, search, file previews, session history, away digest, subagent/workflow monitors, and real-time SSE/WS streams (including the debounced session/task update path, clipboard, and push notifications). A non-admin's `workingDir` is realpath-confined to their own space at every spawn/link path (session create, quick-start, cron create/fire, scheduled runs, case link/docker-link, docker import). Non-admin users default to Claude's classifier-guarded `--permission-mode auto`; raw shell mode, cron `launchCommand`, skip-permissions, and the Codex/Gemini bypass switches require an explicit per-user `canBypassPermissions` grant (enforced at every spawn site incl. one-shots, plan generation, scheduled runs, and remote launches). Machine-level resources (remote/Docker hosts + host reads, mux sessions, orchestrator, tunnel, self-update, settings) are admin-only. Admin API (`/api/admin/users*`) with one-time passwords, last-admin invariants (validated before any teardown), and an append-only audit log; self-service `/api/me` + password change; a frontend admin Users tab + change-password modal; and `codeman users add|passwd|list|rm` CLI. Also adds a global `auto` Claude startup permission mode. When off, behavior is byte-identical to single-user. + +Auth hardening: the login throttle verifies the password before consulting the per-account failure bucket (a correct password can never be locked out); the `mustChangePassword` lockbox covers the WebSocket terminal; the cookie fast-path re-validates identity against the store each request (so a CLI/admin delete/disable/demote takes effect promptly); a role/grant change revokes the target's sessions. (Known limitation: a bare CLI `codeman users passwd` reset — no delete — does not by itself revoke an already-active cookie until it expires; use `codeman users rm`, the admin API, or a restart to force-revoke.) Data-integrity hardening: the store distinguishes a missing users file from a corrupt/unreadable one (so a transient read error can't overwrite all accounts) and writes via a unique per-process temp file; the earlier fire-and-forget `touchLastLogin` corruption race is serialized. + +Note: multi-user mode separates workspaces for a trusted team; it is not a security boundary between users (all sessions share the host OS account). Pair with Docker cases for real isolation. diff --git a/CLAUDE.md b/CLAUDE.md index 042f5b98..f3272a11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,10 +192,12 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups _identical_ in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals. **Codex generated artifacts** (COD-166/#150, `generated-artifact-attachments.ts`): codex-mode sessions ALSO scan (ANSI-stripped) output for `Saved to: file:///…` lines and surface those files as attachment cards with a relaxed trust policy — the allow decision runs on the **realpath-resolved** path against `os.homedir()`-anchored `~/.codex` marker dirs (symlink escapes fall back to force-confinement); gated to `mode === 'codex'` only (`source` is a REQUIRED param through the listener-deps chain — a dropped arg here silently kills the feature). Image thumbnails pass through jpg/jpeg/gif/webp. -**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects///workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_/` (journal.jsonl + agent-_.jsonl). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf\__.json`appears and supersedes, and broadcasts SSE`workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents`**or**`ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()`returns`(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows`(optional`?minutes=`filter) and`GET /api/workflows/:runId`. Frontend `ultracode-panel.js`renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side`agentId`join). **Additionally**,`ultracode-windows.js`auto-pops a draggable **floating window per active run** (gated on a **DEDICATED**`ultracodeFloatingWindows`toggle, default OFF — independent of the dock panel's`showUltracodeAgents`; see `\_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines`SVG from the tail of`\_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA`badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a`window`grab kind in`entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`. +**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects///workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_/` (journal.jsonl + agent-\_.jsonl). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf\__.json`appears and supersedes, and broadcasts SSE`workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents`**or**`ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()`returns`(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows`(optional`?minutes=`filter) and`GET /api/workflows/:runId`. Frontend `ultracode-panel.js`renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side`agentId`join). **Additionally**,`ultracode-windows.js`auto-pops a draggable **floating window per active run** (gated on a **DEDICATED**`ultracodeFloatingWindows`toggle, default OFF — independent of the dock panel's`showUltracodeAgents`; see `\_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines`SVG from the tail of`\_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA`badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a`window`grab kind in`entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`. **Cross-session search** (COD-113/#133): `GET /api/search?q=&types=&limit=` federates an **in-memory** search across all live sessions — session metadata (name/workingDir/id), run-summary events, and per-session attachment-history file entries (workspace-relative path only; the server-private `externalPath` is never read). Pure core `searchSources()` in `search-service.ts` (substring-matches with hard per-type caps — no regex, so no ReDoS; no filesystem reads, so no traversal); `harvestSources()` in `search-routes.ts` gathers the in-memory sources. `SearchQuerySchema` bounds `q` (1–200), allowlists `types` (`session,event,file`), clamps `limit` (1–60). Returns the `{success,data}` envelope. Frontend: history-panel search box in `terminal-ui.js`. Types: `src/types/search.ts`. +**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default; branch `feat/multiuser-mode`, design `docs/multi-user-plan.md`): named users with individually scrypt-hashed passwords in `~/.codeman/users.json` (via `src/user-store.ts`: atomic 0600 write, short-TTL cache, SERIALIZED read-modify-write so a fire-and-forget `touchLastLogin` can't clobber a concurrent route write, last-admin invariants). Gated everywhere by `isMultiUserMode()` (`src/config/multiuser.ts`); when OFF, behavior is byte-identical to single-user (all scoping helpers short-circuit). ⚠️ **Not a security boundary at the agent layer** — every session still runs as the SAME OS account; this separates WORKSPACES, it does not sandbox users (Docker cases are the isolation story). Auth: a PARALLEL async branch in `middleware/auth.ts` (single-user branch untouched) verifies `username:password` against the store, mints identity-carrying cookies (`AuthSessionRecord` gains `username`/`role`/`mustChangePassword`), decorates `req.authUser` (Fastify augmentation; single-user leaves it undefined and the ownership helpers default to a synthetic admin), enforces a per-username failure bucket + the `mustChangePassword` lockbox. Ownership threads through `Session.owner` (stamped from `req.authUser`/`job.owner` at every `new Session()`, round-tripped via `MuxSession.owner` on recovery); `findSessionOrFail(ctx,id,req)` does a NOT_FOUND owner check; list endpoints + `getLightState` + SSE (`deriveSseHint` routes session-scoped events by owner, fail-closed; machine-level + host-plan telemetry admin-only) + WS + search + file-preview all filter by owner. §6.3 permission policy: non-granted users are forced to `--permission-mode auto` (via `resolveClaudeModeForUser` at all spawn sites, incl. one-shots because `buildPromptArgs` now respects the session mode), and shell mode / cron `launchCommand` require the `canBypassPermissions` grant. Cases live in per-user `~/codeman-users//cases` (`resolveCasesDir`); a non-admin's `workingDir` is realpath-confined there; host CRUD is admin-only. Admin API `src/web/routes/admin-routes.ts` (`/api/admin/users*`, one-time passwords, audit log `admin-audit.jsonl`) + self-service `/api/me` + `/api/me/password` (`me-routes.ts`); frontend `public/admin-ui.js` (identity boot, change-password modal + interceptor, admin Users tab). CLI `codeman users add|passwd|list|rm`. Per-user session cap via `sessionCapacityState`/`sessionCapacityMessage`. Tests: `test/user-store.test.ts`, `test/multiuser-auth.test.ts`, `test/ownership-scoping.test.ts`, `test/admin-routes.test.ts`, `test/admin-ui.test.ts`. + **Away digest** (COD-41/#136): `GET /api/away-digest?range=&since=&until=&lastViewed=` aggregates "what happened while you were away" from the lifecycle log + run-summary events + live sessions + daily token stats + recently-completed subagents into needs-attention/completed/still-running/idle/informational sections. Pure aggregator in `web/away-digest.ts` (`resolveAwayDigestRange()` validates the window — `since-last-visit`/`1h`/`today`/`24h`/`custom`, server-local TZ; `buildAwayDigest()` classifies). Header-button modal in `panels-ui.js` (button hidden on phones — regression-guarded). ⚠️ Returns `{success:true,digest}` (a legacy raw-ish shape, consistent with the other raw GET handlers in `system-routes.ts` — `{entries}`/`{config}`/`{files}`/`getSystemStats()`); frontend + tests read `.digest`. Subagent lookback is a fixed 60-min window regardless of range. **Ralph todo-config** (COD-79/#135): per-session `maxTodos` (FIFO-eviction cap, default 500 = `MAX_TODOS_PER_SESSION`) + `todoExpirationMinutes` (auto-expiry, default 60) set via `POST /api/sessions/:id/ralph-config` (`RalphConfigSchema`, both `.int().positive()`). Stored on the tracker (`setMaxTodos`/`setTodoExpirationMinutes`) and **persisted/read-back via `RalphTrackerState`** (surfaced in the `loopState` getter → `toState()` + SSE broadcast → modal `populateRalphForm`), mirroring how `maxIterations` round-trips. Claude-only (skipped by `isExternalCliMode`). @@ -267,7 +269,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ## State Files -All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, `cronJobs`/`cronJobRuns`), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` (VAPID), `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `linked-cases.json` (linked-case registry used for case-path resolution), `remote-hosts.json` + `remote-cases.json` (remote SSH hosts/cases, COD-94), `docker-hosts.json` + `docker-cases.json` (docker hosts/cases, 1.4.0) + `docker-exports/` (portable container bundles), `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance hook secret, COD-54), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI). Transient: `self-update-runner.sh`. +All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, `cronJobs`/`cronJobRuns`), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` (VAPID), `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `linked-cases.json` (linked-case registry used for case-path resolution), `remote-hosts.json` + `remote-cases.json` (remote SSH hosts/cases, COD-94), `docker-hosts.json` + `docker-cases.json` (docker hosts/cases, 1.4.0) + `docker-exports/` (portable container bundles), `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance hook secret, COD-54), `users.json` (multi-user accounts, scrypt hashes, mode 0600) + `admin-audit.jsonl` (multi-user admin action log), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). **Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored. diff --git a/README.md b/README.md index cef587d0..5387bca1 100644 --- a/README.md +++ b/README.md @@ -392,6 +392,26 @@ Prerequisite: Docker (or Podman) and the base image — build it once with `node --- +## 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//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. diff --git a/docs/multi-user-plan.md b/docs/multi-user-plan.md new file mode 100644 index 00000000..a3438971 --- /dev/null +++ b/docs/multi-user-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//cases/` 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//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 --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": "", + "hash": "", + }, + "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/ + 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//`) 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` with no room for a field: it needs a v2 shape (`{ "version": 2, "cases": { "": { "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//` 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 | 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`); 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/` (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/` 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 [--admin] # prompts for password (hidden input), or --password-stdin +codeman users passwd # reset password +codeman users list +codeman users rm [--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-` 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//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//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. diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 5cdaa1db..eed56507 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -487,6 +487,19 @@ 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//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 | diff --git a/src/cli.ts b/src/cli.ts index 03883bc3..c27e8ded 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -584,7 +584,11 @@ program '--allow-unauthenticated-network', 'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)' ) + .option('--multiuser', 'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)') .action(async (options) => { + // The flag is surfaced to the rest of the process via the env var so + // isMultiUserMode() has a single source of truth (see config/multiuser.ts). + if (options.multiuser) process.env.CODEMAN_MULTIUSER = '1'; const { startWebServer } = await import('./web/server.js'); const host = options.host; const port = parseInt(options.port, 10); @@ -626,6 +630,168 @@ program } }); +// ============ Multi-user Commands ============ +// +// Operate directly on ~/.codeman/users.json (via user-store) with NO running +// server, honoring CODEMAN_INSTANCE. This is the headless bootstrap path and the +// recovery answer to "locked out: last admin forgot password". + +/** Read a password from stdin without echoing. Falls back to plain read on non-TTY. */ +function promptHiddenPassword(question: string): Promise { + const stdin = process.stdin; + if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') { + // Non-interactive: read a single line from stdin. + return new Promise((resolve) => { + let buf = ''; + stdin.setEncoding('utf8'); + stdin.on('data', (d) => (buf += d)); + stdin.on('end', () => resolve(buf.replace(/\r?\n$/, ''))); + }); + } + return new Promise((resolve) => { + process.stdout.write(question); + let input = ''; + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + const onData = (chunk: string) => { + for (const c of chunk) { + if (c === '\n' || c === '\r' || c === '\u0004') { + stdin.setRawMode!(false); + stdin.pause(); + stdin.removeListener('data', onData); + process.stdout.write('\n'); + resolve(input); + return; + } else if (c === '\u0003') { + process.stdout.write('\n'); + process.exit(1); + } else if (c === '\u007f' || c === '\b') { + input = input.slice(0, -1); + } else { + input += c; + } + } + }; + stdin.on('data', onData); + }); +} + +function readAllStdin(): Promise { + return new Promise((resolve) => { + let buf = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (d) => (buf += d)); + process.stdin.on('end', () => resolve(buf.replace(/\r?\n$/, ''))); + }); +} + +const usersCmd = program.command('users').description('Manage multi-user accounts (~/.codeman/users.json)'); + +usersCmd + .command('add ') + .description('Create a user (prompts for password; use --password-stdin for scripts)') + .option('--admin', 'Create as an admin') + .option('--password-stdin', 'Read the password from stdin instead of prompting') + .action(async (name, options) => { + const { createUser, isValidUsername } = await import('./user-store.js'); + if (!isValidUsername(name)) { + console.error(chalk.red('✗ Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])')); + process.exit(1); + } + try { + let password: string; + if (options.passwordStdin) { + password = await readAllStdin(); + } else { + password = await promptHiddenPassword('New password: '); + const confirm = await promptHiddenPassword('Confirm password: '); + if (password !== confirm) { + console.error(chalk.red('✗ Passwords do not match')); + process.exit(1); + } + } + if (!password || password.length < 8) { + console.error(chalk.red('✗ Password must be at least 8 characters')); + process.exit(1); + } + const user = await createUser({ username: name, role: options.admin ? 'admin' : 'user', password }); + console.log(chalk.green(`✓ Created ${user.role} "${user.username}"`)); + } catch (err) { + console.error(chalk.red(`✗ ${getErrorMessage(err)}`)); + process.exit(1); + } + }); + +usersCmd + .command('passwd ') + .description('Reset a user password') + .option('--password-stdin', 'Read the new password from stdin instead of prompting') + .action(async (name, options) => { + const { setPassword } = await import('./user-store.js'); + try { + let password: string; + if (options.passwordStdin) { + password = await readAllStdin(); + } else { + password = await promptHiddenPassword('New password: '); + const confirm = await promptHiddenPassword('Confirm password: '); + if (password !== confirm) { + console.error(chalk.red('✗ Passwords do not match')); + process.exit(1); + } + } + await setPassword(name, password, { mustChangePassword: false }); + console.log(chalk.green(`✓ Password updated for "${name}"`)); + } catch (err) { + console.error(chalk.red(`✗ ${getErrorMessage(err)}`)); + process.exit(1); + } + }); + +usersCmd + .command('list') + .alias('ls') + .description('List all users') + .action(async () => { + const { readUsers } = await import('./user-store.js'); + const users = await readUsers(true); + if (users.length === 0) { + console.log(chalk.yellow('No users defined (run: codeman users add --admin)')); + return; + } + console.log(chalk.bold('\nUsers:')); + for (const u of users) { + const role = u.role === 'admin' ? chalk.magenta('admin') : chalk.cyan('user '); + const state = u.disabled ? chalk.red('disabled') : chalk.green('enabled '); + const flags = [u.mustChangePassword ? 'must-change-pw' : '', u.canBypassPermissions ? 'can-bypass' : ''] + .filter(Boolean) + .join(' '); + console.log(` ${role} ${state} ${u.username}${flags ? chalk.gray(` [${flags}]`) : ''}`); + } + console.log(''); + }); + +usersCmd + .command('rm ') + .description('Delete a user') + .option('--delete-space', "Also delete the user's ~/codeman-users/ space") + .action(async (name, options) => { + const { deleteUser, deleteUserSpace } = await import('./user-store.js'); + try { + await deleteUser(name); + if (options.deleteSpace) { + await deleteUserSpace(name); + console.log(chalk.green(`✓ Deleted user "${name}" and their space`)); + } else { + console.log(chalk.green(`✓ Deleted user "${name}" (space left on disk)`)); + } + } catch (err) { + console.error(chalk.red(`✗ ${getErrorMessage(err)}`)); + process.exit(1); + } + }); + program .command('doctor') .alias('check-deps') diff --git a/src/config/multiuser.ts b/src/config/multiuser.ts new file mode 100644 index 00000000..cc2340b6 --- /dev/null +++ b/src/config/multiuser.ts @@ -0,0 +1,63 @@ +/** + * @fileoverview Multi-user mode gating + limits (opt-in, off by default). + * + * Multi-user mode is enabled by `codeman web --multiuser` (which sets + * `CODEMAN_MULTIUSER=1`) or the env var directly. When OFF, behavior is + * byte-identical to today: `users.json` is never read and all ownership scoping + * is bypassed. Everything here is per-instance like the rest of Codeman: a beta + * instance (`CODEMAN_INSTANCE=beta`) has its own `users.json` via `dataPath()`, + * and its user spaces live under the same shared `~/codeman-users` as prod (like + * `~/codeman-cases`), unless `CODEMAN_USER_SPACES_DIR` overrides it. + * + * See `docs/multi-user-plan.md` sections 3, 4.2, and 11. + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { MAX_CONCURRENT_SESSIONS } from './map-limits.js'; + +/** + * Whether multi-user mode is active. Read from the environment each call so it is + * stable for the process lifetime (env does not change after boot) and trivially + * overridable in tests. Accepts `1` or `true`. + */ +export function isMultiUserMode(): boolean { + const v = process.env.CODEMAN_MULTIUSER; + return v === '1' || v === 'true'; +} + +/** + * Root of per-user spaces: `~/codeman-users` (sibling of `~/codeman-cases`). + * Overridable via `CODEMAN_USER_SPACES_DIR` (used by tests). Resolved lazily so a + * test can point it at a temp dir before the first call. + */ +export function getUserSpacesDir(): string { + return process.env.CODEMAN_USER_SPACES_DIR || join(homedir(), 'codeman-users'); +} + +/** Absolute path to a user's top-level space: `/[/segments]`. */ +export function userSpacePath(username: string, ...segments: string[]): string { + return join(getUserSpacesDir(), username, ...segments); +} + +/** Absolute path to a user's cases dir: `//cases`. */ +export function userCasesDir(username: string): string { + return join(getUserSpacesDir(), username, 'cases'); +} + +/** Maximum number of user accounts (default 25, env `CODEMAN_MAX_USERS`). */ +export function maxUsers(): number { + const n = Number(process.env.CODEMAN_MAX_USERS); + return Number.isInteger(n) && n > 0 ? n : 25; +} + +/** + * Per-user concurrent-session cap (the fairness lever). Defaults to half the + * global cap; overridable via `CODEMAN_MAX_SESSIONS_PER_USER`. The global cap + * (MAX_CONCURRENT_SESSIONS) still applies on top and is shared across users. + */ +export function maxSessionsPerUser(): number { + const n = Number(process.env.CODEMAN_MAX_SESSIONS_PER_USER); + if (Number.isInteger(n) && n > 0) return n; + return Math.max(1, Math.floor(MAX_CONCURRENT_SESSIONS / 2)); +} diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index e1fb64cc..63c83064 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -15,6 +15,8 @@ import { SseEvent } from '../web/sse-events.js'; import { CronJobSchema } from '../web/schemas.js'; import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.js'; import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js'; +import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js'; +import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.js'; import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js'; import { DEFAULT_BLOCKED_TREES, @@ -25,6 +27,7 @@ import { validateSessionFilePath } from '../web/route-helpers.js'; import { computeNextRunAt, dueKeyFor } from './cron-time.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.js'; import type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js'; +import type { GeminiConfig } from '../types/session.js'; import type { CronJobInput } from './cron-input.js'; /** The subset of the route context the cron depends on. */ @@ -108,7 +111,7 @@ export class CronService { // ──────────────────────────── Mutations ─────────────────────────── - createJob(input: CronJobInput): CronJob { + createJob(input: CronJobInput, owner?: string): CronJob { if (Object.keys(this.store.getCronJobs()).length >= MAX_CRON_JOBS) { throw this.badRequest(`Maximum number of cron jobs (${MAX_CRON_JOBS}) reached`); } @@ -117,6 +120,7 @@ export class CronService { const job: CronJob = { id: uuidv4(), name: input.name, + owner, agentType: input.agentType, workingDir: input.workingDir, launchCommand: input.launchCommand, @@ -328,6 +332,12 @@ export class CronService { return this.failRun(job, run, 'workingDir does not exist'); } + // Section 6.3: defense-in-depth workingDir confinement re-check at FIRE time against the + // owner's CURRENT space (complements the create/update gate). No-op in single-user / unset owner. + if (!(await isWorkingDirAllowedForUsername(job.owner, job.workingDir))) { + return this.failRun(job, run, 'workingDir is outside the owner workspace'); + } + // Recurring jobs: close the still-open session created by this job's // previous run before launching the next (default ON, opt-out via // autoClosePreviousSession:false) — otherwise an unattended interval/daily @@ -336,10 +346,21 @@ export class CronService { await this.closePreviousRunSessions(job, run.id); } - // Respect the global session cap. - if (this.deps.sessions.size >= MAX_CONCURRENT_SESSIONS) { + // Respect the global cap AND the owner's per-user cap (multi-user). + const cap = sessionCapacityState(this.deps.sessions, job.owner); + if (cap.atGlobalCap) { return this.failRun(job, run, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`); } + if (cap.atUserCap) { + return this.failRun(job, run, `Owner's per-user session limit reached`); + } + + // Section 6.3: re-resolve the owner's grant at FIRE time (it may have been revoked + // since create). Gates shell/launchCommand AND clamps the external-CLI bypass below. + const ownerGranted = await canUsernameRunPrivilegedCommands(job.owner); + if ((job.agentType === 'shell' || job.launchCommand) && !ownerGranted) { + return this.failRun(job, run, 'Owner lacks the can-bypass-permissions grant for shell/launchCommand jobs'); + } // Create + start the session (mirrors the quick-start route flow). let session: Session; @@ -348,7 +369,15 @@ export class CronService { const globalNice = await this.deps.getGlobalNiceConfig(); const modelConfig = await this.deps.getModelConfig(); const claudeModeConfig = await this.deps.getClaudeModeConfig(); + const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner); const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined; + // Section 6.3: cron carries no per-CLI config, so buildGeminiCommand(undefined) + // would default a non-granted owner to `--approval-mode yolo` (classifier-free) — + // materialize auto_edit for a non-granted gemini owner, mirroring the route clamp + // (#15). Granted/admin/single-user leave it undefined → yolo parity. Codex's absent + // config already defaults to the safe sandbox, so no clamp is needed there. + const geminiConfig: GeminiConfig | undefined = + mode === 'gemini' && !ownerGranted ? { approvalMode: 'auto_edit' } : undefined; session = new Session({ workingDir: job.workingDir, mode, @@ -357,8 +386,10 @@ export class CronService { useMux: true, niceConfig: globalNice, model, - claudeMode: claudeModeConfig.claudeMode, + claudeMode: effectiveClaudeMode, allowedTools: claudeModeConfig.allowedTools, + geminiConfig, + owner: job.owner, }); this.deps.addSession(session); this.store.incrementSessionsCreated(); diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 36c483d2..d6e58d4e 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -39,6 +39,8 @@ export interface MuxSession { 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 */ @@ -84,6 +86,8 @@ export interface CreateSessionOptions { 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. */ @@ -110,6 +114,8 @@ export interface RespawnPaneOptions { remote?: SessionRemote; /** Docker execution metadata for local tmux sessions wrapping `docker exec` */ docker?: SessionDocker; + /** Owning username (multi-user); redundant on respawn since the Session object survives, kept for shape parity. */ + owner?: string; } /** Options for pane buffer capture (COD-47 full-history mode). */ diff --git a/src/plan-orchestrator.ts b/src/plan-orchestrator.ts index 5edddb1e..4a5f7db5 100644 --- a/src/plan-orchestrator.ts +++ b/src/plan-orchestrator.ts @@ -20,7 +20,7 @@ import type { TerminalMultiplexer } from './mux-interface.js'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js'; -import { getErrorMessage, type PlanItem } from './types.js'; +import { getErrorMessage, type PlanItem, type ClaudeMode } from './types.js'; // Re-export for backward compatibility export type { PlanItem }; @@ -130,18 +130,28 @@ export class PlanOrchestrator { private taskDescription = ''; private researchModel: string; private plannerModel: string; + // Multi-user permission threading: the resolved claudeMode/owner/allowedTools for the + // internal research/planner one-shots. Left undefined = today's single-user behavior + // (the caller threads the resolved global mode, byte-identical when !isMultiUserMode()). + private claudeMode?: ClaudeMode; + private owner?: string; + private allowedTools?: string; constructor( mux: TerminalMultiplexer, workingDir: string = process.cwd(), outputDir?: string, - modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record } + modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record }, + security?: { claudeMode?: ClaudeMode; owner?: string; allowedTools?: string } ) { this.mux = mux; this.workingDir = workingDir; this.outputDir = outputDir; this.researchModel = modelConfig?.agentTypeOverrides?.explore || modelConfig?.defaultModel || DEFAULT_MODEL; this.plannerModel = modelConfig?.agentTypeOverrides?.review || modelConfig?.defaultModel || DEFAULT_MODEL; + this.claudeMode = security?.claudeMode; + this.owner = security?.owner; + this.allowedTools = security?.allowedTools; } private saveAgentOutput(agentType: string, prompt: string, result: unknown, durationMs: number): void { @@ -424,6 +434,12 @@ export class PlanOrchestrator { mux: this.mux, useMux: false, mode: 'claude', + // Section 6.3: run this one-shot under the caller-resolved permission mode/owner so a + // non-granted multi-user user cannot regain --dangerously-skip-permissions. Undefined + // (single-user, not threaded) is byte-identical to today (Session keeps its default). + claudeMode: this.claudeMode, + allowedTools: this.allowedTools, + owner: this.owner, }); this.runningSessions.add(session); @@ -580,6 +596,10 @@ export class PlanOrchestrator { mux: this.mux, useMux: false, mode: 'claude', + // Section 6.3: same permission-mode/owner threading as the research one-shot above. + claudeMode: this.claudeMode, + allowedTools: this.allowedTools, + owner: this.owner, }); this.runningSessions.add(session); diff --git a/src/push-store.ts b/src/push-store.ts index 5cee424f..059cf612 100644 --- a/src/push-store.ts +++ b/src/push-store.ts @@ -9,10 +9,23 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import webpush from 'web-push'; -import type { VapidKeys, PushSubscriptionRecord } from './types.js'; +import type { VapidKeys, PushSubscriptionRecord, UserRole } from './types.js'; import { Debouncer } from './utils/index.js'; import { getDataDir } from './config/instance.js'; +/** + * A push subscription plus the multi-user owner identity stamped at subscribe time. + * `username`/`role` are undefined in single-user mode (and for legacy records saved + * before this field existed). sendPushNotifications uses them to scope a + * session-notification to its owner's devices (+ admins) instead of fanning out to + * every user. Kept as a store-local widening of PushSubscriptionRecord so the shared + * type stays untouched; the extra keys serialize/persist transparently. + */ +export type OwnedPushSubscriptionRecord = PushSubscriptionRecord & { + username?: string; + role?: UserRole; +}; + const DATA_DIR = getDataDir(); const KEYS_FILE = join(DATA_DIR, 'push-keys.json'); const SUBS_FILE = join(DATA_DIR, 'push-subscriptions.json'); @@ -20,7 +33,7 @@ const SAVE_DEBOUNCE_MS = 500; export class PushSubscriptionStore { private vapidKeys: VapidKeys | null = null; - private subscriptions: Map = new Map(); + private subscriptions: Map = new Map(); private saveDeb = new Debouncer(SAVE_DEBOUNCE_MS); private _disposed = false; @@ -67,17 +80,19 @@ export class PushSubscriptionStore { } /** Register or update a push subscription (deduplicates by endpoint) */ - addSubscription(sub: Omit): PushSubscriptionRecord { + addSubscription(sub: Omit): OwnedPushSubscriptionRecord { // Check for existing subscription with same endpoint for (const [existingId, existing] of this.subscriptions) { if (existing.endpoint === sub.endpoint) { - // Update existing - const updated: PushSubscriptionRecord = { + // Update existing (re-stamp owner identity so it tracks the current caller) + const updated: OwnedPushSubscriptionRecord = { ...existing, keys: sub.keys, userAgent: sub.userAgent, lastUsedAt: Date.now(), pushPreferences: sub.pushPreferences, + username: sub.username, + role: sub.role, }; this.subscriptions.set(existingId, updated); this.scheduleSave(); @@ -86,7 +101,7 @@ export class PushSubscriptionStore { } // New subscription - const record: PushSubscriptionRecord = { + const record: OwnedPushSubscriptionRecord = { ...sub, lastUsedAt: Date.now(), }; @@ -96,7 +111,7 @@ export class PushSubscriptionStore { } /** Update push preferences for a subscription */ - updatePreferences(id: string, preferences: Record): PushSubscriptionRecord | null { + updatePreferences(id: string, preferences: Record): OwnedPushSubscriptionRecord | null { const sub = this.subscriptions.get(id); if (!sub) return null; sub.pushPreferences = preferences; @@ -124,12 +139,12 @@ export class PushSubscriptionStore { } /** Get all subscriptions */ - getAll(): PushSubscriptionRecord[] { + getAll(): OwnedPushSubscriptionRecord[] { return Array.from(this.subscriptions.values()); } /** Get a single subscription by ID */ - get(id: string): PushSubscriptionRecord | null { + get(id: string): OwnedPushSubscriptionRecord | null { return this.subscriptions.get(id) ?? null; } @@ -138,7 +153,7 @@ export class PushSubscriptionStore { if (!existsSync(SUBS_FILE)) return; try { const raw = readFileSync(SUBS_FILE, 'utf-8'); - const arr = JSON.parse(raw) as PushSubscriptionRecord[]; + const arr = JSON.parse(raw) as OwnedPushSubscriptionRecord[]; for (const sub of arr) { this.subscriptions.set(sub.id, sub); } diff --git a/src/session-cli-builder.ts b/src/session-cli-builder.ts index 95f9f0da..1e970c45 100644 --- a/src/session-cli-builder.ts +++ b/src/session-cli-builder.ts @@ -21,6 +21,8 @@ function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): str switch (claudeMode) { case 'dangerously-skip-permissions': return ['--dangerously-skip-permissions']; + case 'auto': + return ['--permission-mode', 'auto']; case 'allowedTools': if (allowedTools) { return ['--allowedTools', allowedTools]; @@ -80,8 +82,16 @@ export function buildInteractiveArgs( * @param model - Optional model override * @returns Array of CLI arguments */ -export function buildPromptArgs(prompt: string, model?: string): string[] { - const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json']; +export function buildPromptArgs( + prompt: string, + model?: string, + claudeMode: ClaudeMode = 'dangerously-skip-permissions', + allowedTools?: string +): string[] { + // Respect the session's permission mode instead of always skipping, so a + // multi-user non-granted user's one-shot runs classifier-guarded (auto) rather + // than with full bypass. Defaults to skip-permissions (unchanged single-user). + const args = ['-p', '--verbose', ...buildPermissionArgs(claudeMode, allowedTools), '--output-format', 'stream-json']; if (model) { args.push('--model', model); } diff --git a/src/session.ts b/src/session.ts index 02ce3e1b..aadb1bad 100644 --- a/src/session.ts +++ b/src/session.ts @@ -412,6 +412,10 @@ export class Session extends EventEmitter { // 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'; @@ -487,6 +491,8 @@ export class Session extends EventEmitter { 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(); @@ -561,6 +567,7 @@ 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); } @@ -667,6 +674,16 @@ export class Session extends EventEmitter { 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 @@ -1027,6 +1044,7 @@ export class Session extends EventEmitter { workingDir: this.workingDir, remote: this._remote, docker: this._docker, + owner: this._owner, currentTaskId: this._currentTaskId, createdAt: this.createdAt, lastActivityAt: this._lastActivityAt, @@ -1395,6 +1413,7 @@ export class Session extends EventEmitter { historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, + owner: this._owner, }, createSessionOptions: { sessionId: this.id, @@ -1414,6 +1433,7 @@ export class Session extends EventEmitter { historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, + owner: this._owner, }, spawnErrLabel: 'mux attachment', }); @@ -1523,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}`); @@ -1785,6 +1805,7 @@ export class Session extends EventEmitter { historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, + owner: this._owner, }, createSessionOptions: { sessionId: this.id, @@ -1796,6 +1817,7 @@ export class Session extends EventEmitter { historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, + owner: this._owner, }, spawnErrLabel: 'shell mux attachment', }); @@ -1923,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, { diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 13200fe8..76ec2f05 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -563,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 @@ -674,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; @@ -777,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 @@ -1487,6 +1502,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, + owner, } = options; const muxName = `codeman-${sessionId.slice(0, 8)}`; @@ -1507,6 +1523,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { workingDir, remote, docker, + owner, mode, attached: false, name, @@ -1555,7 +1572,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const fullCmd = docker ? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId)) : remote - ? buildRemoteLaunchCommand({ mode, remote, sessionId }) + ? buildRemoteLaunchCommand({ mode, remote, sessionId, claudeMode, allowedTools }) : localFullCmd; // Create tmux session in three steps to handle cold-start (no server running) @@ -1683,6 +1700,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { workingDir, remote, docker, + owner, mode, attached: false, name, @@ -1809,7 +1827,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const fullCmd = docker ? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId)) : remote - ? buildRemoteLaunchCommand({ mode, remote, sessionId }) + ? buildRemoteLaunchCommand({ mode, remote, sessionId, claudeMode, allowedTools }) : localFullCmd; try { diff --git a/src/tunnel-manager.ts b/src/tunnel-manager.ts index 083b11d2..9e10764e 100644 --- a/src/tunnel-manager.ts +++ b/src/tunnel-manager.ts @@ -43,6 +43,8 @@ interface QrTokenRecord { shortCode: string; // 6 chars base62 (for URL path) createdAt: number; // Date.now() consumed: boolean; // single-use flag + /** Multi-user: the user this token logs in when redeemed (absent = rotating global token). */ + username?: string; } /** Rejection-sampled base62 short code — no modulo bias */ @@ -378,23 +380,64 @@ export class TunnelManager extends EventEmitter { * Map.get() is hash-based — no timing side-channel from string comparison. */ consumeToken(shortCode: string): boolean { + return this.consumeTokenWithIdentity(shortCode).ok; + } + + /** + * Like consumeToken, but also returns the bound username for multi-user tokens + * (undefined for the rotating global token). Only the identity-less rotating + * token triggers an immediate re-rotation (desktop gets a fresh QR); per-user + * tokens are on-demand and self-expire. + */ + consumeTokenWithIdentity(shortCode: string): { ok: boolean; username?: string } { // Global rate limit (across all IPs) - if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return false; + if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return { ok: false }; this.qrAttemptCount++; const record = this.qrTokensByCode.get(shortCode); - if (!record) return false; - if (record.consumed) return false; + if (!record) return { ok: false }; + if (record.consumed) return { ok: false }; const now = Date.now(); - if (now - record.createdAt > QR_TOKEN_GRACE_MS) return false; + if (now - record.createdAt > QR_TOKEN_GRACE_MS) return { ok: false }; // Atomic consume (single-threaded JS = no race) record.consumed = true; - // Immediately rotate so desktop gets a fresh QR - this.rotateToken(); - this.emit('qrTokenRegenerated'); - return true; + const username = record.username; + if (!username) { + // Rotating global token — immediately rotate so desktop gets a fresh QR. + this.rotateToken(); + this.emit('qrTokenRegenerated'); + } else { + this.qrTokensByCode.delete(shortCode); + } + return { ok: true, username }; + } + + /** + * Multi-user: mint a single-use token bound to a specific user (on-demand, no + * rotation). Evicts expired/consumed tokens first. Returns the short code. + */ + mintUserToken(username: string): string { + const now = Date.now(); + for (const [code, rec] of this.qrTokensByCode) { + if (now - rec.createdAt > QR_TOKEN_GRACE_MS || rec.consumed) this.qrTokensByCode.delete(code); + } + const record: QrTokenRecord = { + token: randomBytes(32).toString('hex'), + shortCode: generateShortCode(), + createdAt: Date.now(), + consumed: false, + username, + }; + this.qrTokensByCode.set(record.shortCode, record); + return record.shortCode; + } + + /** Render a QR SVG for an arbitrary short code (used by per-user minting). */ + async getQrSvgForCode(tunnelUrl: string, code: string): Promise { + const QRCode = await import('qrcode'); + return QRCode.toString(`${tunnelUrl}/q/${code}`, { type: 'svg', margin: 2, width: 256 }); } /** Force-regenerate (manual revocation via API) */ diff --git a/src/types/api.ts b/src/types/api.ts index d5a4578a..4795caa7 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -37,6 +37,16 @@ export enum ApiErrorCode { RATE_LIMITED = 'RATE_LIMITED', /** Operation could not be completed (well-formed but unprocessable) */ OPERATION_FAILED = 'OPERATION_FAILED', + /** Authenticated but not permitted (e.g. non-admin hitting an admin route) */ + FORBIDDEN = 'FORBIDDEN', + /** User must change their password before any other action (multi-user) */ + PASSWORD_CHANGE_REQUIRED = 'PASSWORD_CHANGE_REQUIRED', + /** A user with this name already exists (multi-user) */ + USER_EXISTS = 'USER_EXISTS', + /** No user with this name (multi-user) */ + USER_NOT_FOUND = 'USER_NOT_FOUND', + /** Refusing to demote/disable/delete the last enabled admin (multi-user) */ + LAST_ADMIN = 'LAST_ADMIN', /** Internal server error */ INTERNAL_ERROR = 'INTERNAL_ERROR', } @@ -53,6 +63,11 @@ const ErrorMessages: Record = { [ApiErrorCode.ALREADY_EXISTS]: 'Resource already exists', [ApiErrorCode.RATE_LIMITED]: 'Too many requests', [ApiErrorCode.OPERATION_FAILED]: 'The operation failed', + [ApiErrorCode.FORBIDDEN]: 'You do not have permission to perform this action', + [ApiErrorCode.PASSWORD_CHANGE_REQUIRED]: 'You must change your password before continuing', + [ApiErrorCode.USER_EXISTS]: 'A user with that name already exists', + [ApiErrorCode.USER_NOT_FOUND]: 'No such user', + [ApiErrorCode.LAST_ADMIN]: 'Cannot remove the last enabled admin', [ApiErrorCode.INTERNAL_ERROR]: 'An internal error occurred', }; @@ -69,6 +84,11 @@ const ErrorStatus: Record = { [ApiErrorCode.CONFLICT]: 409, [ApiErrorCode.ALREADY_EXISTS]: 409, [ApiErrorCode.OPERATION_FAILED]: 422, + [ApiErrorCode.FORBIDDEN]: 403, + [ApiErrorCode.PASSWORD_CHANGE_REQUIRED]: 403, + [ApiErrorCode.USER_EXISTS]: 409, + [ApiErrorCode.USER_NOT_FOUND]: 404, + [ApiErrorCode.LAST_ADMIN]: 409, [ApiErrorCode.RATE_LIMITED]: 429, [ApiErrorCode.INTERNAL_ERROR]: 500, }; diff --git a/src/types/cron.ts b/src/types/cron.ts index 05bfdf13..9c05b8c4 100644 --- a/src/types/cron.ts +++ b/src/types/cron.ts @@ -36,6 +36,8 @@ export type ConcurrencyPolicy = 'warn_only' | 'skip_if_same_agent_running'; export interface CronJob { id: string; name: string; + /** Owning username in multi-user mode; the job launches as this user. Undefined in single-user. */ + owner?: string; /** Reuses Codeman's existing session modes; 'shell' covers Terminal/custom. */ agentType: SessionMode; workingDir: string; diff --git a/src/types/index.ts b/src/types/index.ts index 5a5b0911..706f1fa6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -69,3 +69,4 @@ export * from './orchestrator.js'; export * from './update.js'; export * from './workflow-run.js'; export * from './search.js'; +export * from './user.js'; diff --git a/src/types/session.ts b/src/types/session.ts index 0744beab..3e50941d 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -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; } @@ -173,6 +176,8 @@ export interface DockerHost { 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; @@ -335,6 +340,8 @@ export interface SessionState { remote?: SessionRemote; /** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */ docker?: SessionDocker; + /** Owning username in multi-user mode; undefined in single-user (ignored when the flag is off) */ + owner?: string; /** ID of currently assigned task, null if none */ currentTaskId: string | null; /** Timestamp when session was created */ diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 00000000..904cbb9f --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,64 @@ +/** + * @fileoverview Multi-user mode types (opt-in `--multiuser`). + * + * Users live in `~/.codeman/users.json` (via `dataPath`, mode 0600). Each record + * carries a scrypt password hash with its own parameters so hashing cost can be + * raised later and old records rehashed on next login. `AuthUser` is the + * request-scoped identity decorated onto Fastify requests; in SINGLE-user mode a + * synthetic `{ username: 'admin', role: 'admin' }` is used so downstream code has + * one code path. See `src/user-store.ts` and `docs/multi-user-plan.md`. + */ + +export type UserRole = 'admin' | 'user'; + +/** Per-record scrypt parameters + salt/hash (all hex). */ +export interface PasswordHash { + algo: 'scrypt'; + N: number; + r: number; + p: number; + salt: string; + hash: string; +} + +export interface UserRecord { + /** Canonical lowercase slug; also the user's folder name under USER_SPACES_DIR. */ + username: string; + role: UserRole; + password: PasswordHash; + /** Disabled accounts fail auth closed but keep their space on disk. */ + disabled?: boolean; + /** Set by an admin reset; gates all API access until the user changes it. */ + mustChangePassword?: boolean; + /** + * Permission-mode grant (section 6.3). When false (the default for new users), + * the user's Claude sessions are forced to `--permission-mode auto`, shell mode + * and cron `launchCommand` are refused, and other CLIs' bypass flags are dropped. + */ + canBypassPermissions?: boolean; + createdAt: number; + lastLoginAt?: number; +} + +/** On-disk shape of `users.json`. */ +export interface UsersFile { + version: 1; + users: UserRecord[]; +} + +/** Request-scoped identity (decorated as `req.authUser`). */ +export interface AuthUser { + username: string; + role: UserRole; +} + +/** Admin-facing projection of a user: never carries the password hash. */ +export interface PublicUser { + username: string; + role: UserRole; + disabled: boolean; + mustChangePassword: boolean; + canBypassPermissions: boolean; + createdAt: number; + lastLoginAt?: number; +} diff --git a/src/user-store.ts b/src/user-store.ts new file mode 100644 index 00000000..869a08c4 --- /dev/null +++ b/src/user-store.ts @@ -0,0 +1,488 @@ +/** + * @fileoverview Multi-user store: `~/.codeman/users.json` (via `dataPath`, 0600). + * + * Mirrors the storage-module pattern of `remote-hosts.ts` / `docker-hosts.ts`, but + * because it holds password hashes it writes atomically (tmp + rename) at mode + * 0600 and keeps only a SHORT in-process cache so the CLI (`codeman users …`) can + * edit the file while the server runs and have changes picked up within the TTL. + * + * Pure, IO-free helpers (`isValidUsername`, `hashPassword`, `verifyPasswordHash`, + * `needsRehash`, `resolveClaudeModeForUser`, the last-admin invariants) are split + * out so they are unit-testable without a server. Hashing is `scrypt` from + * `node:crypto` (no new deps), compared via `timingSafeEqual`; parameters are + * stored per record so cost can be raised later and old records rehashed on their + * next successful login. + * + * See `docs/multi-user-plan.md` sections 4.1, 5, 6.3. + */ + +import { existsSync, mkdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { isAbsolute, join, relative } from 'node:path'; +import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto'; +import { promisify } from 'node:util'; +import { dataPath, getDataDir } from './config/instance.js'; +import { getUserSpacesDir, isMultiUserMode, maxUsers } from './config/multiuser.js'; +import type { AuthUser, ClaudeMode, PasswordHash, PublicUser, UserRecord, UserRole, UsersFile } from './types.js'; + +const scrypt = promisify(scryptCb) as ( + password: string | Buffer, + salt: string | Buffer, + keylen: number, + options: { N: number; r: number; p: number; maxmem: number } +) => Promise; + +const USERS_FILE = 'users.json'; +const CACHE_TTL_MS = 1000; +const KEYLEN = 64; +const SALT_BYTES = 32; +/** Generous ceiling so raising N/r later does not trip scrypt's memory guard. */ +const SCRYPT_MAXMEM = 256 * 1024 * 1024; + +/** Current hashing parameters. Stored per record; raise these to increase cost. */ +export const DEFAULT_SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 } as const; + +/** Username: lowercase, first char alphanumeric, 2-32 chars total. Becomes a folder name. */ +const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{1,31}$/; + +/** Typed error whose `.code` maps to an API errorCode at the route layer. */ +export class UserStoreError extends Error { + constructor( + message: string, + public readonly code: 'USER_EXISTS' | 'USER_NOT_FOUND' | 'LAST_ADMIN' | 'INVALID_INPUT' + ) { + super(message); + this.name = 'UserStoreError'; + } +} + +// ─────────────────────────────── pure helpers ─────────────────────────────── + +export function normalizeUsername(name: string): string { + return String(name ?? '') + .trim() + .toLowerCase(); +} + +export function isValidUsername(name: string): boolean { + return USERNAME_RE.test(normalizeUsername(name)); +} + +/** Hash a password with the given (or current) scrypt params + a fresh random salt. */ +export async function hashPassword( + password: string, + params: { N: number; r: number; p: number } = DEFAULT_SCRYPT_PARAMS +): Promise { + const salt = randomBytes(SALT_BYTES); + const derived = await scrypt(password, salt, KEYLEN, { ...params, maxmem: SCRYPT_MAXMEM }); + return { + algo: 'scrypt', + N: params.N, + r: params.r, + p: params.p, + salt: salt.toString('hex'), + hash: derived.toString('hex'), + }; +} + +/** Constant-time verify of a password against a stored hash record. Never throws. */ +export async function verifyPasswordHash(password: string, record: PasswordHash): Promise { + if (!record || record.algo !== 'scrypt') return false; + let salt: Buffer; + let expected: Buffer; + try { + salt = Buffer.from(record.salt, 'hex'); + expected = Buffer.from(record.hash, 'hex'); + } catch { + return false; + } + if (expected.length === 0) return false; + let derived: Buffer; + try { + derived = await scrypt(password, salt, expected.length, { + N: record.N, + r: record.r, + p: record.p, + maxmem: SCRYPT_MAXMEM, + }); + } catch { + return false; + } + if (derived.length !== expected.length) return false; + return timingSafeEqual(derived, expected); +} + +/** True when a stored hash uses weaker params than current and should be rehashed. */ +export function needsRehash(record: PasswordHash, params = DEFAULT_SCRYPT_PARAMS): boolean { + return record.algo !== 'scrypt' || record.N !== params.N || record.r !== params.r || record.p !== params.p; +} + +/** URL-safe one-time password (16 chars) for admin create/reset flows. */ +export function generateOneTimePassword(): string { + return randomBytes(12).toString('base64url'); +} + +export function toPublicUser(u: UserRecord): PublicUser { + return { + username: u.username, + role: u.role, + disabled: !!u.disabled, + mustChangePassword: !!u.mustChangePassword, + canBypassPermissions: !!u.canBypassPermissions, + createdAt: u.createdAt, + lastLoginAt: u.lastLoginAt, + }; +} + +export function countEnabledAdmins(users: UserRecord[]): number { + return users.filter((u) => u.role === 'admin' && !u.disabled).length; +} + +/** + * Section 6.3: resolve the effective Claude permission mode for a user. Admins and + * granted users get the global mode as-is; a non-granted regular user whose mode + * would be `dangerously-skip-permissions` is silently downgraded to `auto` (all + * other modes are already <= auto and pass through). Pure. + */ +export function resolveClaudeModeForUser( + globalMode: ClaudeMode | undefined, + grant: { role: UserRole; canBypassPermissions?: boolean } +): ClaudeMode { + const mode: ClaudeMode = globalMode ?? 'dangerously-skip-permissions'; + if (grant.role === 'admin' || grant.canBypassPermissions) return mode; + return mode === 'dangerously-skip-permissions' ? 'auto' : mode; +} + +/** + * Section 6.3: whether a user may run arbitrary commands as the host account + * (shell-mode sessions, cron `launchCommand`, other CLIs' bypass flags). Same + * one-bit grant as bypass. Admins always may. + */ +export function canRunPrivilegedCommands(grant: { role: UserRole; canBypassPermissions?: boolean }): boolean { + return grant.role === 'admin' || !!grant.canBypassPermissions; +} + +// ─────────────────────────────── IO layer ─────────────────────────────── + +let cache: { users: UserRecord[]; ts: number } | null = null; + +/** Drop the in-process cache (called after every write; exported for tests). */ +export function invalidateUsersCache(): void { + cache = null; +} + +export async function readUsers(force = false): Promise { + const now = Date.now(); + if (!force && cache && now - cache.ts < CACHE_TTL_MS) return cache.users; + let raw: string; + try { + raw = await fs.readFile(dataPath(USERS_FILE), 'utf-8'); + } catch (err) { + // ENOENT is the ONLY legitimately-empty store (first boot). Any other read + // error (EIO/EACCES/EMFILE/EBUSY) is a transient/permission failure, NOT an + // empty store — do NOT cache [] and do NOT let it look empty, or a following + // createUser/bootstrap would overwrite users.json and destroy every account. + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + cache = { users: [], ts: now }; + return []; + } + throw err; + } + // A present-but-corrupt file (invalid JSON) must also fail loud rather than + // read as empty, so mutators/bootstrap abort instead of clobbering it. + const parsed = JSON.parse(raw) as Partial; + const users = Array.isArray(parsed.users) ? parsed.users : []; + cache = { users, ts: now }; + return users; +} + +async function writeUsers(users: UserRecord[]): Promise { + const dir = getDataDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const finalPath = dataPath(USERS_FILE); + // Unique per-writer tmp name (pid + random) so the CLI (`codeman users …`) and + // the live server — designed to write this file concurrently across processes — + // never share a single `users.json.tmp` inode and tear each other's payload. + // Matches the state-store.ts / self-update.ts convention. + const tmpPath = `${finalPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`; + const payload: UsersFile = { version: 1, users }; + try { + await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { mode: 0o600 }); + await fs.chmod(tmpPath, 0o600).catch(() => {}); + await fs.rename(tmpPath, finalPath); + } catch (err) { + await fs.unlink(tmpPath).catch(() => {}); + throw err; + } + cache = { users, ts: Date.now() }; +} + +/** + * Serialize every read-modify-write on users.json. Without this a fire-and-forget + * touchLastLogin (fired on each Basic auth) can interleave with a route's + * create/update and clobber records, since both do readUsers(true) → mutate → + * writeUsers against a single shared file + tmp path. + */ +let mutateChain: Promise = Promise.resolve(); +function withUsersLock(fn: () => Promise): Promise { + const run = mutateChain.then(fn, fn); + mutateChain = run.then( + () => undefined, + () => undefined + ); + return run; +} + +export async function hasUsers(): Promise { + return (await readUsers()).length > 0; +} + +// A precomputed dummy hash so an unknown/disabled user costs the same scrypt work +// as a real verify (defeats username-enumeration by timing). Created once, lazily. +let dummyHashPromise: Promise | null = null; +function getDummyHash(): Promise { + if (!dummyHashPromise) dummyHashPromise = hashPassword('codeman-timing-equalization-placeholder'); + return dummyHashPromise; +} + +/** + * Verify a username/password against the store. Returns the record (plus whether it + * should be rehashed) on success, or null for wrong password / unknown / disabled + * user. Runs a dummy scrypt on the miss path so timing does not reveal which users + * exist. Never writes (the caller decides when to persist lastLogin / rehash). + */ +export async function verifyPassword( + username: string, + password: string +): Promise<{ user: UserRecord; needsRehash: boolean } | null> { + const user = await findUser(username); + if (!user || user.disabled) { + await verifyPasswordHash(password, await getDummyHash()); + return null; + } + const ok = await verifyPasswordHash(password, user.password); + if (!ok) return null; + return { user, needsRehash: needsRehash(user.password) }; +} + +export async function findUser(username: string): Promise { + const norm = normalizeUsername(username); + if (!norm) return undefined; + const users = await readUsers(); + return users.find((u) => u.username === norm); +} + +export interface CreateUserOptions { + username: string; + role: UserRole; + password: string; + mustChangePassword?: boolean; + canBypassPermissions?: boolean; +} + +export async function createUser(opts: CreateUserOptions): Promise { + const username = normalizeUsername(opts.username); + if (!isValidUsername(username)) { + throw new UserStoreError( + 'Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])', + 'INVALID_INPUT' + ); + } + if (opts.role !== 'admin' && opts.role !== 'user') { + throw new UserStoreError('Role must be "admin" or "user"', 'INVALID_INPUT'); + } + if (!opts.password || opts.password.length < 8) { + throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT'); + } + return withUsersLock(async () => { + const users = await readUsers(true); + if (users.some((u) => u.username === username)) { + throw new UserStoreError(`User "${username}" already exists`, 'USER_EXISTS'); + } + if (users.length >= maxUsers()) { + throw new UserStoreError(`Maximum number of users (${maxUsers()}) reached`, 'INVALID_INPUT'); + } + const record: UserRecord = { + username, + role: opts.role, + password: await hashPassword(opts.password), + disabled: false, + mustChangePassword: !!opts.mustChangePassword, + canBypassPermissions: !!opts.canBypassPermissions, + createdAt: Date.now(), + }; + users.push(record); + await writeUsers(users); + return record; + }); +} + +/** Set a user's password. `mustChangePassword` is left unchanged unless specified. */ +export async function setPassword( + username: string, + password: string, + opts: { mustChangePassword?: boolean } = {} +): Promise { + if (!password || password.length < 8) { + throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT'); + } + const norm = normalizeUsername(username); + return withUsersLock(async () => { + const users = await readUsers(true); + const record = users.find((u) => u.username === norm); + if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); + record.password = await hashPassword(password); + if (opts.mustChangePassword !== undefined) record.mustChangePassword = opts.mustChangePassword; + await writeUsers(users); + return record; + }); +} + +export interface UpdateUserPatch { + role?: UserRole; + disabled?: boolean; + canBypassPermissions?: boolean; + mustChangePassword?: boolean; +} + +export async function updateUser(username: string, patch: UpdateUserPatch): Promise { + const norm = normalizeUsername(username); + return withUsersLock(async () => { + const users = await readUsers(true); + const record = users.find((u) => u.username === norm); + if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); + + // Guard the last-enabled-admin invariant against demote/disable. + const before = countEnabledAdmins(users); + const projected: UserRecord = { + ...record, + role: patch.role ?? record.role, + disabled: patch.disabled ?? record.disabled, + }; + const after = countEnabledAdmins(users.map((u) => (u.username === norm ? projected : u))); + if (before > 0 && after === 0) { + throw new UserStoreError('Cannot demote or disable the last enabled admin', 'LAST_ADMIN'); + } + + if (patch.role !== undefined) record.role = patch.role; + if (patch.disabled !== undefined) record.disabled = patch.disabled; + if (patch.canBypassPermissions !== undefined) record.canBypassPermissions = patch.canBypassPermissions; + if (patch.mustChangePassword !== undefined) record.mustChangePassword = patch.mustChangePassword; + await writeUsers(users); + return record; + }); +} + +/** + * Record a successful login timestamp. Best-effort + throttled: skips the write if + * the last login was within the last minute (Basic clients re-send credentials on + * every request, so this fires often — the throttle keeps disk churn bounded). + */ +export async function touchLastLogin(username: string): Promise { + const norm = normalizeUsername(username); + try { + await withUsersLock(async () => { + const users = await readUsers(true); + const record = users.find((u) => u.username === norm); + if (!record) return; + if (record.lastLoginAt && Date.now() - record.lastLoginAt < 60_000) return; + record.lastLoginAt = Date.now(); + await writeUsers(users); + }); + } catch { + /* best-effort */ + } +} + +export async function deleteUser(username: string): Promise { + const norm = normalizeUsername(username); + await withUsersLock(async () => { + const users = await readUsers(true); + const record = users.find((u) => u.username === norm); + if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); + const before = countEnabledAdmins(users); + const remaining = users.filter((u) => u.username !== norm); + const after = countEnabledAdmins(remaining); + if (before > 0 && after === 0) { + throw new UserStoreError('Cannot delete the last enabled admin', 'LAST_ADMIN'); + } + await writeUsers(remaining); + }); +} + +/** + * First-boot bootstrap: in multi-user mode with no users yet, create the initial + * admin from `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` if both are set. Returns a + * status the caller (server start / CLI) uses to decide whether to refuse boot. + */ +export async function bootstrapInitialAdmin(): Promise<{ + status: 'created' | 'exists' | 'missing-env'; + username?: string; +}> { + if (await hasUsers()) return { status: 'exists' }; + const username = process.env.CODEMAN_USERNAME; + const password = process.env.CODEMAN_PASSWORD; + if (!username || !password) return { status: 'missing-env' }; + const created = await createUser({ username, role: 'admin', password }); + return { status: 'created', username: created.username }; +} + +/** + * Delete a user's on-disk space (`/`) with the section 8 + * guard rails: the top-level dir must not be a symlink, and its realpath must + * resolve strictly inside USER_SPACES_DIR (so a symlinked or `..`-escaping target + * can never be used to rm an arbitrary tree). No-op if the space does not exist. + */ +export async function deleteUserSpace(username: string): Promise { + const norm = normalizeUsername(username); + if (!isValidUsername(norm)) throw new UserStoreError('Invalid username', 'INVALID_INPUT'); + const root = getUserSpacesDir(); + const target = join(root, norm); + let lst; + try { + lst = await fs.lstat(target); + } catch { + return; // nothing to delete + } + if (lst.isSymbolicLink()) { + throw new UserStoreError('Refusing to delete a symlinked user space', 'INVALID_INPUT'); + } + const realRoot = await fs.realpath(root).catch(() => root); + const realTarget = await fs.realpath(target); + const rel = relative(realRoot, realTarget); + if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) { + throw new UserStoreError('User space escapes USER_SPACES_DIR', 'INVALID_INPUT'); + } + await fs.rm(realTarget, { recursive: true, force: true }); +} + +/** The synthetic admin used in single-user mode so downstream has one code path. */ +export const SYNTHETIC_ADMIN: AuthUser = { username: 'admin', role: 'admin' }; + +/** + * Whether a username may run arbitrary commands (shell mode, cron launchCommand, + * other CLIs' bypass). Single-user or an unset owner: allowed. In multi-user a + * MISSING user (e.g. deleted) fails closed (non-privileged). Used at cron fire time. + */ +export async function canUsernameRunPrivilegedCommands(username: string | undefined): Promise { + if (!isMultiUserMode() || !username) return true; + const user = await findUser(username); + return canRunPrivilegedCommands(user ?? { role: 'user' }); +} + +/** + * Resolve the effective Claude mode for a username by looking up the grant. In + * single-user mode (or for an unknown owner) the global mode passes through. + */ +export async function resolveClaudeModeForUsername( + globalMode: ClaudeMode | undefined, + username: string | undefined +): Promise { + const fallback: ClaudeMode = globalMode ?? 'dangerously-skip-permissions'; + if (!isMultiUserMode() || !username) return fallback; + // Fail closed: an unknown/deleted owner in multi-user mode is treated as a + // non-granted regular user so a stale-owned spawn (e.g. an orphaned cron job) + // is downgraded to `auto` rather than inheriting the global bypass. + const user = await findUser(username); + return resolveClaudeModeForUser(globalMode, user ?? { role: 'user' }); +} diff --git a/src/web/admin-audit.ts b/src/web/admin-audit.ts new file mode 100644 index 00000000..857e4095 --- /dev/null +++ b/src/web/admin-audit.ts @@ -0,0 +1,28 @@ +/** + * @fileoverview Append-only admin audit log (~/.codeman/admin-audit.jsonl). + * + * Every user-management action (create/patch/reset/delete/logout/assign) writes one + * JSON line: timestamp, acting admin, action, target, request IP. Same idiom as + * session-lifecycle.jsonl. Best-effort: a write failure never blocks the action. + */ + +import fs from 'node:fs/promises'; +import { dataPath } from '../config/instance.js'; + +export interface AdminAuditEntry { + ts: number; + admin: string; + action: string; + target?: string; + ip?: string; + detail?: Record; +} + +export async function appendAdminAudit(entry: Omit): Promise { + try { + const line = JSON.stringify({ ts: Date.now(), ...entry }) + '\n'; + await fs.appendFile(dataPath('admin-audit.jsonl'), line, { mode: 0o600 }); + } catch { + /* best-effort audit; never block the action */ + } +} diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index 5387a280..41b9b160 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -8,7 +8,7 @@ * - CORS (localhost only) */ -import type { FastifyInstance, FastifyReply } from 'fastify'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { randomBytes, timingSafeEqual } from 'node:crypto'; import { StaleExpirationMap } from '../../utils/index.js'; import type { AuthSessionRecord } from '../ports/auth-port.js'; @@ -20,6 +20,17 @@ import { AUTH_FAILURE_WINDOW_MS, } from '../../config/auth-config.js'; import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js'; +import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js'; + +// Request-scoped identity (multi-user). Single-user leaves it undefined and the +// ownership helpers default to a synthetic admin (see route-helpers). +declare module 'fastify' { + interface FastifyRequest { + authUser?: AuthUser; + } +} // Auth session cookie name export const AUTH_COOKIE_NAME = 'codeman_session'; @@ -30,6 +41,83 @@ interface AuthState { authFailures: StaleExpirationMap | null; qrAuthFailures: StaleExpirationMap | null; hookSecretFailures: StaleExpirationMap | null; + /** Per-username Basic-auth failure bucket (multi-user only). */ + userFailures: StaleExpirationMap | null; +} + +/** Rate-limit response for a client that exceeded the failure cap. */ +function sendAuthRateLimit(reply: FastifyReply, failures: StaleExpirationMap, key: string): void { + const remainingMs = failures.getRemainingTtl(key) ?? AUTH_FAILURE_WINDOW_MS; + const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000)); + reply.header('Retry-After', String(retryAfterSeconds)); + reply.code(429).send('Too Many Requests — try again later'); +} + +/** Parse a `Basic base64(user:pass)` header into its parts, or null if malformed. */ +function parseBasicAuth(header?: string): { username: string; password: string } | null { + if (!header || !header.startsWith('Basic ')) return null; + try { + const decoded = Buffer.from(header.slice(6), 'base64').toString('utf-8'); + const idx = decoded.indexOf(':'); + if (idx < 0) return null; + return { username: decoded.slice(0, idx), password: decoded.slice(idx + 1) }; + } catch { + return null; + } +} + +/** + * The `/api/hook-event` + `/api/status-telemetry` localhost bypass, shared by the + * single-user and multi-user auth hooks so the security-critical logic has ONE + * source of truth. Returns: + * - 'bypass' : loopback + valid hook secret; the caller should allow the request + * - 'rejected' : a reply was already sent (wrong secret rate-limited / 401) + * - 'continue' : not a hook request (or non-loopback); fall through to normal auth + * + * COD-91: the shared hook secret is required UNCONDITIONALLY on the loopback bypass + * (a user's own loopback reverse proxy is indistinguishable from a real local hook). + */ +function checkHookSecretBypass( + req: FastifyRequest, + reply: FastifyReply, + hookSecretFailures: StaleExpirationMap +): 'bypass' | 'rejected' | 'continue' { + if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') { + const ip = req.ip; + const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; + if (isLoopback) { + const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? ''); + const expected = Buffer.from(getHookSecret()); + if (presented.length === expected.length && timingSafeEqual(presented, expected)) { + return 'bypass'; + } + const hookIp = req.ip; + const hookFailures = hookSecretFailures.get(hookIp) ?? 0; + if (hookFailures >= AUTH_FAILURE_MAX) { + sendAuthRateLimit(reply, hookSecretFailures, hookIp); + return 'rejected'; + } + hookSecretFailures.set(hookIp, hookFailures + 1); + reply.code(401).send('Unauthorized: hook secret required'); + return 'rejected'; + } + // Non-localhost hook requests fall through to normal auth + } + return 'continue'; +} + +/** + * Requests that a `mustChangePassword` user may still reach: the identity probe, + * the password-change endpoint, and any non-API path (static assets / index.html, + * so the browser can load the app and render the change-password modal). + */ +function isPasswordChangeExempt(req: FastifyRequest): boolean { + const url = (req.url ?? '').split('?')[0]; + if (url === '/api/me' || url === '/api/me/password') return true; + // Security: the WebSocket terminal (/ws/...) is a functional channel, not a static + // asset, so it must NOT be exempt, or a locked user keeps a working terminal. + if (url.startsWith('/ws/')) return false; + return !url.startsWith('/api/'); } /** @@ -47,13 +135,20 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au authFailures: null, qrAuthFailures: null, hookSecretFailures: null, + userFailures: null, }; - const authPassword = process.env.CODEMAN_PASSWORD; - if (!authPassword) return state; + // Always declare req.authUser so downstream reads are safe (single-user leaves it + // undefined; the ownership helpers then default to a synthetic admin). + if (!app.hasRequestDecorator('authUser')) app.decorateRequest('authUser', undefined); - const authUsername = process.env.CODEMAN_USERNAME || 'admin'; - const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64'); + const multiUser = isMultiUserMode(); + const authPassword = process.env.CODEMAN_PASSWORD; + + // No auth at all: single-user with no password (byte-identical to legacy). In + // multi-user mode auth is ALWAYS active (users authenticate individually), even + // without CODEMAN_PASSWORD. + if (!multiUser && !authPassword) return state; // Session token store — active sessions extend TTL on access state.authSessions = new StaleExpirationMap({ @@ -87,57 +182,28 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au const authFailures = state.authFailures; const hookSecretFailures = state.hookSecretFailures; - function sendAuthRateLimit( - reply: FastifyReply, - clientIp: string, - failures: StaleExpirationMap = authFailures - ): void { - const remainingMs = failures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS; - const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000)); - reply.header('Retry-After', String(retryAfterSeconds)); - reply.code(429).send('Too Many Requests — try again later'); + if (multiUser) { + // Per-username failure bucket: a botnet can't brute-force one account across + // many IPs, and one user behind a NAT can't lock out everyone else. + state.userFailures = new StaleExpirationMap({ + ttlMs: AUTH_FAILURE_WINDOW_MS, + refreshOnGet: false, + }); + registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures); + return state; } + // ── Single-user Basic Auth (unchanged behavior; CODEMAN_PASSWORD required) ── + const authUsername = process.env.CODEMAN_USERNAME || 'admin'; + const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64'); + app.addHook('onRequest', (req, reply, done) => { - // Hook events + statusline telemetry come from local Claude Code (curl from - // localhost) — no Basic-Auth credentials available. Validated downstream by - // HookEventSchema / StatusTelemetrySchema. Same loopback+hook-secret gate. - // - // COD-54: the bare localhost bypass is unsafe while a tunnel is running, because - // `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the - // loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and - // would pass. COD-91: require the shared hook secret on the loopback bypass - // UNCONDITIONALLY (not just while the managed tunnel is up). Codeman can't detect - // a user's own loopback reverse proxy (their own `cloudflared --url`, `tailscale - // serve`, nginx → 127.0.0.1), so tunnel-gating left that path with the unsafe plain - // bypass. Managed-session hooks always present the secret (X-Codeman-Hook-Secret, - // from $CODEMAN_HOOK_SECRET_FILE — generated for every instance), so requiring it - // always closes the gap without breaking the legitimate hook channel. - if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') { - const ip = req.ip; - const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; - if (isLoopback) { - // Always require the shared secret (constant-time compare). - const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? ''); - const expected = Buffer.from(getHookSecret()); - if (presented.length === expected.length && timingSafeEqual(presented, expected)) { - done(); - return; - } - // Wrong/absent secret — rate-limit per IP in the DEDICATED hook bucket - // (never authFailures, which would lock out the login path). - const hookIp = req.ip; - const hookFailures = hookSecretFailures.get(hookIp) ?? 0; - if (hookFailures >= AUTH_FAILURE_MAX) { - sendAuthRateLimit(reply, hookIp, hookSecretFailures); - return; - } - hookSecretFailures.set(hookIp, hookFailures + 1); - reply.code(401).send('Unauthorized: hook secret required'); - return; - } - // Non-localhost hook requests fall through to normal auth + const bypass = checkHookSecretBypass(req, reply, hookSecretFailures); + if (bypass === 'bypass') { + done(); + return; } + if (bypass === 'rejected') return; // QR auth path — handled by the route itself (token validation + rate limiting) if (req.url?.startsWith('/q/')) { @@ -153,10 +219,6 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au if (sessionToken && authSessions.get(sessionToken) !== undefined) { // Sliding cookie: re-issue on every authenticated request so the browser // cookie lifetime tracks the server-side sliding TTL (refreshOnGet above). - // Without this the cookie has a fixed lifetime from login; the browser - // drops it mid-use, the next request arrives cookie-less and falls through - // to Basic Auth — popping the native username/password dialog, which reads - // as a random logout while actively working. reply.setCookie(AUTH_COOKIE_NAME, sessionToken, { httpOnly: true, secure: https, @@ -206,7 +268,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au // Rate limit only requests that failed to authenticate on this attempt. const failures = authFailures.get(clientIp) ?? 0; if (failures >= AUTH_FAILURE_MAX) { - sendAuthRateLimit(reply, clientIp); + sendAuthRateLimit(reply, authFailures, clientIp); return; } @@ -220,6 +282,164 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au return state; } +/** + * Multi-user auth hook (async, because password verification runs scrypt). Verifies + * `username:password` against the user store, mints an identity-carrying cookie, + * decorates `req.authUser`, enforces the per-IP + per-username rate limits, and the + * `mustChangePassword` lockbox. The single-user hook above is left untouched. + */ +function registerMultiUserAuthHook( + app: FastifyInstance, + https: boolean, + authSessions: StaleExpirationMap, + authFailures: StaleExpirationMap, + hookSecretFailures: StaleExpirationMap, + userFailures: StaleExpirationMap +): void { + const setSessionCookie = (reply: FastifyReply, token: string) => + reply.setCookie(AUTH_COOKIE_NAME, token, { + httpOnly: true, + secure: https, + sameSite: 'lax', + maxAge: AUTH_SESSION_TTL_MS / 1000, + path: '/', + }); + + // Evict the oldest cookie session of the SAME user first (so one user logging in + // 100 times cannot flush everyone else's sessions), falling back to global-oldest. + const evictForCapacity = (username: string) => { + let userKey: string | undefined; + let userTs = Infinity; + let globalKey: string | undefined; + let globalTs = Infinity; + for (const [k, v] of authSessions) { + if (v.createdAt < globalTs) { + globalTs = v.createdAt; + globalKey = k; + } + if (v.username === username && v.createdAt < userTs) { + userTs = v.createdAt; + userKey = k; + } + } + const key = userKey ?? globalKey; + if (key !== undefined) authSessions.delete(key); + }; + + const enforcePasswordChange = (req: FastifyRequest, reply: FastifyReply, mustChange: boolean): boolean => { + if (mustChange && !isPasswordChangeExempt(req)) { + reply.code(403).send(createErrorResponse(ApiErrorCode.PASSWORD_CHANGE_REQUIRED)); + return true; + } + return false; + }; + + app.addHook('onRequest', async (req, reply) => { + const bypass = checkHookSecretBypass(req, reply, hookSecretFailures); + if (bypass === 'bypass' || bypass === 'rejected') return; + + // QR redemption path — handled by the route itself. + if (req.url?.startsWith('/q/')) return; + + const clientIp = req.ip; + + // 1. Cookie session (carries identity + mustChangePassword snapshot). + const sessionToken = req.cookies[AUTH_COOKIE_NAME]; + const record = sessionToken ? authSessions.get(sessionToken) : undefined; + if (record && record.username) { + // Security: re-validate the cookie identity against the store on every request so + // an out-of-band mutation the in-memory map can't see (the `codeman users` CLI, + // a separate process, deleting/disabling/demoting a user) takes effect promptly + // instead of riding the 24h cookie. findUser is cached ~1s, so this is cheap. + let live: Awaited>; + try { + live = await findUser(record.username); + } catch { + // The store is transiently unreadable/corrupt (readUsers throws on a non-ENOENT + // read, #23). Fall back to the cookie's snapshot for THIS request rather than + // 500-ing an already-authenticated client (pre-#24 behaviour); a persistently + // corrupt store still fails all WRITES loudly at the mutator/bootstrap layer. + req.authUser = { username: record.username, role: record.role ?? 'user' }; + setSessionCookie(reply, sessionToken!); + enforcePasswordChange(req, reply, !!record.mustChangePassword); + return; + } + if (!live || live.disabled) { + authSessions.delete(sessionToken!); + reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' }); + reply.code(401).send('Unauthorized'); + return; + } + // Trust the LIVE role/mustChangePassword, not the (possibly stale) cookie snapshot + // (also defends #9/#13: a CLI demotion is reflected without a revoke). + req.authUser = { username: live.username, role: live.role }; + setSessionCookie(reply, sessionToken!); // sliding re-issue + enforcePasswordChange(req, reply, !!live.mustChangePassword); + return; + } + + // 2. Basic Auth against the user store (scrypt verify). + // Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the + // per-username bucket here; see below). + const ipFail = authFailures.get(clientIp) ?? 0; + if (ipFail >= AUTH_FAILURE_MAX) { + sendAuthRateLimit(reply, authFailures, clientIp); + return; + } + const creds = parseBasicAuth(req.headers.authorization); + if (creds) { + const normUser = creds.username.trim().toLowerCase(); + // Security: VERIFY FIRST, then throttle only FAILED attempts. Consulting the + // per-username bucket before verifying let throwaway IPs lock out a known account + // (incl. admin) even with the correct password. A correct password must always + // win and self-heal both buckets, regardless of the username-failure count. + const result = await verifyPassword(creds.username, creds.password); + if (result) { + const { user, needsRehash: rehash } = result; + if (rehash) void setPassword(user.username, creds.password).catch(() => {}); + void touchLastLogin(user.username).catch(() => {}); + authFailures.delete(clientIp); + userFailures.delete(normUser); + + const token = randomBytes(32).toString('hex'); + if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username); + authSessions.set(token, { + ip: clientIp, + ua: req.headers['user-agent'] ?? '', + createdAt: Date.now(), + method: 'basic', + username: user.username, + role: user.role, + mustChangePassword: !!user.mustChangePassword, + }); + req.authUser = { username: user.username, role: user.role }; + setSessionCookie(reply, token); + enforcePasswordChange(req, reply, !!user.mustChangePassword); + return; + } + // Failed guess: count it against BOTH buckets. Once the per-username bucket + // reaches the cap, further FAILED attempts get 429 (throttles distributed + // brute-force), but this path is only reached on a wrong password, so it can + // never deny a correct one. + const uFail = (userFailures.get(normUser) ?? 0) + 1; + userFailures.set(normUser, uFail); + authFailures.set(clientIp, ipFail + 1); + if (uFail >= AUTH_FAILURE_MAX) { + sendAuthRateLimit(reply, userFailures, normUser); + return; + } + reply.header('WWW-Authenticate', 'Basic realm="Codeman"'); + reply.code(401).send('Unauthorized'); + return; + } + + // No credentials presented: count against the per-IP bucket and challenge. + authFailures.set(clientIp, ipFail + 1); + reply.header('WWW-Authenticate', 'Basic realm="Codeman"'); + reply.code(401).send('Unauthorized'); + }); +} + /** Methods that don't change server state and so skip the cross-site Origin check. */ const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); diff --git a/src/web/ports/auth-port.ts b/src/web/ports/auth-port.ts index ef022230..80059111 100644 --- a/src/web/ports/auth-port.ts +++ b/src/web/ports/auth-port.ts @@ -11,6 +11,19 @@ export interface AuthSessionRecord { ua: string; createdAt: number; method: 'qr' | 'basic'; + /** + * Multi-user identity carried by the cookie (single-user leaves these unset). + * Snapshotted at mint time. Authorization-relevant admin changes (password reset, + * disable, delete, role change, bypass-grant change) revoke the user's sessions so + * a stale snapshot can't outlive the change; additionally the cookie fast-path + * re-reads role/disabled/mustChangePassword live from the store each request, so an + * out-of-band CLI mutation also takes effect promptly. See docs/multi-user-plan.md + * section 5. + */ + username?: string; + role?: 'admin' | 'user'; + /** Whether this user must change their password before other actions are allowed. */ + mustChangePassword?: boolean; } export interface AuthPort { diff --git a/src/web/ports/config-port.ts b/src/web/ports/config-port.ts index 9d249cc0..bff5e9331 100644 --- a/src/web/ports/config-port.ts +++ b/src/web/ports/config-port.ts @@ -18,7 +18,7 @@ export interface ConfigPort { getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>; getTerminalHistoryConfig(): Promise; getDefaultClaudeMdPath(): Promise; - getLightState(): unknown; + getLightState(identity?: { username: string; role: 'admin' | 'user' }): unknown; getLightSessionsState(): unknown[]; startTranscriptWatcher(sessionId: string, transcriptPath: string): void; stopTranscriptWatcher(sessionId: string): void; diff --git a/src/web/ports/infra-port.ts b/src/web/ports/infra-port.ts index 322cdf37..21e5beca 100644 --- a/src/web/ports/infra-port.ts +++ b/src/web/ports/infra-port.ts @@ -23,6 +23,9 @@ export interface ScheduledRun { completedTasks: number; totalCost: number; logs: string[]; + /** Multi-user owner (username) — undefined in single-user mode. Used to scope + * list/delete and to downgrade the spawned Session's permission mode. */ + owner?: string; } export interface InfraPort { @@ -33,6 +36,6 @@ export interface InfraPort { readonly teamWatcher: TeamWatcher; readonly tunnelManager: TunnelManager; readonly pushStore: PushSubscriptionStore; - startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise; + startScheduledRun(prompt: string, workingDir: string, durationMinutes: number, owner?: string): Promise; stopScheduledRun(id: string): Promise; } diff --git a/src/web/public/admin-ui.js b/src/web/public/admin-ui.js new file mode 100644 index 00000000..aaadc98a --- /dev/null +++ b/src/web/public/admin-ui.js @@ -0,0 +1,260 @@ +/** + * @fileoverview Multi-user frontend: identity boot, admin Users panel, and the + * change-password flow. Self-contained (builds its own DOM) so it needs no + * index.html surgery beyond the script tag and integrates with the existing App + * Settings modal by injecting a "Users" tab (admins in multi-user mode only). + * + * @dependency app.js (window.app), settings-ui.js (App Settings modal + tab switch) + * @loadorder after settings-ui.js / ultracode-panel.js, before session-ui.js + * + * In single-user mode GET /api/me returns a synthetic admin with multiUser:false, + * so none of the admin UI is shown and behavior is unchanged. + */ +(function () { + 'use strict'; + + const unwrap = (body) => (body && typeof body === 'object' && 'data' in body ? body.data : body); + + async function apiGet(path) { + const res = await window.fetch(path, { headers: { Accept: 'application/json' } }); + return unwrap(await res.json()); + } + async function apiSend(method, path, body) { + const res = await window.fetch(path, { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + let json = null; + try { + json = await res.json(); + } catch { + /* empty body */ + } + return { ok: res.ok, status: res.status, body: json, data: unwrap(json) }; + } + + // ── Change-password modal ───────────────────────────────────────────────── + let cpModal = null; + function buildChangePasswordModal() { + if (cpModal) return cpModal; + const el = document.createElement('div'); + el.className = 'modal'; + el.id = 'changePasswordModal'; + el.style.zIndex = '3100'; + el.innerHTML = ` + `; + document.body.appendChild(el); + el.querySelector('#cpCancel').onclick = () => (el.style.display = 'none'); + el.querySelector('#cpSubmit').onclick = async () => { + const current = el.querySelector('#cpCurrent').value; + const nw = el.querySelector('#cpNew').value; + const confirm = el.querySelector('#cpConfirm').value; + const err = el.querySelector('#cpError'); + err.textContent = ''; + if (nw.length < 8) return (err.textContent = 'New password must be at least 8 characters.'); + if (nw !== confirm) return (err.textContent = 'Passwords do not match.'); + const r = await apiSend('POST', '/api/me/password', { currentPassword: current, newPassword: nw }); + if (!r.ok) return (err.textContent = (r.body && r.body.error) || 'Change failed.'); + el.style.display = 'none'; + if (window.app && window.app.showToast) window.app.showToast('Password changed'); + }; + cpModal = el; + return el; + } + function openChangePassword(forced) { + const el = buildChangePasswordModal(); + el.querySelector('#cpMustNote').style.display = forced ? '' : 'none'; + el.querySelector('#cpCancel').style.display = forced ? 'none' : ''; + el.querySelector('#cpError').textContent = ''; + el.style.display = 'flex'; + } + + // ── Fetch interceptor: surface PASSWORD_CHANGE_REQUIRED ─────────────────── + function installInterceptor() { + const orig = window.fetch; + window.fetch = async function (...args) { + const res = await orig.apply(this, args); + if (res.status === 403) { + try { + const clone = res.clone(); + const j = await clone.json(); + if (j && j.errorCode === 'PASSWORD_CHANGE_REQUIRED') openChangePassword(true); + } catch { + /* not JSON */ + } + } + return res; + }; + } + + // ── Admin Users panel (injected into the App Settings modal) ────────────── + function injectUsersTab() { + const modal = document.getElementById('appSettingsModal'); + if (!modal || modal.querySelector('[data-tab="settings-users"]')) return; + const tabs = modal.querySelector('.modal-tabs'); + const body = modal.querySelector('.modal-body'); + if (!tabs || !body) return; + const btn = document.createElement('button'); + btn.className = 'modal-tab-btn'; + btn.dataset.tab = 'settings-users'; + btn.textContent = 'Users'; + tabs.appendChild(btn); + const content = document.createElement('div'); + content.className = 'modal-tab-content hidden'; + content.id = 'settings-users'; + content.innerHTML = ` +
+ Users + +
+

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

+
+

`; + body.appendChild(content); + // Render whenever the tab is shown (the shared switchSettingsTab toggles it). + btn.addEventListener('click', renderUsers); + content.querySelector('#adminAddUser').onclick = addUserFlow; + } + + function esc(s) { + return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); + } + + async function renderUsers() { + const table = document.getElementById('adminUsersTable'); + if (!table) return; + table.innerHTML = 'Loading…'; + let users; + try { + users = await apiGet('/api/admin/users'); + } catch { + table.innerHTML = 'Failed to load users.'; + return; + } + const rows = users + .map((u) => { + const flags = [ + u.role === 'admin' ? 'admin' : 'user', + u.disabled ? 'disabled' : 'enabled', + u.canBypassPermissions ? 'can-bypass' : '', + u.mustChangePassword ? 'must-change-pw' : '', + ] + .filter(Boolean) + .join(', '); + const st = u.stats || {}; + return ` + ${esc(u.username)} + ${esc(flags)} + ${st.liveSessions ?? 0} live · ${st.caseCount ?? 0} cases + + + + + + + `; + }) + .join(''); + table.innerHTML = ` + + ${rows}
UserFlagsUsage
`; + table.querySelectorAll('button[data-act]').forEach((b) => { + b.onclick = () => + userAction( + b.closest('tr').dataset.u, + b.dataset.act, + users.find((x) => x.username === b.closest('tr').dataset.u) + ); + }); + } + + function setMsg(t) { + const m = document.getElementById('adminUsersMsg'); + if (m) m.textContent = t || ''; + } + + async function userAction(username, act, u) { + if (act === 'role') { + const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, { + role: u.role === 'admin' ? 'user' : 'admin', + }); + setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.'); + } else if (act === 'disabled') { + const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, { disabled: !u.disabled }); + setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.'); + } else if (act === 'bypass') { + const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, { + canBypassPermissions: !u.canBypassPermissions, + }); + setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.'); + } else if (act === 'reset') { + if (!window.confirm(`Reset ${username}'s password? They must set a new one on next login.`)) return; + const r = await apiSend('POST', `/api/admin/users/${encodeURIComponent(username)}/reset-password`); + if (r.ok && r.data && r.data.oneTimePassword) { + window.prompt(`One-time password for ${username} (copy it now — shown once):`, r.data.oneTimePassword); + } else setMsg((r.body && r.body.error) || 'Reset failed.'); + } else if (act === 'delete') { + const typed = window.prompt(`Type "${username}" to delete this user. Add " +space" to also delete their files.`); + if (typed !== username && typed !== `${username} +space`) return setMsg('Delete cancelled.'); + const deleteSpace = typed.endsWith(' +space'); + const r = await apiSend('DELETE', `/api/admin/users/${encodeURIComponent(username)}`, { deleteSpace }); + setMsg(r.ok ? `Deleted ${username}.` : (r.body && r.body.error) || 'Delete failed.'); + } + renderUsers(); + } + + async function addUserFlow() { + const username = window.prompt('New username (lowercase, 2-32 chars, [a-z0-9_-]):'); + if (!username) return; + const admin = window.confirm('Make this user an admin? (OK = admin, Cancel = regular user)'); + const r = await apiSend('POST', '/api/admin/users', { username: username.trim(), role: admin ? 'admin' : 'user' }); + if (r.ok && r.data && r.data.oneTimePassword) { + window.prompt(`Created ${username}. One-time password (copy it now — shown once):`, r.data.oneTimePassword); + } else setMsg((r.body && r.body.error) || 'Create failed.'); + renderUsers(); + } + + // ── Boot ────────────────────────────────────────────────────────────────── + async function boot() { + installInterceptor(); + let me = null; + try { + me = await apiGet('/api/me'); + } catch { + /* server may be pre-auth */ + } + window.__codemanUser = me || { username: 'admin', role: 'admin', multiUser: false }; + document.dispatchEvent(new CustomEvent('codeman:me', { detail: window.__codemanUser })); + if (window.__codemanUser.mustChangePassword) openChangePassword(true); + if (window.__codemanUser.multiUser && window.__codemanUser.role === 'admin') { + injectUsersTab(); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } + + window.codemanAdmin = { openChangePassword, renderUsers }; +})(); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index fb81deaf..435fe040 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -481,6 +481,9 @@ const SSE_EVENTS = { 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', }; // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/index.html b/src/web/public/index.html index a70f360e..28db93d3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1449,6 +1449,7 @@ @@ -2494,6 +2495,7 @@ + diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 6629862c..200dd961 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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); diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index a6ecc516..e2a2c2fc 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -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//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 { + 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, + 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, 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 | 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`), diff --git a/src/web/routes/admin-routes.ts b/src/web/routes/admin-routes.ts new file mode 100644 index 00000000..2f94e6b7 --- /dev/null +++ b/src/web/routes/admin-routes.ts @@ -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 { + 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) => + 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); + } + }); +} diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index ee899add..1cc82165 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -28,9 +28,23 @@ import { 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, @@ -78,11 +92,16 @@ async function readLinkedCases(): Promise> { return readJsonConfig>(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 { +/** + * 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 { 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); } /** @@ -134,47 +153,54 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // ========== List Cases ========== - app.get('/api/cases', async (): Promise => { + app.get('/api/cases', async (req): Promise => { 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, @@ -202,6 +228,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config 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 = { @@ -243,7 +270,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.post('/api/cases', async (req): Promise> => { 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'); } @@ -272,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> => { + // Hosts are machine-level resources: only admins may define them in multi-user mode. + const adminOnly = (req: FastifyRequest, reply: { code: (n: number) => unknown }): ApiResponse | 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> => { + 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)) { @@ -284,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> => { + app.put('/api/remote-hosts/:id', async (req, reply): Promise> => { + 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); @@ -296,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> => { + app.delete('/api/remote-hosts/:id', async (req, reply): Promise> => { + 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)) { @@ -311,7 +354,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config }); app.post('/api/cases/remote-link', async (req): Promise> => { - 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'); @@ -321,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'); } @@ -341,9 +384,15 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // ========== Docker hosts + docker cases (COD-Docker) ========== - app.get('/api/docker-hosts', async () => readDockerHosts(CODEMAN_CONFIG_DIR)); + // 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): Promise> => { + app.post('/api/docker-hosts', async (req, reply): Promise> => { + 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)) { @@ -353,7 +402,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return { success: true, data: { host } }; }); - app.put('/api/docker-hosts/:id', async (req): Promise> => { + app.put('/api/docker-hosts/:id', async (req, reply): Promise> => { + 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); @@ -365,7 +416,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return { success: true, data: { host } }; }); - app.delete('/api/docker-hosts/:id', async (req): Promise> => { + app.delete('/api/docker-hosts/:id', async (req, reply): Promise> => { + 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)) { @@ -386,7 +439,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config ): Promise< ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }> > => { - const dockerCase = { ...parseBody(DockerCaseLinkSchema, req.body), type: 'docker' as const }; + 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'); @@ -396,11 +453,17 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config if ( dockerCases.some((item) => item.name === dockerCase.name) || linkedCases[dockerCase.name] || - existsSync(join(CASES_DIR, 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. @@ -460,7 +523,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config > => { const body = parseBody(DockerQuickCreateSchema, req.body); const { name, description } = body; - const casePath = validatePathWithinBase(name, CASES_DIR); + const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req))); if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); // Collision across every case kind. @@ -525,7 +588,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config availability.error || 'docker daemon is not available' ); } - const dockerCase = { name, type: 'docker' as const, hostId: host.id, hostWorkspacePath: casePath }; + 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); @@ -644,11 +713,17 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config if ( dockerCases.some((item) => item.name === newCaseName) || linkedCases[newCaseName] || - existsSync(join(CASES_DIR, 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 { @@ -681,6 +756,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config 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' }); @@ -688,7 +764,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config }); // Link an existing folder as a case - app.post('/api/cases/link', async (req): Promise> => { + app.post('/api/cases/link', async (req, reply): Promise> => { + // 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 @@ -700,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.'); } @@ -735,27 +815,31 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.delete('/api/cases/:name', async (req): Promise> => { 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 } }; } const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR); - const dockerCase = dockerCases.find((item) => item.name === name); + const dockerCase = dockerCases.find((item) => item.name === name && canAccessOwned(user, item.owner)); if (dockerCase) { await writeDockerCases( CODEMAN_CONFIG_DIR, - dockerCases.filter((item) => item.name !== name) + 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). @@ -776,9 +860,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return { success: true, data: { name } }; } - // Check linked cases first — unlink only, don't delete the actual directory + // 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)); @@ -790,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`); } @@ -832,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'); @@ -855,7 +945,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config }; } - const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === 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'); @@ -875,13 +967,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config }; } - const casePath = await resolveCasePath(name); + 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, @@ -894,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'); @@ -999,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'); @@ -1042,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'); } @@ -1051,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'); diff --git a/src/web/routes/clipboard-routes.ts b/src/web/routes/clipboard-routes.ts index 32f22c4b..ecede0a2 100644 --- a/src/web/routes/clipboard-routes.ts +++ b/src/web/routes/clipboard-routes.ts @@ -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 {}; diff --git a/src/web/routes/cron-routes.ts b/src/web/routes/cron-routes.ts index 9aae6de2..d98488f7 100644 --- a/src/web/routes/cron-routes.ts +++ b/src/web/routes/cron-routes.ts @@ -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( + ctx.cron.listJobs().map((j): [string, string | undefined] => [j.id, j.owner]) + ); + return runs.filter((run) => canAccessOwned(user, ownerByJobId.get(run.cronJobId))); }); } diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 6d4da5fb..8d34e096 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -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')); diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index 62108a11..5f585cda 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -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'; diff --git a/src/web/routes/me-routes.ts b/src/web/routes/me-routes.ts new file mode 100644 index 00000000..ef1ba082 --- /dev/null +++ b/src/web/routes/me-routes.ts @@ -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 }; + }); +} diff --git a/src/web/routes/mux-routes.ts b/src/web/routes/mux-routes.ts index 14b93760..12b22229 100644 --- a/src/web/routes/mux-routes.ts +++ b/src/web/routes/mux-routes.ts @@ -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 {}; }); diff --git a/src/web/routes/orchestrator-routes.ts b/src/web/routes/orchestrator-routes.ts index fa0d4f6f..1fce72d0 100644 --- a/src/web/routes/orchestrator-routes.ts +++ b/src/web/routes/orchestrator-routes.ts @@ -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 }; diff --git a/src/web/routes/plan-routes.ts b/src/web/routes/plan-routes.ts index e1fe3b46..09d257d8 100644 --- a/src/web/routes/plan-routes.ts +++ b/src/web/routes/plan-routes.ts @@ -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) { diff --git a/src/web/routes/push-routes.ts b/src/web/routes/push-routes.ts index 0fb27b93..a45f88db 100644 --- a/src/web/routes/push-routes.ts +++ b/src/web/routes/push-routes.ts @@ -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 } }; }); diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index 12e2f349..df34ef8b 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -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 => { - // 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 diff --git a/src/web/routes/respawn-routes.ts b/src/web/routes/respawn-routes.ts index 2688d93d..56543997 100644 --- a/src/web/routes/respawn-routes.ts +++ b/src/web/routes/respawn-routes.ts @@ -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; } - 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; - 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; 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; 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)) { diff --git a/src/web/routes/scheduled-routes.ts b/src/web/routes/scheduled-routes.ts index 614f3824..0d56fe08 100644 --- a/src/web/routes/scheduled-routes.ts +++ b/src/web/routes/scheduled-routes.ts @@ -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> => { 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'); } diff --git a/src/web/routes/search-routes.ts b/src/web/routes/search-routes.ts index 0eaa19f8..82fb19f6 100644 --- a/src/web/routes/search-routes.ts +++ b/src/web/routes/search-routes.ts @@ -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 | 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. diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index af8abe51..2029dc66 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -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,7 +78,6 @@ 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'; @@ -259,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 @@ -285,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 { @@ -320,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!)); } @@ -438,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, @@ -447,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); @@ -476,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; @@ -491,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)) { @@ -510,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> => { - const sessionIds = Array.from(ctx.sessions.keys()); + app.delete('/api/sessions', async (req): Promise> => { + // 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) { @@ -538,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). @@ -553,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, @@ -569,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, @@ -585,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) { @@ -605,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, @@ -624,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'); @@ -651,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'); @@ -707,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'); @@ -740,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) { @@ -799,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'); @@ -831,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 {}; @@ -917,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 @@ -1368,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 @@ -1501,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); @@ -1522,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); @@ -1544,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); @@ -1565,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); @@ -1590,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); @@ -1610,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, @@ -1629,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 { @@ -1641,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); @@ -1671,13 +1745,9 @@ 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', @@ -1690,6 +1760,12 @@ 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. @@ -1697,11 +1773,20 @@ export function registerSessionRoutes( 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); + : (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'); @@ -1737,7 +1822,7 @@ export function registerSessionRoutes( // 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). + // 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 ( @@ -1834,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'); } @@ -1844,6 +1933,15 @@ 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; + // 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)) { @@ -1922,6 +2020,13 @@ 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, @@ -1931,11 +2036,12 @@ export function registerSessionRoutes( 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, @@ -2278,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. @@ -2289,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) { @@ -2307,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) }; }); @@ -2414,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; + 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, { @@ -2473,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); diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 17484463..021791fe 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -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(); + 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; @@ -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 } }; }); diff --git a/src/web/routes/ws-routes.ts b/src/web/routes/ws-routes.ts index f71a1ed4..1b59d6eb 100644 --- a/src/web/routes/ws-routes.ts +++ b/src/web/routes/ws-routes.ts @@ -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). diff --git a/src/web/server.ts b/src/web/server.ts index f3a2187b..d07abd55 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 | null = null; private qrAuthFailures: StaleExpirationMap | null = null; private hookSecretFailures: StaleExpirationMap | null = null; + private userFailures: StaleExpirationMap | null = null; private pushStore: PushSubscriptionStore = new PushSubscriptionStore(); private teamWatcher: TeamWatcher = new TeamWatcher(); private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null; @@ -330,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 ); @@ -680,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) @@ -790,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); @@ -899,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 @@ -1514,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 {}; @@ -1540,7 +1554,12 @@ export class WebServer extends EventEmitter { ); } - private async startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise { + private async startScheduledRun( + prompt: string, + workingDir: string, + durationMinutes: number, + owner?: string + ): Promise { const id = uuidv4(); const now = Date.now(); @@ -1556,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); @@ -1594,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); @@ -1740,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, username: string): Record { + const ownedIds = new Set(); + const ownedClaudeIds = new Set(); + 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 = {}; + for (const [id, v] of Object.entries((base.respawnStatus as Record) ?? {})) { + if (ownedIds.has(id)) respawnStatus[id] = v; + } + const bySession = (arr: unknown, key: 'sessionId' | 'sessionUuid') => + Array.isArray(arr) + ? (arr as Array>).filter((x) => ownedClaudeIds.has(String(x[key]))) + : arr; + const filtered: Record = { + ...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; @@ -1785,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 { @@ -1842,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) { @@ -1878,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)) { @@ -1925,6 +2084,24 @@ export class WebServer extends EventEmitter { } async start(): Promise { + // 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 --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(); @@ -2001,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} ` + @@ -2206,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` @@ -2243,6 +2432,7 @@ export class WebServer extends EventEmitter { // 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 @@ -2632,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(); diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index e9945ed4..69b566e7 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -386,6 +386,13 @@ 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 ───────────────────────────────────────────────────── /** @@ -576,4 +583,6 @@ export const SseEvent = { DockerImageBuildProgress, DockerImageBuildComplete, DockerImageBuildFailed, + AdminUsersChanged, + AuthPasswordChangeRequired, } as const; diff --git a/src/web/sse-stream-manager.ts b/src/web/sse-stream-manager.ts index 539846ef..32c65f71 100644 --- a/src/web/sse-stream-manager.ts +++ b/src/web/sse-stream-manager.ts @@ -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 | null> = new Map(); /** Optional client-supplied IDs → reply, for live filter updates without reconnecting */ private sseClientsById: Map = new Map(); + /** Per-client identity (multi-user); absent for single-user clients → no filtering. */ + private sseClientIdentity: Map = new Map(); /** SSE clients connecting from non-localhost (i.e. through tunnel) */ private remoteSseClients: Set = 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 | null, isRemote: boolean, clientId?: string): void { + addClient( + reply: FastifyReply, + sessionFilter: Set | 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(); diff --git a/test/admin-routes.test.ts b/test/admin-routes.test.ts new file mode 100644 index 00000000..17484d78 --- /dev/null +++ b/test/admin-routes.test.ts @@ -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 = {}; + +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'; + } + }); +}); diff --git a/test/admin-ui.test.ts b/test/admin-ui.test.ts new file mode 100644 index 00000000..72739ae7 --- /dev/null +++ b/test/admin-ui.test.ts @@ -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) { + const dom = new JSDOM( + ` + + `, + { url: 'http://localhost/', runScripts: 'outside-only' } + ); + const win = dom.window as unknown as Window & typeof globalThis & { __codemanUser?: Record }; + 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); + }); +}); diff --git a/test/claude-permission-mode.test.ts b/test/claude-permission-mode.test.ts new file mode 100644 index 00000000..a10a1caa --- /dev/null +++ b/test/claude-permission-mode.test.ts @@ -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'); + }); +}); diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts index d3c1a4bd..8ceec2ae 100644 --- a/test/edge-cases.test.ts +++ b/test/edge-cases.test.ts @@ -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 () => { diff --git a/test/multiuser-auth.test.ts b/test/multiuser-auth.test.ts new file mode 100644 index 00000000..25128914 --- /dev/null +++ b/test/multiuser-auth.test.ts @@ -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 = {}; + +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 }); + }); +}); diff --git a/test/ownership-scoping.test.ts b/test/ownership-scoping.test.ts new file mode 100644 index 00000000..aa6ad7df --- /dev/null +++ b/test/ownership-scoping.test.ts @@ -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 = {}; +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): Promise { + 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) => ({ 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([['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([ + ['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 { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} diff --git a/test/routes/scheduled-routes.test.ts b/test/routes/scheduled-routes.test.ts index b9e532f4..dda0b7bf 100644 --- a/test/routes/scheduled-routes.test.ts +++ b/test/routes/scheduled-routes.test.ts @@ -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); }); }); diff --git a/test/types.test.ts b/test/types.test.ts index 81c2ffab..5a73a10e 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -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'); }); }); }); diff --git a/test/user-store.test.ts b/test/user-store.test.ts new file mode 100644 index 00000000..ddd34967 --- /dev/null +++ b/test/user-store.test.ts @@ -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 = {}; + +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' }); + }); +});