Merge pull request #161 from Ark0N/feat/multiuser-mode

feat: opt-in multi-user mode (per-user spaces + admin panel)
This commit is contained in:
Ark0N
2026-07-20 13:07:58 +02:00
committed by GitHub
61 changed files with 4475 additions and 377 deletions
+11
View File
@@ -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/<name>/cases`, and ownership scoping of sessions (create/list/delete/mutate, incl. bulk delete), cases, cron jobs + run history, scheduled runs, search, file previews, session history, away digest, subagent/workflow monitors, and real-time SSE/WS streams (including the debounced session/task update path, clipboard, and push notifications). A non-admin's `workingDir` is realpath-confined to their own space at every spawn/link path (session create, quick-start, cron create/fire, scheduled runs, case link/docker-link, docker import). Non-admin users default to Claude's classifier-guarded `--permission-mode auto`; raw shell mode, cron `launchCommand`, skip-permissions, and the Codex/Gemini bypass switches require an explicit per-user `canBypassPermissions` grant (enforced at every spawn site incl. one-shots, plan generation, scheduled runs, and remote launches). Machine-level resources (remote/Docker hosts + host reads, mux sessions, orchestrator, tunnel, self-update, settings) are admin-only. Admin API (`/api/admin/users*`) with one-time passwords, last-admin invariants (validated before any teardown), and an append-only audit log; self-service `/api/me` + password change; a frontend admin Users tab + change-password modal; and `codeman users add|passwd|list|rm` CLI. Also adds a global `auto` Claude startup permission mode. When off, behavior is byte-identical to single-user.
Auth hardening: the login throttle verifies the password before consulting the per-account failure bucket (a correct password can never be locked out); the `mustChangePassword` lockbox covers the WebSocket terminal; the cookie fast-path re-validates identity against the store each request (so a CLI/admin delete/disable/demote takes effect promptly); a role/grant change revokes the target's sessions. (Known limitation: a bare CLI `codeman users passwd` reset — no delete — does not by itself revoke an already-active cookie until it expires; use `codeman users rm`, the admin API, or a restart to force-revoke.) Data-integrity hardening: the store distinguishes a missing users file from a corrupt/unreadable one (so a transient read error can't overwrite all accounts) and writes via a unique per-process temp file; the earlier fire-and-forget `touchLastLogin` corruption race is serialized.
Note: multi-user mode separates workspaces for a trusted team; it is not a security boundary between users (all sessions share the host OS account). Pair with Docker cases for real isolation.
+4 -2
View File
@@ -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/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (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/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (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/<name>/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/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
+20
View File
@@ -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/<name>/cases`; sessions, cases, search, and real-time events are scoped to their owner. Admins see everything.
- **Individually revocable logins** — named users with scrypt-hashed passwords in `~/.codeman/users.json`; disable, reset (one-time password), or delete an account at any time. Admin actions are audited to `~/.codeman/admin-audit.jsonl`.
- **Safer defaults for regular users** — non-admins run Claude in `--permission-mode auto` (Anthropic's classifier-guarded mode); raw shell sessions, cron `launchCommand`, and skip-permissions require an explicit per-user grant.
> ⚠️ **This separates workspaces; it does not sandbox users from each other.** Every session runs as the same OS account, so a determined user's agent can still reach another user's files. For real isolation, pair users with **Docker cases** or run separate instances under separate OS accounts. See [`docs/multi-user-plan.md`](docs/multi-user-plan.md) and the multi-user section of [`docs/security-architecture.md`](docs/security-architecture.md).
---
## Remote Access — Cloudflare Tunnel
Access Codeman from your phone or any device outside your local network using a free [Cloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) — no port forwarding, no DNS, no static IP required.
+282
View File
@@ -0,0 +1,282 @@
# Multi-User Mode: Design Plan
Status: **IMPLEMENTED on `feat/multiuser-mode`** (phases 1-5; opt-in, off by default). Target: opt-in multi-user support behind a `--multiuser` flag, with per-user case spaces and an admin panel for user management.
Shipped by phase:
- **Phase 1** (user store + mode plumbing + CLI): `src/user-store.ts` (scrypt, atomic 0600 writes, last-admin invariants, serialized read-modify-write), `src/config/multiuser.ts`, `codeman users add|passwd|list|rm`, `--multiuser` flag, bootstrap-on-first-boot. Tests: `test/user-store.test.ts`.
- **Phase 2** (multi-user auth): parallel async auth branch (`src/web/middleware/auth.ts`), `req.authUser`, per-username rate bucket, `mustChangePassword` lockbox, `GET /api/me` + `POST /api/me/password`, QR identity-bound minting, network-bind + tunnel exemptions, new error codes. Tests: `test/multiuser-auth.test.ts`.
- **Phase 3** (ownership threading): `Session.owner` at every create path + recovery mirror; `findSessionOrFail` owner check + list filtering; §6.3 permission policy (`resolveClaudeModeForUser` at all spawn sites incl. one-shots via `buildPromptArgs`; shell/launchCommand grant); per-user case spaces (`resolveCasesDir`) + owner-scoped case list + admin-only host CRUD; `workingDir` confinement; `sessionCapacityState` per-user cap. Tests: `test/ownership-scoping.test.ts`.
- **Phase 4** (event fan-out): WS owner gate; SSE per-client identity + `broadcast`/terminal-batch routing (`deriveSseHint`, fail-closed); `getLightState` per-identity filtering; file-route preview/thumbnail/history + `GET /api/search` scoping.
- **Phase 5** (admin API + frontend): `src/web/routes/admin-routes.ts` (user CRUD, one-time passwords, last-admin guards, session revoke/kill) + `src/web/admin-audit.ts`; `public/admin-ui.js` (identity boot, change-password modal + interceptor, admin Users tab). Tests: `test/admin-routes.test.ts`, `test/admin-ui.test.ts`.
Deferred follow-ups (documented, non-blocking): away-digest + subagent/workflow REST-list scoping, push-subscription identity/routing, per-user screenshot subdirs, `linked-cases.json` v2 owner field, `ScheduledRun.owner`, plan-orchestrator internal one-shot mode resolution, and a Playwright browser pass. Phase 6 (login form replacing Basic) remains out of scope.
## 1. Summary
Today Codeman is strictly single-user: one optional credential pair (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`), one shared `~/codeman-cases` folder, one global session list, and a global SSE/WS fan-out. This plan adds an opt-in **multi-user mode**:
- **Off by default.** Without the flag, behavior stays byte-identical to today (same auth path, same paths, same payloads). All new code is gated behind `isMultiUserMode()`.
- **`codeman web --multiuser`** (or `CODEMAN_MULTIUSER=1`) enables named users with individually hashed passwords stored in `~/.codeman/users.json`.
- **Each user gets their own space**: `~/codeman-users/<username>/cases/<case>` replaces the shared `~/codeman-cases` for that user. Sessions, cases, attachments, search, digests, and SSE events are scoped to their owner.
- **Admin panel** (App Settings, admin-only "Users" tab): create/delete users, change/reset passwords, enable/disable accounts, delete a user's space, see per-user live sessions and disk usage, force logout.
## 2. Threat Model (read first, be honest about this)
Multi-user mode is **workspace separation for a trusted team, NOT security isolation between mutually distrusting users**:
- Every session still runs as the **same OS account** with `claude --dangerously-skip-permissions`. Any user can ask their agent to `cat /home/<host>/codeman-users/otheruser/...`. The web layer enforces scoping; the agent layer cannot.
- **Shell sessions and custom launch commands are the bluntest holes**: `SessionMode = 'shell'` hands out a raw shell as the host account, and a cron job's `launchCommand` runs an arbitrary command; no Claude permission classifier is involved in either. These must be gated behind the same grant as bypass (section 6.3), otherwise the `auto`-mode mitigation below is theater.
- All sessions share one tmux socket (`-L codeman`), one `~/.claude` (transcripts, credentials, plan usage), one Claude subscription.
- Mitigation for stronger isolation: pair a user's cases with **Docker cases** (container per case, `docs/docker-cases.md`), or run separate Codeman instances per user (`CODEMAN_INSTANCE`, separate OS accounts). True per-user OS isolation is explicitly **out of scope** for this feature.
- Partial mitigation at the agent layer: non-admin users default to Claude's `auto` permission mode (section 6.3), whose safety classifier blocks destructive actions and credential exfiltration. That reduces, but does not eliminate, cross-user snooping; the `canBypassPermissions` grant reopens it and should be given deliberately.
This must be stated loudly in `docs/security-architecture.md`, the README section, and the admin panel UI ("Users share the host account; this separates workspaces, it does not sandbox users from each other").
Also note the flip side: multi-user mode strictly _improves_ today's network posture, because it removes the single shared password and gives every person their own revocable credential.
## 3. Activation and Mode Rules
| Condition | Behavior |
| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No flag (default) | Exactly today's behavior. `users.json` is never read. Single-user auth via `CODEMAN_PASSWORD` if set. |
| `--multiuser` / `CODEMAN_MULTIUSER=1`, `users.json` has users | Multi-user auth active. `CODEMAN_PASSWORD` is ignored for login (warn if set). |
| `--multiuser`, no `users.json` (first boot) | Bootstrap: if `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` are set, create that user as the initial admin and continue. Otherwise refuse to start with instructions to run `codeman users add <name> --admin`. Never start multi-user with zero users (there would be no way in). |
| `--multiuser` on a non-loopback bind | Allowed without `CODEMAN_PASSWORD`: `server.ts start()` treats "multi-user with >= 1 enabled user" as satisfying the auth requirement in the loud-warning check (wire into the existing `isLoopbackBindHost()` branch). |
| Flag later removed | Single-user mode again. Sessions/state that carry `owner` fields keep working (owner is simply ignored); user spaces remain on disk untouched. |
Plumbing: flag in `src/cli.ts` (web command), env in a new `src/config/multiuser.ts` exporting `isMultiUserMode()`. Per-instance like everything else: a beta instance (`CODEMAN_INSTANCE=beta`) has its own `users.json` via `dataPath()`.
## 4. Data Model and Disk Layout
### 4.1 `~/.codeman/users.json` (via `dataPath('users.json')`, mode 0600, atomic write: tmp + rename)
```jsonc
{
"version": 1,
"users": [
{
"username": "alice", // canonical lowercase slug
"role": "admin", // "admin" | "user"
"password": {
"algo": "scrypt", // node:crypto scrypt, no new deps
"N": 16384,
"r": 8,
"p": 1,
"salt": "<hex 32B>",
"hash": "<hex 64B>",
},
"disabled": false,
"mustChangePassword": false, // set by admin reset; gates all API access until changed
"canBypassPermissions": false, // permission-mode grant, see section 6.3; false for new users
"createdAt": 1752900000000,
"lastLoginAt": 1752900000000,
},
],
}
```
- **Username rules**: `^[a-z0-9][a-z0-9_-]{1,31}$` (it becomes a folder name), stored lowercase, unique case-insensitively. Reserve `admin`? No: any name can be admin; role is a field, not a name.
- **Hashing**: `scrypt` from `node:crypto` with per-user salt, compared via `timingSafeEqual`. Params stored per record so they can be raised later; verify tolerates old params and rehashes on next successful login.
- New module `src/user-store.ts` (mirrors the `remote-hosts.ts` / `docker-hosts.ts` pattern): `readUsers()`, `writeUsers()`, `verifyPassword()`, `createUser()`, `setPassword()`, `deleteUser()`, plus pure helpers (`isValidUsername`, `hashPassword`) that are unit-testable without IO. In-process cache with short TTL like `readSettings`, invalidated on every write; the short TTL also covers the CLI (section 10) editing `users.json` while the server runs (cross-process changes picked up within the TTL).
### 4.2 User spaces
```
~/codeman-users/
alice/
cases/
my-project/ <- same layout as today's ~/codeman-cases/<case>
bob/
cases/
```
- New helper in `route-helpers.ts`:
`resolveCasesDir(user?: AuthUser): string`
single-user mode: returns `CASES_DIR` (today's `~/codeman-cases`); multi-user: returns `join(USER_SPACES_DIR, user.username, 'cases')`, creating it lazily on first use.
- `CASES_DIR` stays exported for single-user code paths, but every route usage (see 6) switches to the resolver.
- The **user folder** (`~/codeman-users/<username>/`) is the deletion unit for "delete user + space" and leaves room for future per-user extras (uploads, exports) beside `cases/`.
- Legacy `~/codeman-cases` in multi-user mode: surfaces to admins only, as a read-only "Unassigned (legacy)" group in the case list, with an admin action `POST /api/admin/cases/assign { case, username }` that `fs.rename`s the folder into a user's space (same-filesystem move, cheap). No automatic migration.
## 5. Auth Pipeline Changes (`src/web/middleware/auth.ts`)
Keep the existing single-user branch untouched. Add a parallel multi-user branch selected once at registration time:
1. **Credential check**: Basic header parsed into `username:password`, verified against the user store (scrypt + `timingSafeEqual`). Disabled users fail closed.
2. **Cookie sessions**: same `codeman_session` cookie and `StaleExpirationMap`, but `AuthSessionRecord` gains `username` and `role`. All existing TTL/sliding/eviction logic reused. Eviction cap becomes per-user aware (evict oldest _of that user_ first) so one user cannot flush everyone's sessions by logging in 100 times.
3. **Request identity**: decorate `req.authUser = { username, role }` (Fastify decorateRequest). In single-user mode `req.authUser` is `{ username: 'admin', role: 'admin' }` when auth is on, and a synthetic admin when auth is off, so downstream code has ONE code path.
4. **Rate limiting**: keep the per-IP bucket; add a per-username failure bucket (same `StaleExpirationMap` pattern) so a botnet cannot brute-force one account across IPs, and one flaky user behind a NAT cannot lock out the rest.
5. **`mustChangePassword` gate**: when set, every API request except `GET /api/me`, `POST /api/me/password`, and static assets returns 403 with `errorCode: 'PASSWORD_CHANGE_REQUIRED'`; the frontend intercepts that code and shows the change-password modal.
6. **Password change vs Basic-auth caching**: browsers cache Basic credentials. After a password change we revoke all of that user's cookie sessions; the next request falls to Basic with stale creds, gets 401, and the browser re-prompts. Acceptable for v1; a proper login form is Phase 6 (see 15).
7. **Unchanged**: hook-secret loopback bypass (hooks authenticate the _instance_, not a user; the event maps to a session which has an owner), host guard, Origin/CSRF guard, security headers.
8. **WS upgrade identity** (`ws-routes.ts`): the global auth `onRequest` hook does run on the upgrade request (`@fastify/websocket` v11 runs hooks before the handshake; browsers send the session cookie), but the route handler itself only checks Host/Origin and never learns WHO authenticated. Multi-user: the handler reads the decorated `req.authUser` and closes 4003 unless owner or admin (section 6.4; identity plumbing lands in Phase 2, the owner check in Phase 4 once sessions have owners). Add a regression test that an upgrade with no credentials is rejected while auth is active: the handler-level Host/Origin gate alone must never be mistaken for auth.
9. **QR auth** (`/q/:code` redemption in `system-routes.ts`, minting in `tunnel-manager.ts`): today there is ONE global token, auto-rotated every 60s with a 90s grace window. A globally-rotating token cannot carry an identity (every logged-in user sees the same code), so multi-user mode replaces rotation with **on-demand minting**: an authenticated `POST /api/tunnel/qr` mints a single-use, short-TTL token bound to `req.authUser.username` (field on `QrTokenRecord`); redemption creates a cookie session for that user. Existing rate-limit buckets (`qrAuthFailures`, global `QR_RATE_LIMIT_MAX`) apply unchanged. Single-user mode keeps the rotating token.
New error codes in `src/types/api.ts`: `FORBIDDEN`, `PASSWORD_CHANGE_REQUIRED`, `USER_EXISTS`, `USER_NOT_FOUND`, `LAST_ADMIN`.
Role guard helper in `route-helpers.ts`: `requireAdmin(req, reply): boolean` used as the first line of every admin handler (403 `FORBIDDEN`), plus `requireOwnerOrAdmin(req, session)`.
## 6. Ownership Threading (the big refactor)
### 6.1 Sessions
- `Session` gains `owner?: string` (constructor option), persisted in `SessionState.owner`, included in `toState()`, round-tripped through recovery (`mux-sessions.json` entries carry it, `restoreMuxSessions` passes it back, exactly like `remote`/`docker`).
- Every session-creating path stamps the owner from `req.authUser`. Verified inventory of `new Session(...)` call sites: `POST /api/sessions` (session-routes.ts:444), `POST /api/quick-start` (:1956), `POST /api/run` one-shot (:1652), Ralph start (ralph-routes.ts:327), **cron** (cron-service.ts:352; `CronJob` gains `owner`, stamped at job create, launched as the job's owner), legacy `ScheduledRun` loop (server.ts:1603), plan generation + plan-orchestrator agents (plan-routes.ts:128, plan-orchestrator.ts:422/578; owner = requesting user), and recovery (server.ts:2225, next bullet). Two non-paths, also verified: **respawn never constructs a new Session** (it re-spawns the PTY on the same object, so `owner` survives automatically; no inheritance logic needed), and **orchestrator-loop creates no sessions** (it schedules work onto existing idle sessions via the task queue; its scoping requirement is different: it must only pick idle sessions owned by the goal's creator).
- Recovery: `owner` must ALSO be mirrored on `MuxSession` (mux-sessions.json) and read back mux-first like `remote`/`docker` (`muxSession.owner ?? savedState?.owner`, the server.ts:2246-2250 pattern), or a reboot erases ownership on the next persist.
- Every session-reading/mutating route filters: non-admin users only see and act on `session.owner === req.authUser.username`. Centralize in `findSessionOrFail` (route-helpers.ts:87; the owner check there covers the 6 route files that use it: system/session/respawn/ralph/file/plan-routes) and in the list endpoints (`GET /api/sessions`, `GET /api/sessions/unified`, `GET /api/status`). The Phase 3 audit must grep for BOTH `sessionManager.getSession` AND direct map access (`ctx.sessions.get(` / `.has(`): ws-routes and hook-event-routes reach sessions that way and bypass `findSessionOrFail`.
- Admins see everything; every session row carries `owner` so the UI can badge it.
### 6.2 Cases
- All `CASES_DIR` call sites switch to `resolveCasesDir(req.authUser)`: `case-routes.ts` (list/create/delete/CLAUDE.md scaffolding, name-collision checks, docker quickcreate), `session-routes.ts` (quick-start case resolution, the workingDir-inside-cases env-strip check), `ralph-routes.ts` (case path resolution), and `plan-routes.ts:231` (easy to miss). Case-name-to-path resolution is currently DUPLICATED (`resolveCasePath` in case-routes.ts:82 and an inline copy in quick-start, session-routes.ts:1846-1863); consolidate into one owner-aware resolver as part of this refactor instead of patching both copies.
- Registries that map case names to metadata become owner-scoped. `remote-cases.json`/`docker-cases.json` are arrays of objects, so entries simply gain `owner?: string` (absent = legacy: admin-only). `linked-cases.json` is a flat `Record<caseName, path>` with no room for a field: it needs a v2 shape (`{ "version": 2, "cases": { "<name>": { "path": "...", "owner": "..." } } }`) with read-time migration of the v1 form; it is read in two places (case-routes AND inline in quick-start), both must move to the new reader. Case names only need to be unique per user.
- **Remote hosts and Docker hosts are machine-level resources**: CRUD on `/api/docker-hosts` and remote-host endpoints becomes admin-only in multi-user mode; regular users can _use_ hosts on their own cases but not define them. (Docker containers exec as the host account; letting any user define arbitrary `docker run` args is admin-equivalent.)
- Case deletion, exports (`docker-exports/`), and imports check ownership; export filenames get an owner prefix to avoid collisions (fits the existing `^[a-zA-Z0-9._-]+\.tgz$` download guard).
- **Workspace confinement for non-admins (the linchpin, do not skip)**: today `POST /api/sessions` accepts ANY host directory as `workingDir` (the only check is `statSync().isDirectory()`, session-routes.ts:305-318), and file-routes/attachments confine reads to `session.workingDir`. Without a new rule the whole scoping story is circular: a user points a session at `~/codeman-users/bob` (or `/home`) and the web layer itself serves that subtree, no agent needed. Rule: in multi-user mode a non-admin's `workingDir` must realpath-resolve inside their own space, enforced at `POST /api/sessions`, `POST /api/run`, cron job create AND fire time (the dir can change owners between the two), and Ralph auto-configure. Admins are unrestricted. This one rule is what makes the section 6.4 file-route line ("own space or own sessions' workingDirs") meaningful.
### 6.3 Per-user Claude permission-mode policy
Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab: `settings.claudeMode`, values `dangerously-skip-permissions` (default) | `auto` | `normal` | `allowedTools`; `auto` emits `--permission-mode auto`, Anthropic's classifier-guarded low-prompt mode). Multi-user mode layers a per-user policy on top of it:
- **Default for regular users: `auto` only.** A non-admin's Claude sessions are forced to `--permission-mode auto` regardless of the global `claudeMode` setting. `normal` and `allowedTools` are also permitted (they are strictly more restrictive than auto), but `dangerously-skip-permissions` is NOT.
- **Bypass is an explicit admin grant**: `canBypassPermissions: true` on the user record (default `false`, section 4.1). Only with that grant does the global skip-permissions default (or a future per-user choice) apply to their sessions.
- **Admins** are unrestricted; the global setting applies to them as-is.
- **Single enforcement point**: a pure `resolveClaudeModeForUser(globalMode, user)` in `user-store.ts`, applied server-side at option-resolution time, BEFORE the Session constructor, so both downstream arg builders inherit it for free (`buildPermissionArgs` in session-cli-builder.ts for the direct-PTY path AND `buildClaudePermissionFlags` in tmux-manager.ts for tmux panes; there are two builders, not one). Call sites where `getClaudeModeConfig()` feeds a spawn: session-routes.ts:452/1964, ralph-routes.ts:334, cron-service.ts:360, and recovery (server.ts:2214/2233). Recovery re-reads the GLOBAL setting on reboot, so the resolver must run there with the RECOVERED owner, or a restart silently un-downgrades every restored session. Never resolved in the frontend, so it cannot be bypassed via payload.
- **Downgrade, don't error**: a non-granted user whose effective mode would be bypass gets `auto` silently (logged + surfaced as a badge on the session), so shared presets keep working.
- **Other CLIs' bypass equivalents** follow the same grant: Codex `--dangerously-bypass-approvals-and-sandbox` (`codexDangerouslyBypassApprovals`) and Gemini `--approval-mode yolo` are refused for non-granted users (Gemini falls back to `auto_edit`, Codex to its default sandbox). Whether this stays one grant or splits per-CLI is an open question (section 15).
- **Shell mode and custom launch commands follow the grant too**: `mode: 'shell'` sessions and cron `launchCommand` are arbitrary command execution as the host account, strictly stronger than any bypass flag, and no permission-mode downgrade applies to them. Non-granted users get 403 `FORBIDDEN` on shell session/quick-start creation and on cron jobs carrying `launchCommand` (checked at create AND at fire time). Folding them under `canBypassPermissions` keeps the model one-bit; section 15 asks whether it should split.
- **Admin UI**: a "Can skip permissions" toggle per user in the Users tab (PATCH field, section 8), with a warning echoing the section 2 threat model.
- Revoking the grant takes effect on the user's NEXT session start; live sessions are listed so the admin can restart them.
### 6.4 Everything else that lists or streams
| Surface | Scoping rule |
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SSE `/api/events` | Per-connection filter (see 7) |
| WS terminal (`ws-routes.ts`) | Handler reads `req.authUser` (section 5.8) and closes 4003 unless owner or admin; today it checks Host/Origin only and has no identity |
| `GET /api/search` | `harvestSources()` only over owned sessions |
| `GET /api/away-digest` | Aggregate only owned sessions/events |
| `GET /api/subagents`, workflow runs | Filter by owning session (`claudeSessionId -> session -> owner`); agents not attributable to any session: admin-only |
| Push (`push-routes.ts`) | Subscription records currently carry NO identity (keyed by endpoint only): `subscribe` stamps `username`. All 8 `PUSH_EVENT_MAP` events are session-scoped, so routing = resolve owner from `data.sessionId`, deliver to that owner's (plus admins') subscriptions. Legacy identity-less subscriptions: admin-only delivery |
| Screenshots `/api/screenshots` | Per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only |
| `getLightState` init snapshot | Filtered per connection. Actual contents to filter (verified): `sessions`, `scheduledRuns`, `respawnStatus`, `subagents`, `workflowRuns`, `planUsage` (host-plan telemetry: admin-only); `globalStats` stays coarse-global. Cron jobs are NOT in the snapshot (they have their own REST route; filter there). The snapshot is cached process-wide (`LIGHT_STATE_CACHE_TTL_MS`): either key the cache per role/user or filter AFTER the cache on each send |
## 7. SSE Event Filtering
`/api/events` currently broadcasts everything to everyone. Ground truth first (verified): `broadcast()` lives in `SseStreamManager` (`sse-stream-manager.ts`), not server.ts; clients are keyed by the raw Fastify reply (`sseClients: Map<FastifyReply, Set<string> | null>`, plus `sseClientsById` for live filter updates); the existing `?sessions=` filter is a bandwidth optimization applied ONLY to `session:terminal` batches in `flushSessionTerminalBatch()`, while `broadcast()` itself loops ALL clients unconditionally. The single-client delivery primitive already exists (`sendSSE`, used for the per-connection init snapshot). Plan:
- At connection time, resolve `req.authUser` and store `{ username, role }` with the client. Concretely: extend `addClient(reply, sessionFilter, isRemote, clientId)` to take the identity and change the `sseClients` map value to `{ filter, identity }` (or add a parallel `Map<reply, identity>`); there is no per-client record object today to hang it on.
- `broadcast()` gains an optional routing hint: `broadcast(event, data, { sessionId?, adminOnly?, username? })`. Resolution order per client: admin sees all; `username` targets one user; `sessionId` resolves owner via SessionManager; `adminOnly` for machine-level events (docker image builds, tunnel, self-update); no hint = broadcast to all (connection status etc.).
- **Enforce the identity check in BOTH `broadcast()` AND `flushSessionTerminalBatch()`**: the terminal batch path does not go through `broadcast()`, and it carries the highest-value payload (raw terminal bytes).
- Sweep of the ~120 backend event constants in `sse-events.ts`: mechanically, everything `session:*`, `ralph:*`, `respawn:*`, `subagent:*`, `workflow:*`, `attachment:*`, `cron:*` (job owner) carries or can resolve a sessionId/owner; `docker:*`, `system:*`, tunnel and update events are adminOnly; a short tail needs case-by-case decisions during implementation.
- The existing `?sessions=` filter and `/api/events/subscribe` compose with (never override) the ownership filter: the subscription filter can only narrow within what the identity allows.
## 8. Admin API (`src/web/routes/admin-routes.ts`, new module + `AdminPort`)
All handlers: multi-user mode only (404 otherwise), `requireAdmin`, Zod schemas in `schemas.ts`, `ApiResponse` envelope, audit-logged.
| Endpoint | Behavior |
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/admin/users` | List users + stats: role, disabled, createdAt, lastLoginAt, live session count, case count, space disk usage (best-effort async walk, cached 60s), active cookie-session count |
| `POST /api/admin/users` | Create: `{ username, role, password? }`. No password given: generate a one-time password, return it ONCE in the response, set `mustChangePassword` |
| `PATCH /api/admin/users/:username` | `{ role?, disabled?, canBypassPermissions? }`. Demoting/disabling the last enabled admin: 409 `LAST_ADMIN`. Disable also revokes cookie sessions. `canBypassPermissions` is the section 6.3 grant (default false) |
| `POST /api/admin/users/:username/reset-password` | Generates one-time password (returned once), sets `mustChangePassword`, revokes cookie sessions |
| `POST /api/admin/users/:username/logout` | Revoke all cookie sessions for that user. Honest limit under Basic auth: the browser silently re-sends cached credentials and gets a fresh cookie on the next request, so logout only truly ends QR-issued sessions; to actually lock someone out, disable the account or reset the password. Say so in the panel tooltip until Phase 6 |
| `DELETE /api/admin/users/:username` | `{ deleteSpace?: boolean }` (default false). Refuses last admin. Kills the user's live sessions first (normal kill flow, incl. docker/remote teardown per case), revokes cookies, removes from store. With `deleteSpace`: guarded recursive delete of `~/codeman-users/<username>` (realpath must be inside `USER_SPACES_DIR`, top-level dir must not be a symlink), plus their registry entries and push subscriptions |
| `POST /api/admin/cases/assign` | Move a legacy `~/codeman-cases/<case>` into a user's space (`fs.rename`) |
| Self-service `GET /api/me` | `{ username, role, mustChangePassword }` (works in single-user mode too: synthetic admin; the frontend uses it to decide whether to render admin UI) |
| Self-service `POST /api/me/password` | `{ currentPassword, newPassword }`, verifies current, min length 8, revokes other sessions, clears `mustChangePassword` |
**Audit log**: append-only `~/.codeman/admin-audit.jsonl` (same idiom as `session-lifecycle.jsonl`): timestamp, acting admin, action, target, request IP. User management without an audit trail is not acceptable even for a homelab tool.
SSE additions (both `sse-events.ts` and `constants.js`): `admin:usersChanged` (adminOnly; the panel re-fetches) and `auth:passwordChangeRequired` (targeted to the user).
## 9. Frontend
- **`GET /api/me` on boot** (app.js init): stores `window.__codemanUser`; everything below keys off it. Single-user mode returns the synthetic admin, so the UI needs no mode awareness beyond "am I admin".
- **Admin panel**: new tab "Users" in the App Settings modal (settings-ui.js), rendered only for admins in multi-user mode. Table of users with actions (create, reset password showing the one-time password in a copy-to-clipboard reveal, enable/disable, role toggle, logout, delete with a typed-username confirm for the delete-space variant). No new header button (mobile header policy test stays green; the settings modal is already reachable everywhere).
- **Change-password modal**: shown on `PASSWORD_CHANGE_REQUIRED` (fetch interceptor in api-client.js) and reachable from settings for self-service.
- **Owner badges**: admin's session tabs and the session palette/manager show `owner` on foreign sessions; regular users see no change.
- New module `admin-ui.js` if the settings-ui.js addition gets large (load order after settings-ui, before session-ui), else keep inside settings-ui.js. Follow the `@fileoverview` + `@loadorder` convention either way.
## 10. CLI Additions (`src/cli.ts`)
Headless bootstrap and recovery must not require the web UI:
```
codeman users add <name> [--admin] # prompts for password (hidden input), or --password-stdin
codeman users passwd <name> # reset password
codeman users list
codeman users rm <name> [--delete-space]
```
These operate directly on `users.json` via `user-store.ts` (no server needed), honoring `CODEMAN_INSTANCE`. This is also the answer to "locked out: last admin forgot password".
## 11. Limits and Config
- New `src/config/multiuser.ts`: `isMultiUserMode()`, `USER_SPACES_DIR` (`~/codeman-users`, overridable via `CODEMAN_USER_SPACES_DIR` for tests), `MAX_USERS` (default 25), per-user session cap (default: global cap / 2, env `CODEMAN_MAX_SESSIONS_PER_USER`).
- Cap enforcement is currently COPY-PASTED: the global `MAX_CONCURRENT_SESSIONS` (50, `config/map-limits.ts:25`) check appears at 6 independent sites (session-routes.ts:298/1622/1683, ralph-routes.ts:275, cron-service.ts:340, server.ts:1595). Do not add a 7th copy per site: extract one `assertSessionCapacity(ctx, owner?)` helper doing the global + per-user checks and use it everywhere, or the per-user cap WILL miss a path.
- Global limits (50 sessions, SSE clients 100, terminal buffers) are unchanged and shared; the per-user session cap is the fairness lever.
## 12. Compatibility Matrix
| Concern | Guarantee |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Default (no flag) | No behavior change. No new file reads on the hot path. All new fields optional in state |
| State round-trip | `SessionState.owner`, `MuxSession.owner`, `CronJob.owner`, registry `owner` fields are optional; old state loads clean; new state loaded by an old build ignores unknown fields (existing tolerant parsing) |
| Instance isolation | `users.json`, audit log, screenshots subdirs all via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
| API versioning | HTTP API is internal per `docs/versioning-policy.md`; still, all changes are additive. Ship as a **minor** version |
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
## 13. Implementation Phases
Each phase is independently shippable behind the flag and ends with its tests green.
**Phase 1: user store + mode plumbing** (no behavior change yet)
`src/user-store.ts`, `src/config/multiuser.ts`, CLI `users` subcommands, bootstrap-on-first-boot logic, `users.json` schema + atomic writes.
Tests: `test/user-store.test.ts` (hashing, verify, params upgrade, username validation, atomic write, last-admin invariants; pure, no server).
**Phase 2: multi-user auth**
Auth middleware branch, `req.authUser` decoration, cookie records with username/role, per-username rate bucket, `mustChangePassword` gate, WS upgrade identity plumbing + unauthenticated-upgrade regression test (section 5.8), QR on-demand minting + identity binding (section 5.9), `GET /api/me`, `POST /api/me/password`, error codes, network-bind check integration.
Tests: `test/multiuser-auth.test.ts` (live server, unique port 3170+; wrong password, disabled user, cookie carries identity, per-user rate limit isolation, mustChangePassword lockbox, QR redemption identity). Reuse the `delete process.env.CODEMAN_PASSWORD` idiom from `test/setup.ts`.
**Phase 3: ownership threading**
Session `owner` + persistence + `MuxSession` mirror + recovery; `resolveCasesDir()` refactor across case/session/ralph/plan routes (consolidating the duplicated case-path resolution); registry owner fields incl. the linked-cases v2 shape; `findSessionOrFail` owner check + the direct-`sessions.get` audit; list filtering; owner stamping across ALL create paths from 6.1; **non-admin workingDir confinement** (6.2); permission-mode/shell/launchCommand policy (6.3); `assertSessionCapacity` helper + per-user cap.
Tests: `test/routes/ownership-scoping.test.ts` (inject-based: user A cannot read/kill/input user B's session, case lists are disjoint, admin sees both), extend `test/cron-service.test.ts` for owner stamping, recovery round-trip in the existing mux-recovery tests.
**Phase 4: event fan-out + remaining surfaces**
SSE routing hints + client identity (enforced in BOTH `broadcast()` and the terminal-batch flush), WS owner gate (identity landed in Phase 2), search/digest/subagent/workflow scoping, push subscription identity + owner routing, screenshot subdirs, file-route scoping, `getLightState` filtering + per-identity caching, admin-only system ops.
Tests: `test/sse-ownership.test.ts` (two SSE clients, event for A's session reaches only A + admin), WS upgrade rejection test, search/digest scoping tests.
**Phase 5: admin API + frontend**
`admin-routes.ts` + `AdminPort` + schemas + audit log + `admin:usersChanged`; settings-ui Users tab, change-password modal, owner badges, api-client interceptor.
Tests: `test/routes/admin-routes.test.ts` (CRUD, last-admin 409, one-time password flow, delete-space guard rails incl. symlink refusal), frontend vm-sandbox test following `test/run-mode-ui.test.ts` pattern, Playwright pass per the always-end-to-end rule before calling it done.
**Phase 6 (optional, later): login page**
Replace Basic with a form + `POST /api/login` in multi-user mode only (fixes browser credential caching UX, enables logout button). Explicitly deferred; Basic works for v1.
**Docs**: update `docs/security-architecture.md` (new section: multi-user model + threat model from section 2), `README.md` (short opt-in section), `CLAUDE.md` (Key Patterns entry + State Files + route/SSE counts), this file gets a "shipped" status stamp per phase.
## 14. Key Risks / Decisions Made
1. **Not a security boundary at the agent layer** (section 2). Decided: ship with loud documentation; Docker cases are the isolation story.
2. **`findSessionOrFail` as the single enforcement point** for ~30 session routes: any route that fetches sessions another way must be audited in Phase 3 (grep for `sessionManager.getSession` outside route-helpers).
3. **SSE sweep is the riskiest surface**: a missed event leaks metadata (not terminal content, which is session-scoped, but names/paths). Phase 4 includes a checklist pass over all ~138 events with the default flipped to "owner-scoped unless explicitly global": fail closed.
4. **Basic-auth password-change UX** is mediocre (browser re-prompt). Accepted for v1; Phase 6 fixes it properly.
5. **Legacy case migration** is manual (admin assigns). No silent moves of user data.
6. **Case-name uniqueness becomes per-user**; tmux session names already include the session id so no collision, but the `w<n>-<case>` tab naming and lifecycle-log rows should include the owner for disambiguation in admin views.
7. **`workingDir` confinement (6.2) is the single most load-bearing rule**: every file-serving and agent-spawning surface downstream trusts `session.workingDir`. Review and test it as carefully as the auth branch (foreign-space path, symlink into a foreign space, `..` traversal, cron fire-time re-check).
8. **The WS handler never sees identity today** (auth happens only in the global hook): the 5.8 wiring is new code on a security-sensitive path; cover unauthenticated, foreign-user, and admin upgrades with tests.
## 15. Open Questions (answer before Phase 3)
1. Should admins' own cases live in `~/codeman-users/<admin>/cases` (symmetric, proposed) or keep using legacy `~/codeman-cases`? Proposed: symmetric; legacy dir is a migration source only.
2. Per-user settings (respawn presets, notification prefs): global-only in v1. Worth a `users/<name>/settings.json` overlay later?
3. Should regular users be allowed to create Docker cases on admin-defined hosts (proposed: yes) or is Docker entirely admin-only?
4. Session handoff: does an admin need "reassign session/case to another user"? (Cheap to add next to `cases/assign`; not in v1 scope.)
5. Permission-mode grants (section 6.3): one `canBypassPermissions` flag covering Claude/Codex/Gemini bypass equivalents PLUS shell mode and cron `launchCommand` (proposed: one flag, keep it one-bit), or split into `canBypassPermissions` + `canRunArbitraryCommands`? And should admins be able to set a per-user DEFAULT mode (for example force `normal` for an intern) rather than just gating bypass?
6. OpenCode has no single bypass flag (its permission config rides `OPENCODE_CONFIG_CONTENT`): decide what the grant means there before Phase 3, or exclude OpenCode mode for non-granted users in v1.
+13
View File
@@ -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/<name>/cases`, checked BEFORE any disk write.
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
- **Admin actions are audited** append‑only to `~/.codeman/admin-audit.jsonl` (acting admin, action, target, IP). Passwords set by an admin create/reset are one‑time (returned once, force change). Under Basic auth, `logout` only truly ends QR‑issued sessions — to lock someone out, disable the account or reset the password (a proper login form is a deferred Phase 6).
---
## 11. Quick reference
| Env / flag | Effect |
+166
View File
@@ -584,7 +584,11 @@ program
'--allow-unauthenticated-network',
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
)
.option('--multiuser', 'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)')
.action(async (options) => {
// The flag is surfaced to the rest of the process via the env var so
// isMultiUserMode() has a single source of truth (see config/multiuser.ts).
if (options.multiuser) process.env.CODEMAN_MULTIUSER = '1';
const { startWebServer } = await import('./web/server.js');
const host = options.host;
const port = parseInt(options.port, 10);
@@ -626,6 +630,168 @@ program
}
});
// ============ Multi-user Commands ============
//
// Operate directly on ~/.codeman/users.json (via user-store) with NO running
// server, honoring CODEMAN_INSTANCE. This is the headless bootstrap path and the
// recovery answer to "locked out: last admin forgot password".
/** Read a password from stdin without echoing. Falls back to plain read on non-TTY. */
function promptHiddenPassword(question: string): Promise<string> {
const stdin = process.stdin;
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
// Non-interactive: read a single line from stdin.
return new Promise((resolve) => {
let buf = '';
stdin.setEncoding('utf8');
stdin.on('data', (d) => (buf += d));
stdin.on('end', () => resolve(buf.replace(/\r?\n$/, '')));
});
}
return new Promise((resolve) => {
process.stdout.write(question);
let input = '';
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding('utf8');
const onData = (chunk: string) => {
for (const c of chunk) {
if (c === '\n' || c === '\r' || c === '\u0004') {
stdin.setRawMode!(false);
stdin.pause();
stdin.removeListener('data', onData);
process.stdout.write('\n');
resolve(input);
return;
} else if (c === '\u0003') {
process.stdout.write('\n');
process.exit(1);
} else if (c === '\u007f' || c === '\b') {
input = input.slice(0, -1);
} else {
input += c;
}
}
};
stdin.on('data', onData);
});
}
function readAllStdin(): Promise<string> {
return new Promise((resolve) => {
let buf = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (d) => (buf += d));
process.stdin.on('end', () => resolve(buf.replace(/\r?\n$/, '')));
});
}
const usersCmd = program.command('users').description('Manage multi-user accounts (~/.codeman/users.json)');
usersCmd
.command('add <name>')
.description('Create a user (prompts for password; use --password-stdin for scripts)')
.option('--admin', 'Create as an admin')
.option('--password-stdin', 'Read the password from stdin instead of prompting')
.action(async (name, options) => {
const { createUser, isValidUsername } = await import('./user-store.js');
if (!isValidUsername(name)) {
console.error(chalk.red('✗ Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])'));
process.exit(1);
}
try {
let password: string;
if (options.passwordStdin) {
password = await readAllStdin();
} else {
password = await promptHiddenPassword('New password: ');
const confirm = await promptHiddenPassword('Confirm password: ');
if (password !== confirm) {
console.error(chalk.red('✗ Passwords do not match'));
process.exit(1);
}
}
if (!password || password.length < 8) {
console.error(chalk.red('✗ Password must be at least 8 characters'));
process.exit(1);
}
const user = await createUser({ username: name, role: options.admin ? 'admin' : 'user', password });
console.log(chalk.green(`✓ Created ${user.role} "${user.username}"`));
} catch (err) {
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
process.exit(1);
}
});
usersCmd
.command('passwd <name>')
.description('Reset a user password')
.option('--password-stdin', 'Read the new password from stdin instead of prompting')
.action(async (name, options) => {
const { setPassword } = await import('./user-store.js');
try {
let password: string;
if (options.passwordStdin) {
password = await readAllStdin();
} else {
password = await promptHiddenPassword('New password: ');
const confirm = await promptHiddenPassword('Confirm password: ');
if (password !== confirm) {
console.error(chalk.red('✗ Passwords do not match'));
process.exit(1);
}
}
await setPassword(name, password, { mustChangePassword: false });
console.log(chalk.green(`✓ Password updated for "${name}"`));
} catch (err) {
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
process.exit(1);
}
});
usersCmd
.command('list')
.alias('ls')
.description('List all users')
.action(async () => {
const { readUsers } = await import('./user-store.js');
const users = await readUsers(true);
if (users.length === 0) {
console.log(chalk.yellow('No users defined (run: codeman users add <name> --admin)'));
return;
}
console.log(chalk.bold('\nUsers:'));
for (const u of users) {
const role = u.role === 'admin' ? chalk.magenta('admin') : chalk.cyan('user ');
const state = u.disabled ? chalk.red('disabled') : chalk.green('enabled ');
const flags = [u.mustChangePassword ? 'must-change-pw' : '', u.canBypassPermissions ? 'can-bypass' : '']
.filter(Boolean)
.join(' ');
console.log(` ${role} ${state} ${u.username}${flags ? chalk.gray(` [${flags}]`) : ''}`);
}
console.log('');
});
usersCmd
.command('rm <name>')
.description('Delete a user')
.option('--delete-space', "Also delete the user's ~/codeman-users/<name> space")
.action(async (name, options) => {
const { deleteUser, deleteUserSpace } = await import('./user-store.js');
try {
await deleteUser(name);
if (options.deleteSpace) {
await deleteUserSpace(name);
console.log(chalk.green(`✓ Deleted user "${name}" and their space`));
} else {
console.log(chalk.green(`✓ Deleted user "${name}" (space left on disk)`));
}
} catch (err) {
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
process.exit(1);
}
});
program
.command('doctor')
.alias('check-deps')
+63
View File
@@ -0,0 +1,63 @@
/**
* @fileoverview Multi-user mode gating + limits (opt-in, off by default).
*
* Multi-user mode is enabled by `codeman web --multiuser` (which sets
* `CODEMAN_MULTIUSER=1`) or the env var directly. When OFF, behavior is
* byte-identical to today: `users.json` is never read and all ownership scoping
* is bypassed. Everything here is per-instance like the rest of Codeman: a beta
* instance (`CODEMAN_INSTANCE=beta`) has its own `users.json` via `dataPath()`,
* and its user spaces live under the same shared `~/codeman-users` as prod (like
* `~/codeman-cases`), unless `CODEMAN_USER_SPACES_DIR` overrides it.
*
* See `docs/multi-user-plan.md` sections 3, 4.2, and 11.
*/
import { homedir } from 'node:os';
import { join } from 'node:path';
import { MAX_CONCURRENT_SESSIONS } from './map-limits.js';
/**
* Whether multi-user mode is active. Read from the environment each call so it is
* stable for the process lifetime (env does not change after boot) and trivially
* overridable in tests. Accepts `1` or `true`.
*/
export function isMultiUserMode(): boolean {
const v = process.env.CODEMAN_MULTIUSER;
return v === '1' || v === 'true';
}
/**
* Root of per-user spaces: `~/codeman-users` (sibling of `~/codeman-cases`).
* Overridable via `CODEMAN_USER_SPACES_DIR` (used by tests). Resolved lazily so a
* test can point it at a temp dir before the first call.
*/
export function getUserSpacesDir(): string {
return process.env.CODEMAN_USER_SPACES_DIR || join(homedir(), 'codeman-users');
}
/** Absolute path to a user's top-level space: `<USER_SPACES_DIR>/<username>[/segments]`. */
export function userSpacePath(username: string, ...segments: string[]): string {
return join(getUserSpacesDir(), username, ...segments);
}
/** Absolute path to a user's cases dir: `<USER_SPACES_DIR>/<username>/cases`. */
export function userCasesDir(username: string): string {
return join(getUserSpacesDir(), username, 'cases');
}
/** Maximum number of user accounts (default 25, env `CODEMAN_MAX_USERS`). */
export function maxUsers(): number {
const n = Number(process.env.CODEMAN_MAX_USERS);
return Number.isInteger(n) && n > 0 ? n : 25;
}
/**
* Per-user concurrent-session cap (the fairness lever). Defaults to half the
* global cap; overridable via `CODEMAN_MAX_SESSIONS_PER_USER`. The global cap
* (MAX_CONCURRENT_SESSIONS) still applies on top and is shared across users.
*/
export function maxSessionsPerUser(): number {
const n = Number(process.env.CODEMAN_MAX_SESSIONS_PER_USER);
if (Number.isInteger(n) && n > 0) return n;
return Math.max(1, Math.floor(MAX_CONCURRENT_SESSIONS / 2));
}
+35 -4
View File
@@ -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();
+6
View File
@@ -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). */
+22 -2
View File
@@ -20,7 +20,7 @@ import type { TerminalMultiplexer } from './mux-interface.js';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
import { getErrorMessage, type PlanItem } from './types.js';
import { getErrorMessage, type PlanItem, type ClaudeMode } from './types.js';
// Re-export for backward compatibility
export type { PlanItem };
@@ -130,18 +130,28 @@ export class PlanOrchestrator {
private taskDescription = '';
private researchModel: string;
private plannerModel: string;
// Multi-user permission threading: the resolved claudeMode/owner/allowedTools for the
// internal research/planner one-shots. Left undefined = today's single-user behavior
// (the caller threads the resolved global mode, byte-identical when !isMultiUserMode()).
private claudeMode?: ClaudeMode;
private owner?: string;
private allowedTools?: string;
constructor(
mux: TerminalMultiplexer,
workingDir: string = process.cwd(),
outputDir?: string,
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> }
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> },
security?: { claudeMode?: ClaudeMode; owner?: string; allowedTools?: string }
) {
this.mux = mux;
this.workingDir = workingDir;
this.outputDir = outputDir;
this.researchModel = modelConfig?.agentTypeOverrides?.explore || modelConfig?.defaultModel || DEFAULT_MODEL;
this.plannerModel = modelConfig?.agentTypeOverrides?.review || modelConfig?.defaultModel || DEFAULT_MODEL;
this.claudeMode = security?.claudeMode;
this.owner = security?.owner;
this.allowedTools = security?.allowedTools;
}
private saveAgentOutput(agentType: string, prompt: string, result: unknown, durationMs: number): void {
@@ -424,6 +434,12 @@ export class PlanOrchestrator {
mux: this.mux,
useMux: false,
mode: 'claude',
// Section 6.3: run this one-shot under the caller-resolved permission mode/owner so a
// non-granted multi-user user cannot regain --dangerously-skip-permissions. Undefined
// (single-user, not threaded) is byte-identical to today (Session keeps its default).
claudeMode: this.claudeMode,
allowedTools: this.allowedTools,
owner: this.owner,
});
this.runningSessions.add(session);
@@ -580,6 +596,10 @@ export class PlanOrchestrator {
mux: this.mux,
useMux: false,
mode: 'claude',
// Section 6.3: same permission-mode/owner threading as the research one-shot above.
claudeMode: this.claudeMode,
allowedTools: this.allowedTools,
owner: this.owner,
});
this.runningSessions.add(session);
+25 -10
View File
@@ -9,10 +9,23 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import webpush from 'web-push';
import type { VapidKeys, PushSubscriptionRecord } from './types.js';
import type { VapidKeys, PushSubscriptionRecord, UserRole } from './types.js';
import { Debouncer } from './utils/index.js';
import { getDataDir } from './config/instance.js';
/**
* A push subscription plus the multi-user owner identity stamped at subscribe time.
* `username`/`role` are undefined in single-user mode (and for legacy records saved
* before this field existed). sendPushNotifications uses them to scope a
* session-notification to its owner's devices (+ admins) instead of fanning out to
* every user. Kept as a store-local widening of PushSubscriptionRecord so the shared
* type stays untouched; the extra keys serialize/persist transparently.
*/
export type OwnedPushSubscriptionRecord = PushSubscriptionRecord & {
username?: string;
role?: UserRole;
};
const DATA_DIR = getDataDir();
const KEYS_FILE = join(DATA_DIR, 'push-keys.json');
const SUBS_FILE = join(DATA_DIR, 'push-subscriptions.json');
@@ -20,7 +33,7 @@ const SAVE_DEBOUNCE_MS = 500;
export class PushSubscriptionStore {
private vapidKeys: VapidKeys | null = null;
private subscriptions: Map<string, PushSubscriptionRecord> = new Map();
private subscriptions: Map<string, OwnedPushSubscriptionRecord> = new Map();
private saveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
private _disposed = false;
@@ -67,17 +80,19 @@ export class PushSubscriptionStore {
}
/** Register or update a push subscription (deduplicates by endpoint) */
addSubscription(sub: Omit<PushSubscriptionRecord, 'lastUsedAt'>): PushSubscriptionRecord {
addSubscription(sub: Omit<OwnedPushSubscriptionRecord, 'lastUsedAt'>): OwnedPushSubscriptionRecord {
// Check for existing subscription with same endpoint
for (const [existingId, existing] of this.subscriptions) {
if (existing.endpoint === sub.endpoint) {
// Update existing
const updated: PushSubscriptionRecord = {
// Update existing (re-stamp owner identity so it tracks the current caller)
const updated: OwnedPushSubscriptionRecord = {
...existing,
keys: sub.keys,
userAgent: sub.userAgent,
lastUsedAt: Date.now(),
pushPreferences: sub.pushPreferences,
username: sub.username,
role: sub.role,
};
this.subscriptions.set(existingId, updated);
this.scheduleSave();
@@ -86,7 +101,7 @@ export class PushSubscriptionStore {
}
// New subscription
const record: PushSubscriptionRecord = {
const record: OwnedPushSubscriptionRecord = {
...sub,
lastUsedAt: Date.now(),
};
@@ -96,7 +111,7 @@ export class PushSubscriptionStore {
}
/** Update push preferences for a subscription */
updatePreferences(id: string, preferences: Record<string, boolean>): PushSubscriptionRecord | null {
updatePreferences(id: string, preferences: Record<string, boolean>): OwnedPushSubscriptionRecord | null {
const sub = this.subscriptions.get(id);
if (!sub) return null;
sub.pushPreferences = preferences;
@@ -124,12 +139,12 @@ export class PushSubscriptionStore {
}
/** Get all subscriptions */
getAll(): PushSubscriptionRecord[] {
getAll(): OwnedPushSubscriptionRecord[] {
return Array.from(this.subscriptions.values());
}
/** Get a single subscription by ID */
get(id: string): PushSubscriptionRecord | null {
get(id: string): OwnedPushSubscriptionRecord | null {
return this.subscriptions.get(id) ?? null;
}
@@ -138,7 +153,7 @@ export class PushSubscriptionStore {
if (!existsSync(SUBS_FILE)) return;
try {
const raw = readFileSync(SUBS_FILE, 'utf-8');
const arr = JSON.parse(raw) as PushSubscriptionRecord[];
const arr = JSON.parse(raw) as OwnedPushSubscriptionRecord[];
for (const sub of arr) {
this.subscriptions.set(sub.id, sub);
}
+12 -2
View File
@@ -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);
}
+24 -2
View File
@@ -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, {
+23 -5
View File
@@ -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 {
+51 -8
View File
@@ -43,6 +43,8 @@ interface QrTokenRecord {
shortCode: string; // 6 chars base62 (for URL path)
createdAt: number; // Date.now()
consumed: boolean; // single-use flag
/** Multi-user: the user this token logs in when redeemed (absent = rotating global token). */
username?: string;
}
/** Rejection-sampled base62 short code — no modulo bias */
@@ -378,23 +380,64 @@ export class TunnelManager extends EventEmitter {
* Map.get() is hash-based — no timing side-channel from string comparison.
*/
consumeToken(shortCode: string): boolean {
return this.consumeTokenWithIdentity(shortCode).ok;
}
/**
* Like consumeToken, but also returns the bound username for multi-user tokens
* (undefined for the rotating global token). Only the identity-less rotating
* token triggers an immediate re-rotation (desktop gets a fresh QR); per-user
* tokens are on-demand and self-expire.
*/
consumeTokenWithIdentity(shortCode: string): { ok: boolean; username?: string } {
// Global rate limit (across all IPs)
if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return false;
if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return { ok: false };
this.qrAttemptCount++;
const record = this.qrTokensByCode.get(shortCode);
if (!record) return false;
if (record.consumed) return false;
if (!record) return { ok: false };
if (record.consumed) return { ok: false };
const now = Date.now();
if (now - record.createdAt > QR_TOKEN_GRACE_MS) return false;
if (now - record.createdAt > QR_TOKEN_GRACE_MS) return { ok: false };
// Atomic consume (single-threaded JS = no race)
record.consumed = true;
// Immediately rotate so desktop gets a fresh QR
this.rotateToken();
this.emit('qrTokenRegenerated');
return true;
const username = record.username;
if (!username) {
// Rotating global token — immediately rotate so desktop gets a fresh QR.
this.rotateToken();
this.emit('qrTokenRegenerated');
} else {
this.qrTokensByCode.delete(shortCode);
}
return { ok: true, username };
}
/**
* Multi-user: mint a single-use token bound to a specific user (on-demand, no
* rotation). Evicts expired/consumed tokens first. Returns the short code.
*/
mintUserToken(username: string): string {
const now = Date.now();
for (const [code, rec] of this.qrTokensByCode) {
if (now - rec.createdAt > QR_TOKEN_GRACE_MS || rec.consumed) this.qrTokensByCode.delete(code);
}
const record: QrTokenRecord = {
token: randomBytes(32).toString('hex'),
shortCode: generateShortCode(),
createdAt: Date.now(),
consumed: false,
username,
};
this.qrTokensByCode.set(record.shortCode, record);
return record.shortCode;
}
/** Render a QR SVG for an arbitrary short code (used by per-user minting). */
async getQrSvgForCode(tunnelUrl: string, code: string): Promise<string> {
const QRCode = await import('qrcode');
return QRCode.toString(`${tunnelUrl}/q/${code}`, { type: 'svg', margin: 2, width: 256 });
}
/** Force-regenerate (manual revocation via API) */
+20
View File
@@ -37,6 +37,16 @@ export enum ApiErrorCode {
RATE_LIMITED = 'RATE_LIMITED',
/** Operation could not be completed (well-formed but unprocessable) */
OPERATION_FAILED = 'OPERATION_FAILED',
/** Authenticated but not permitted (e.g. non-admin hitting an admin route) */
FORBIDDEN = 'FORBIDDEN',
/** User must change their password before any other action (multi-user) */
PASSWORD_CHANGE_REQUIRED = 'PASSWORD_CHANGE_REQUIRED',
/** A user with this name already exists (multi-user) */
USER_EXISTS = 'USER_EXISTS',
/** No user with this name (multi-user) */
USER_NOT_FOUND = 'USER_NOT_FOUND',
/** Refusing to demote/disable/delete the last enabled admin (multi-user) */
LAST_ADMIN = 'LAST_ADMIN',
/** Internal server error */
INTERNAL_ERROR = 'INTERNAL_ERROR',
}
@@ -53,6 +63,11 @@ const ErrorMessages: Record<ApiErrorCode, string> = {
[ApiErrorCode.ALREADY_EXISTS]: 'Resource already exists',
[ApiErrorCode.RATE_LIMITED]: 'Too many requests',
[ApiErrorCode.OPERATION_FAILED]: 'The operation failed',
[ApiErrorCode.FORBIDDEN]: 'You do not have permission to perform this action',
[ApiErrorCode.PASSWORD_CHANGE_REQUIRED]: 'You must change your password before continuing',
[ApiErrorCode.USER_EXISTS]: 'A user with that name already exists',
[ApiErrorCode.USER_NOT_FOUND]: 'No such user',
[ApiErrorCode.LAST_ADMIN]: 'Cannot remove the last enabled admin',
[ApiErrorCode.INTERNAL_ERROR]: 'An internal error occurred',
};
@@ -69,6 +84,11 @@ const ErrorStatus: Record<ApiErrorCode, number> = {
[ApiErrorCode.CONFLICT]: 409,
[ApiErrorCode.ALREADY_EXISTS]: 409,
[ApiErrorCode.OPERATION_FAILED]: 422,
[ApiErrorCode.FORBIDDEN]: 403,
[ApiErrorCode.PASSWORD_CHANGE_REQUIRED]: 403,
[ApiErrorCode.USER_EXISTS]: 409,
[ApiErrorCode.USER_NOT_FOUND]: 404,
[ApiErrorCode.LAST_ADMIN]: 409,
[ApiErrorCode.RATE_LIMITED]: 429,
[ApiErrorCode.INTERNAL_ERROR]: 500,
};
+2
View File
@@ -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;
+1
View File
@@ -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';
+8 -1
View File
@@ -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 */
+64
View File
@@ -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;
}
+488
View File
@@ -0,0 +1,488 @@
/**
* @fileoverview Multi-user store: `~/.codeman/users.json` (via `dataPath`, 0600).
*
* Mirrors the storage-module pattern of `remote-hosts.ts` / `docker-hosts.ts`, but
* because it holds password hashes it writes atomically (tmp + rename) at mode
* 0600 and keeps only a SHORT in-process cache so the CLI (`codeman users …`) can
* edit the file while the server runs and have changes picked up within the TTL.
*
* Pure, IO-free helpers (`isValidUsername`, `hashPassword`, `verifyPasswordHash`,
* `needsRehash`, `resolveClaudeModeForUser`, the last-admin invariants) are split
* out so they are unit-testable without a server. Hashing is `scrypt` from
* `node:crypto` (no new deps), compared via `timingSafeEqual`; parameters are
* stored per record so cost can be raised later and old records rehashed on their
* next successful login.
*
* See `docs/multi-user-plan.md` sections 4.1, 5, 6.3.
*/
import { existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { isAbsolute, join, relative } from 'node:path';
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
import { dataPath, getDataDir } from './config/instance.js';
import { getUserSpacesDir, isMultiUserMode, maxUsers } from './config/multiuser.js';
import type { AuthUser, ClaudeMode, PasswordHash, PublicUser, UserRecord, UserRole, UsersFile } from './types.js';
const scrypt = promisify(scryptCb) as (
password: string | Buffer,
salt: string | Buffer,
keylen: number,
options: { N: number; r: number; p: number; maxmem: number }
) => Promise<Buffer>;
const USERS_FILE = 'users.json';
const CACHE_TTL_MS = 1000;
const KEYLEN = 64;
const SALT_BYTES = 32;
/** Generous ceiling so raising N/r later does not trip scrypt's memory guard. */
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
/** Current hashing parameters. Stored per record; raise these to increase cost. */
export const DEFAULT_SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 } as const;
/** Username: lowercase, first char alphanumeric, 2-32 chars total. Becomes a folder name. */
const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{1,31}$/;
/** Typed error whose `.code` maps to an API errorCode at the route layer. */
export class UserStoreError extends Error {
constructor(
message: string,
public readonly code: 'USER_EXISTS' | 'USER_NOT_FOUND' | 'LAST_ADMIN' | 'INVALID_INPUT'
) {
super(message);
this.name = 'UserStoreError';
}
}
// ─────────────────────────────── pure helpers ───────────────────────────────
export function normalizeUsername(name: string): string {
return String(name ?? '')
.trim()
.toLowerCase();
}
export function isValidUsername(name: string): boolean {
return USERNAME_RE.test(normalizeUsername(name));
}
/** Hash a password with the given (or current) scrypt params + a fresh random salt. */
export async function hashPassword(
password: string,
params: { N: number; r: number; p: number } = DEFAULT_SCRYPT_PARAMS
): Promise<PasswordHash> {
const salt = randomBytes(SALT_BYTES);
const derived = await scrypt(password, salt, KEYLEN, { ...params, maxmem: SCRYPT_MAXMEM });
return {
algo: 'scrypt',
N: params.N,
r: params.r,
p: params.p,
salt: salt.toString('hex'),
hash: derived.toString('hex'),
};
}
/** Constant-time verify of a password against a stored hash record. Never throws. */
export async function verifyPasswordHash(password: string, record: PasswordHash): Promise<boolean> {
if (!record || record.algo !== 'scrypt') return false;
let salt: Buffer;
let expected: Buffer;
try {
salt = Buffer.from(record.salt, 'hex');
expected = Buffer.from(record.hash, 'hex');
} catch {
return false;
}
if (expected.length === 0) return false;
let derived: Buffer;
try {
derived = await scrypt(password, salt, expected.length, {
N: record.N,
r: record.r,
p: record.p,
maxmem: SCRYPT_MAXMEM,
});
} catch {
return false;
}
if (derived.length !== expected.length) return false;
return timingSafeEqual(derived, expected);
}
/** True when a stored hash uses weaker params than current and should be rehashed. */
export function needsRehash(record: PasswordHash, params = DEFAULT_SCRYPT_PARAMS): boolean {
return record.algo !== 'scrypt' || record.N !== params.N || record.r !== params.r || record.p !== params.p;
}
/** URL-safe one-time password (16 chars) for admin create/reset flows. */
export function generateOneTimePassword(): string {
return randomBytes(12).toString('base64url');
}
export function toPublicUser(u: UserRecord): PublicUser {
return {
username: u.username,
role: u.role,
disabled: !!u.disabled,
mustChangePassword: !!u.mustChangePassword,
canBypassPermissions: !!u.canBypassPermissions,
createdAt: u.createdAt,
lastLoginAt: u.lastLoginAt,
};
}
export function countEnabledAdmins(users: UserRecord[]): number {
return users.filter((u) => u.role === 'admin' && !u.disabled).length;
}
/**
* Section 6.3: resolve the effective Claude permission mode for a user. Admins and
* granted users get the global mode as-is; a non-granted regular user whose mode
* would be `dangerously-skip-permissions` is silently downgraded to `auto` (all
* other modes are already <= auto and pass through). Pure.
*/
export function resolveClaudeModeForUser(
globalMode: ClaudeMode | undefined,
grant: { role: UserRole; canBypassPermissions?: boolean }
): ClaudeMode {
const mode: ClaudeMode = globalMode ?? 'dangerously-skip-permissions';
if (grant.role === 'admin' || grant.canBypassPermissions) return mode;
return mode === 'dangerously-skip-permissions' ? 'auto' : mode;
}
/**
* Section 6.3: whether a user may run arbitrary commands as the host account
* (shell-mode sessions, cron `launchCommand`, other CLIs' bypass flags). Same
* one-bit grant as bypass. Admins always may.
*/
export function canRunPrivilegedCommands(grant: { role: UserRole; canBypassPermissions?: boolean }): boolean {
return grant.role === 'admin' || !!grant.canBypassPermissions;
}
// ─────────────────────────────── IO layer ───────────────────────────────
let cache: { users: UserRecord[]; ts: number } | null = null;
/** Drop the in-process cache (called after every write; exported for tests). */
export function invalidateUsersCache(): void {
cache = null;
}
export async function readUsers(force = false): Promise<UserRecord[]> {
const now = Date.now();
if (!force && cache && now - cache.ts < CACHE_TTL_MS) return cache.users;
let raw: string;
try {
raw = await fs.readFile(dataPath(USERS_FILE), 'utf-8');
} catch (err) {
// ENOENT is the ONLY legitimately-empty store (first boot). Any other read
// error (EIO/EACCES/EMFILE/EBUSY) is a transient/permission failure, NOT an
// empty store — do NOT cache [] and do NOT let it look empty, or a following
// createUser/bootstrap would overwrite users.json and destroy every account.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
cache = { users: [], ts: now };
return [];
}
throw err;
}
// A present-but-corrupt file (invalid JSON) must also fail loud rather than
// read as empty, so mutators/bootstrap abort instead of clobbering it.
const parsed = JSON.parse(raw) as Partial<UsersFile>;
const users = Array.isArray(parsed.users) ? parsed.users : [];
cache = { users, ts: now };
return users;
}
async function writeUsers(users: UserRecord[]): Promise<void> {
const dir = getDataDir();
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const finalPath = dataPath(USERS_FILE);
// Unique per-writer tmp name (pid + random) so the CLI (`codeman users …`) and
// the live server — designed to write this file concurrently across processes —
// never share a single `users.json.tmp` inode and tear each other's payload.
// Matches the state-store.ts / self-update.ts convention.
const tmpPath = `${finalPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
const payload: UsersFile = { version: 1, users };
try {
await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { mode: 0o600 });
await fs.chmod(tmpPath, 0o600).catch(() => {});
await fs.rename(tmpPath, finalPath);
} catch (err) {
await fs.unlink(tmpPath).catch(() => {});
throw err;
}
cache = { users, ts: Date.now() };
}
/**
* Serialize every read-modify-write on users.json. Without this a fire-and-forget
* touchLastLogin (fired on each Basic auth) can interleave with a route's
* create/update and clobber records, since both do readUsers(true) → mutate →
* writeUsers against a single shared file + tmp path.
*/
let mutateChain: Promise<unknown> = Promise.resolve();
function withUsersLock<T>(fn: () => Promise<T>): Promise<T> {
const run = mutateChain.then(fn, fn);
mutateChain = run.then(
() => undefined,
() => undefined
);
return run;
}
export async function hasUsers(): Promise<boolean> {
return (await readUsers()).length > 0;
}
// A precomputed dummy hash so an unknown/disabled user costs the same scrypt work
// as a real verify (defeats username-enumeration by timing). Created once, lazily.
let dummyHashPromise: Promise<PasswordHash> | null = null;
function getDummyHash(): Promise<PasswordHash> {
if (!dummyHashPromise) dummyHashPromise = hashPassword('codeman-timing-equalization-placeholder');
return dummyHashPromise;
}
/**
* Verify a username/password against the store. Returns the record (plus whether it
* should be rehashed) on success, or null for wrong password / unknown / disabled
* user. Runs a dummy scrypt on the miss path so timing does not reveal which users
* exist. Never writes (the caller decides when to persist lastLogin / rehash).
*/
export async function verifyPassword(
username: string,
password: string
): Promise<{ user: UserRecord; needsRehash: boolean } | null> {
const user = await findUser(username);
if (!user || user.disabled) {
await verifyPasswordHash(password, await getDummyHash());
return null;
}
const ok = await verifyPasswordHash(password, user.password);
if (!ok) return null;
return { user, needsRehash: needsRehash(user.password) };
}
export async function findUser(username: string): Promise<UserRecord | undefined> {
const norm = normalizeUsername(username);
if (!norm) return undefined;
const users = await readUsers();
return users.find((u) => u.username === norm);
}
export interface CreateUserOptions {
username: string;
role: UserRole;
password: string;
mustChangePassword?: boolean;
canBypassPermissions?: boolean;
}
export async function createUser(opts: CreateUserOptions): Promise<UserRecord> {
const username = normalizeUsername(opts.username);
if (!isValidUsername(username)) {
throw new UserStoreError(
'Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])',
'INVALID_INPUT'
);
}
if (opts.role !== 'admin' && opts.role !== 'user') {
throw new UserStoreError('Role must be "admin" or "user"', 'INVALID_INPUT');
}
if (!opts.password || opts.password.length < 8) {
throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT');
}
return withUsersLock(async () => {
const users = await readUsers(true);
if (users.some((u) => u.username === username)) {
throw new UserStoreError(`User "${username}" already exists`, 'USER_EXISTS');
}
if (users.length >= maxUsers()) {
throw new UserStoreError(`Maximum number of users (${maxUsers()}) reached`, 'INVALID_INPUT');
}
const record: UserRecord = {
username,
role: opts.role,
password: await hashPassword(opts.password),
disabled: false,
mustChangePassword: !!opts.mustChangePassword,
canBypassPermissions: !!opts.canBypassPermissions,
createdAt: Date.now(),
};
users.push(record);
await writeUsers(users);
return record;
});
}
/** Set a user's password. `mustChangePassword` is left unchanged unless specified. */
export async function setPassword(
username: string,
password: string,
opts: { mustChangePassword?: boolean } = {}
): Promise<UserRecord> {
if (!password || password.length < 8) {
throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT');
}
const norm = normalizeUsername(username);
return withUsersLock(async () => {
const users = await readUsers(true);
const record = users.find((u) => u.username === norm);
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
record.password = await hashPassword(password);
if (opts.mustChangePassword !== undefined) record.mustChangePassword = opts.mustChangePassword;
await writeUsers(users);
return record;
});
}
export interface UpdateUserPatch {
role?: UserRole;
disabled?: boolean;
canBypassPermissions?: boolean;
mustChangePassword?: boolean;
}
export async function updateUser(username: string, patch: UpdateUserPatch): Promise<UserRecord> {
const norm = normalizeUsername(username);
return withUsersLock(async () => {
const users = await readUsers(true);
const record = users.find((u) => u.username === norm);
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
// Guard the last-enabled-admin invariant against demote/disable.
const before = countEnabledAdmins(users);
const projected: UserRecord = {
...record,
role: patch.role ?? record.role,
disabled: patch.disabled ?? record.disabled,
};
const after = countEnabledAdmins(users.map((u) => (u.username === norm ? projected : u)));
if (before > 0 && after === 0) {
throw new UserStoreError('Cannot demote or disable the last enabled admin', 'LAST_ADMIN');
}
if (patch.role !== undefined) record.role = patch.role;
if (patch.disabled !== undefined) record.disabled = patch.disabled;
if (patch.canBypassPermissions !== undefined) record.canBypassPermissions = patch.canBypassPermissions;
if (patch.mustChangePassword !== undefined) record.mustChangePassword = patch.mustChangePassword;
await writeUsers(users);
return record;
});
}
/**
* Record a successful login timestamp. Best-effort + throttled: skips the write if
* the last login was within the last minute (Basic clients re-send credentials on
* every request, so this fires often — the throttle keeps disk churn bounded).
*/
export async function touchLastLogin(username: string): Promise<void> {
const norm = normalizeUsername(username);
try {
await withUsersLock(async () => {
const users = await readUsers(true);
const record = users.find((u) => u.username === norm);
if (!record) return;
if (record.lastLoginAt && Date.now() - record.lastLoginAt < 60_000) return;
record.lastLoginAt = Date.now();
await writeUsers(users);
});
} catch {
/* best-effort */
}
}
export async function deleteUser(username: string): Promise<void> {
const norm = normalizeUsername(username);
await withUsersLock(async () => {
const users = await readUsers(true);
const record = users.find((u) => u.username === norm);
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
const before = countEnabledAdmins(users);
const remaining = users.filter((u) => u.username !== norm);
const after = countEnabledAdmins(remaining);
if (before > 0 && after === 0) {
throw new UserStoreError('Cannot delete the last enabled admin', 'LAST_ADMIN');
}
await writeUsers(remaining);
});
}
/**
* First-boot bootstrap: in multi-user mode with no users yet, create the initial
* admin from `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` if both are set. Returns a
* status the caller (server start / CLI) uses to decide whether to refuse boot.
*/
export async function bootstrapInitialAdmin(): Promise<{
status: 'created' | 'exists' | 'missing-env';
username?: string;
}> {
if (await hasUsers()) return { status: 'exists' };
const username = process.env.CODEMAN_USERNAME;
const password = process.env.CODEMAN_PASSWORD;
if (!username || !password) return { status: 'missing-env' };
const created = await createUser({ username, role: 'admin', password });
return { status: 'created', username: created.username };
}
/**
* Delete a user's on-disk space (`<USER_SPACES_DIR>/<username>`) with the section 8
* guard rails: the top-level dir must not be a symlink, and its realpath must
* resolve strictly inside USER_SPACES_DIR (so a symlinked or `..`-escaping target
* can never be used to rm an arbitrary tree). No-op if the space does not exist.
*/
export async function deleteUserSpace(username: string): Promise<void> {
const norm = normalizeUsername(username);
if (!isValidUsername(norm)) throw new UserStoreError('Invalid username', 'INVALID_INPUT');
const root = getUserSpacesDir();
const target = join(root, norm);
let lst;
try {
lst = await fs.lstat(target);
} catch {
return; // nothing to delete
}
if (lst.isSymbolicLink()) {
throw new UserStoreError('Refusing to delete a symlinked user space', 'INVALID_INPUT');
}
const realRoot = await fs.realpath(root).catch(() => root);
const realTarget = await fs.realpath(target);
const rel = relative(realRoot, realTarget);
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
throw new UserStoreError('User space escapes USER_SPACES_DIR', 'INVALID_INPUT');
}
await fs.rm(realTarget, { recursive: true, force: true });
}
/** The synthetic admin used in single-user mode so downstream has one code path. */
export const SYNTHETIC_ADMIN: AuthUser = { username: 'admin', role: 'admin' };
/**
* Whether a username may run arbitrary commands (shell mode, cron launchCommand,
* other CLIs' bypass). Single-user or an unset owner: allowed. In multi-user a
* MISSING user (e.g. deleted) fails closed (non-privileged). Used at cron fire time.
*/
export async function canUsernameRunPrivilegedCommands(username: string | undefined): Promise<boolean> {
if (!isMultiUserMode() || !username) return true;
const user = await findUser(username);
return canRunPrivilegedCommands(user ?? { role: 'user' });
}
/**
* Resolve the effective Claude mode for a username by looking up the grant. In
* single-user mode (or for an unknown owner) the global mode passes through.
*/
export async function resolveClaudeModeForUsername(
globalMode: ClaudeMode | undefined,
username: string | undefined
): Promise<ClaudeMode> {
const fallback: ClaudeMode = globalMode ?? 'dangerously-skip-permissions';
if (!isMultiUserMode() || !username) return fallback;
// Fail closed: an unknown/deleted owner in multi-user mode is treated as a
// non-granted regular user so a stale-owned spawn (e.g. an orphaned cron job)
// is downgraded to `auto` rather than inheriting the global bypass.
const user = await findUser(username);
return resolveClaudeModeForUser(globalMode, user ?? { role: 'user' });
}
+28
View File
@@ -0,0 +1,28 @@
/**
* @fileoverview Append-only admin audit log (~/.codeman/admin-audit.jsonl).
*
* Every user-management action (create/patch/reset/delete/logout/assign) writes one
* JSON line: timestamp, acting admin, action, target, request IP. Same idiom as
* session-lifecycle.jsonl. Best-effort: a write failure never blocks the action.
*/
import fs from 'node:fs/promises';
import { dataPath } from '../config/instance.js';
export interface AdminAuditEntry {
ts: number;
admin: string;
action: string;
target?: string;
ip?: string;
detail?: Record<string, unknown>;
}
export async function appendAdminAudit(entry: Omit<AdminAuditEntry, 'ts'>): Promise<void> {
try {
const line = JSON.stringify({ ts: Date.now(), ...entry }) + '\n';
await fs.appendFile(dataPath('admin-audit.jsonl'), line, { mode: 0o600 });
} catch {
/* best-effort audit; never block the action */
}
}
+277 -57
View File
@@ -8,7 +8,7 @@
* - CORS (localhost only)
*/
import type { FastifyInstance, FastifyReply } from 'fastify';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { StaleExpirationMap } from '../../utils/index.js';
import type { AuthSessionRecord } from '../ports/auth-port.js';
@@ -20,6 +20,17 @@ import {
AUTH_FAILURE_WINDOW_MS,
} from '../../config/auth-config.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
// ownership helpers default to a synthetic admin (see route-helpers).
declare module 'fastify' {
interface FastifyRequest {
authUser?: AuthUser;
}
}
// Auth session cookie name
export const AUTH_COOKIE_NAME = 'codeman_session';
@@ -30,6 +41,83 @@ interface AuthState {
authFailures: StaleExpirationMap<string, number> | null;
qrAuthFailures: StaleExpirationMap<string, number> | null;
hookSecretFailures: StaleExpirationMap<string, number> | null;
/** Per-username Basic-auth failure bucket (multi-user only). */
userFailures: StaleExpirationMap<string, number> | null;
}
/** Rate-limit response for a client that exceeded the failure cap. */
function sendAuthRateLimit(reply: FastifyReply, failures: StaleExpirationMap<string, number>, key: string): void {
const remainingMs = failures.getRemainingTtl(key) ?? AUTH_FAILURE_WINDOW_MS;
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
reply.header('Retry-After', String(retryAfterSeconds));
reply.code(429).send('Too Many Requests — try again later');
}
/** Parse a `Basic base64(user:pass)` header into its parts, or null if malformed. */
function parseBasicAuth(header?: string): { username: string; password: string } | null {
if (!header || !header.startsWith('Basic ')) return null;
try {
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf-8');
const idx = decoded.indexOf(':');
if (idx < 0) return null;
return { username: decoded.slice(0, idx), password: decoded.slice(idx + 1) };
} catch {
return null;
}
}
/**
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass, shared by the
* single-user and multi-user auth hooks so the security-critical logic has ONE
* source of truth. Returns:
* - 'bypass' : loopback + valid hook secret; the caller should allow the request
* - 'rejected' : a reply was already sent (wrong secret rate-limited / 401)
* - 'continue' : not a hook request (or non-loopback); fall through to normal auth
*
* COD-91: the shared hook secret is required UNCONDITIONALLY on the loopback bypass
* (a user's own loopback reverse proxy is indistinguishable from a real local hook).
*/
function checkHookSecretBypass(
req: FastifyRequest,
reply: FastifyReply,
hookSecretFailures: StaleExpirationMap<string, number>
): 'bypass' | 'rejected' | 'continue' {
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
const ip = req.ip;
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
if (isLoopback) {
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
const expected = Buffer.from(getHookSecret());
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
return 'bypass';
}
const hookIp = req.ip;
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
if (hookFailures >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, hookSecretFailures, hookIp);
return 'rejected';
}
hookSecretFailures.set(hookIp, hookFailures + 1);
reply.code(401).send('Unauthorized: hook secret required');
return 'rejected';
}
// Non-localhost hook requests fall through to normal auth
}
return 'continue';
}
/**
* Requests that a `mustChangePassword` user may still reach: the identity probe,
* the password-change endpoint, and any non-API path (static assets / index.html,
* so the browser can load the app and render the change-password modal).
*/
function isPasswordChangeExempt(req: FastifyRequest): boolean {
const url = (req.url ?? '').split('?')[0];
if (url === '/api/me' || url === '/api/me/password') return true;
// Security: the WebSocket terminal (/ws/...) is a functional channel, not a static
// asset, so it must NOT be exempt, or a locked user keeps a working terminal.
if (url.startsWith('/ws/')) return false;
return !url.startsWith('/api/');
}
/**
@@ -47,13 +135,20 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
authFailures: null,
qrAuthFailures: null,
hookSecretFailures: null,
userFailures: null,
};
const authPassword = process.env.CODEMAN_PASSWORD;
if (!authPassword) return state;
// Always declare req.authUser so downstream reads are safe (single-user leaves it
// undefined; the ownership helpers then default to a synthetic admin).
if (!app.hasRequestDecorator('authUser')) app.decorateRequest('authUser', undefined);
const authUsername = process.env.CODEMAN_USERNAME || 'admin';
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
const multiUser = isMultiUserMode();
const authPassword = process.env.CODEMAN_PASSWORD;
// No auth at all: single-user with no password (byte-identical to legacy). In
// multi-user mode auth is ALWAYS active (users authenticate individually), even
// without CODEMAN_PASSWORD.
if (!multiUser && !authPassword) return state;
// Session token store — active sessions extend TTL on access
state.authSessions = new StaleExpirationMap<string, AuthSessionRecord>({
@@ -87,57 +182,28 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
const authFailures = state.authFailures;
const hookSecretFailures = state.hookSecretFailures;
function sendAuthRateLimit(
reply: FastifyReply,
clientIp: string,
failures: StaleExpirationMap<string, number> = authFailures
): void {
const remainingMs = failures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
reply.header('Retry-After', String(retryAfterSeconds));
reply.code(429).send('Too Many Requests — try again later');
if (multiUser) {
// Per-username failure bucket: a botnet can't brute-force one account across
// many IPs, and one user behind a NAT can't lock out everyone else.
state.userFailures = new StaleExpirationMap<string, number>({
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures);
return state;
}
// ── Single-user Basic Auth (unchanged behavior; CODEMAN_PASSWORD required) ──
const authUsername = process.env.CODEMAN_USERNAME || 'admin';
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
app.addHook('onRequest', (req, reply, done) => {
// Hook events + statusline telemetry come from local Claude Code (curl from
// localhost) — no Basic-Auth credentials available. Validated downstream by
// HookEventSchema / StatusTelemetrySchema. Same loopback+hook-secret gate.
//
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
// `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
// loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and
// would pass. COD-91: require the shared hook secret on the loopback bypass
// UNCONDITIONALLY (not just while the managed tunnel is up). Codeman can't detect
// a user's own loopback reverse proxy (their own `cloudflared --url`, `tailscale
// serve`, nginx → 127.0.0.1), so tunnel-gating left that path with the unsafe plain
// bypass. Managed-session hooks always present the secret (X-Codeman-Hook-Secret,
// from $CODEMAN_HOOK_SECRET_FILE — generated for every instance), so requiring it
// always closes the gap without breaking the legitimate hook channel.
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
const ip = req.ip;
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
if (isLoopback) {
// Always require the shared secret (constant-time compare).
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
const expected = Buffer.from(getHookSecret());
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
done();
return;
}
// Wrong/absent secret — rate-limit per IP in the DEDICATED hook bucket
// (never authFailures, which would lock out the login path).
const hookIp = req.ip;
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
if (hookFailures >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, hookIp, hookSecretFailures);
return;
}
hookSecretFailures.set(hookIp, hookFailures + 1);
reply.code(401).send('Unauthorized: hook secret required');
return;
}
// Non-localhost hook requests fall through to normal auth
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
if (bypass === 'bypass') {
done();
return;
}
if (bypass === 'rejected') return;
// QR auth path — handled by the route itself (token validation + rate limiting)
if (req.url?.startsWith('/q/')) {
@@ -153,10 +219,6 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
if (sessionToken && authSessions.get(sessionToken) !== undefined) {
// Sliding cookie: re-issue on every authenticated request so the browser
// cookie lifetime tracks the server-side sliding TTL (refreshOnGet above).
// Without this the cookie has a fixed lifetime from login; the browser
// drops it mid-use, the next request arrives cookie-less and falls through
// to Basic Auth — popping the native username/password dialog, which reads
// as a random logout while actively working.
reply.setCookie(AUTH_COOKIE_NAME, sessionToken, {
httpOnly: true,
secure: https,
@@ -206,7 +268,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
// Rate limit only requests that failed to authenticate on this attempt.
const failures = authFailures.get(clientIp) ?? 0;
if (failures >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, clientIp);
sendAuthRateLimit(reply, authFailures, clientIp);
return;
}
@@ -220,6 +282,164 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
return state;
}
/**
* Multi-user auth hook (async, because password verification runs scrypt). Verifies
* `username:password` against the user store, mints an identity-carrying cookie,
* decorates `req.authUser`, enforces the per-IP + per-username rate limits, and the
* `mustChangePassword` lockbox. The single-user hook above is left untouched.
*/
function registerMultiUserAuthHook(
app: FastifyInstance,
https: boolean,
authSessions: StaleExpirationMap<string, AuthSessionRecord>,
authFailures: StaleExpirationMap<string, number>,
hookSecretFailures: StaleExpirationMap<string, number>,
userFailures: StaleExpirationMap<string, number>
): void {
const setSessionCookie = (reply: FastifyReply, token: string) =>
reply.setCookie(AUTH_COOKIE_NAME, token, {
httpOnly: true,
secure: https,
sameSite: 'lax',
maxAge: AUTH_SESSION_TTL_MS / 1000,
path: '/',
});
// Evict the oldest cookie session of the SAME user first (so one user logging in
// 100 times cannot flush everyone else's sessions), falling back to global-oldest.
const evictForCapacity = (username: string) => {
let userKey: string | undefined;
let userTs = Infinity;
let globalKey: string | undefined;
let globalTs = Infinity;
for (const [k, v] of authSessions) {
if (v.createdAt < globalTs) {
globalTs = v.createdAt;
globalKey = k;
}
if (v.username === username && v.createdAt < userTs) {
userTs = v.createdAt;
userKey = k;
}
}
const key = userKey ?? globalKey;
if (key !== undefined) authSessions.delete(key);
};
const enforcePasswordChange = (req: FastifyRequest, reply: FastifyReply, mustChange: boolean): boolean => {
if (mustChange && !isPasswordChangeExempt(req)) {
reply.code(403).send(createErrorResponse(ApiErrorCode.PASSWORD_CHANGE_REQUIRED));
return true;
}
return false;
};
app.addHook('onRequest', async (req, reply) => {
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
if (bypass === 'bypass' || bypass === 'rejected') return;
// QR redemption path — handled by the route itself.
if (req.url?.startsWith('/q/')) return;
const clientIp = req.ip;
// 1. Cookie session (carries identity + mustChangePassword snapshot).
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
const record = sessionToken ? authSessions.get(sessionToken) : undefined;
if (record && record.username) {
// Security: re-validate the cookie identity against the store on every request so
// an out-of-band mutation the in-memory map can't see (the `codeman users` CLI,
// a separate process, deleting/disabling/demoting a user) takes effect promptly
// instead of riding the 24h cookie. findUser is cached ~1s, so this is cheap.
let live: Awaited<ReturnType<typeof findUser>>;
try {
live = await findUser(record.username);
} catch {
// The store is transiently unreadable/corrupt (readUsers throws on a non-ENOENT
// read, #23). Fall back to the cookie's snapshot for THIS request rather than
// 500-ing an already-authenticated client (pre-#24 behaviour); a persistently
// corrupt store still fails all WRITES loudly at the mutator/bootstrap layer.
req.authUser = { username: record.username, role: record.role ?? 'user' };
setSessionCookie(reply, sessionToken!);
enforcePasswordChange(req, reply, !!record.mustChangePassword);
return;
}
if (!live || live.disabled) {
authSessions.delete(sessionToken!);
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
reply.code(401).send('Unauthorized');
return;
}
// Trust the LIVE role/mustChangePassword, not the (possibly stale) cookie snapshot
// (also defends #9/#13: a CLI demotion is reflected without a revoke).
req.authUser = { username: live.username, role: live.role };
setSessionCookie(reply, sessionToken!); // sliding re-issue
enforcePasswordChange(req, reply, !!live.mustChangePassword);
return;
}
// 2. Basic Auth against the user store (scrypt verify).
// Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the
// per-username bucket here; see below).
const ipFail = authFailures.get(clientIp) ?? 0;
if (ipFail >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, authFailures, clientIp);
return;
}
const creds = parseBasicAuth(req.headers.authorization);
if (creds) {
const normUser = creds.username.trim().toLowerCase();
// Security: VERIFY FIRST, then throttle only FAILED attempts. Consulting the
// per-username bucket before verifying let throwaway IPs lock out a known account
// (incl. admin) even with the correct password. A correct password must always
// win and self-heal both buckets, regardless of the username-failure count.
const result = await verifyPassword(creds.username, creds.password);
if (result) {
const { user, needsRehash: rehash } = result;
if (rehash) void setPassword(user.username, creds.password).catch(() => {});
void touchLastLogin(user.username).catch(() => {});
authFailures.delete(clientIp);
userFailures.delete(normUser);
const token = randomBytes(32).toString('hex');
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
authSessions.set(token, {
ip: clientIp,
ua: req.headers['user-agent'] ?? '',
createdAt: Date.now(),
method: 'basic',
username: user.username,
role: user.role,
mustChangePassword: !!user.mustChangePassword,
});
req.authUser = { username: user.username, role: user.role };
setSessionCookie(reply, token);
enforcePasswordChange(req, reply, !!user.mustChangePassword);
return;
}
// Failed guess: count it against BOTH buckets. Once the per-username bucket
// reaches the cap, further FAILED attempts get 429 (throttles distributed
// brute-force), but this path is only reached on a wrong password, so it can
// never deny a correct one.
const uFail = (userFailures.get(normUser) ?? 0) + 1;
userFailures.set(normUser, uFail);
authFailures.set(clientIp, ipFail + 1);
if (uFail >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, userFailures, normUser);
return;
}
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
reply.code(401).send('Unauthorized');
return;
}
// No credentials presented: count against the per-IP bucket and challenge.
authFailures.set(clientIp, ipFail + 1);
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
reply.code(401).send('Unauthorized');
});
}
/** Methods that don't change server state and so skip the cross-site Origin check. */
const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
+13
View File
@@ -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 {
+1 -1
View File
@@ -18,7 +18,7 @@ export interface ConfigPort {
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
getDefaultClaudeMdPath(): Promise<string | undefined>;
getLightState(): unknown;
getLightState(identity?: { username: string; role: 'admin' | 'user' }): unknown;
getLightSessionsState(): unknown[];
startTranscriptWatcher(sessionId: string, transcriptPath: string): void;
stopTranscriptWatcher(sessionId: string): void;
+4 -1
View File
@@ -23,6 +23,9 @@ export interface ScheduledRun {
completedTasks: number;
totalCost: number;
logs: string[];
/** Multi-user owner (username) — undefined in single-user mode. Used to scope
* list/delete and to downgrade the spawned Session's permission mode. */
owner?: string;
}
export interface InfraPort {
@@ -33,6 +36,6 @@ export interface InfraPort {
readonly teamWatcher: TeamWatcher;
readonly tunnelManager: TunnelManager;
readonly pushStore: PushSubscriptionStore;
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun>;
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number, owner?: string): Promise<ScheduledRun>;
stopScheduledRun(id: string): Promise<void>;
}
+260
View File
@@ -0,0 +1,260 @@
/**
* @fileoverview Multi-user frontend: identity boot, admin Users panel, and the
* change-password flow. Self-contained (builds its own DOM) so it needs no
* index.html surgery beyond the script tag and integrates with the existing App
* Settings modal by injecting a "Users" tab (admins in multi-user mode only).
*
* @dependency app.js (window.app), settings-ui.js (App Settings modal + tab switch)
* @loadorder after settings-ui.js / ultracode-panel.js, before session-ui.js
*
* In single-user mode GET /api/me returns a synthetic admin with multiUser:false,
* so none of the admin UI is shown and behavior is unchanged.
*/
(function () {
'use strict';
const unwrap = (body) => (body && typeof body === 'object' && 'data' in body ? body.data : body);
async function apiGet(path) {
const res = await window.fetch(path, { headers: { Accept: 'application/json' } });
return unwrap(await res.json());
}
async function apiSend(method, path, body) {
const res = await window.fetch(path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
let json = null;
try {
json = await res.json();
} catch {
/* empty body */
}
return { ok: res.ok, status: res.status, body: json, data: unwrap(json) };
}
// ── Change-password modal ─────────────────────────────────────────────────
let cpModal = null;
function buildChangePasswordModal() {
if (cpModal) return cpModal;
const el = document.createElement('div');
el.className = 'modal';
el.id = 'changePasswordModal';
el.style.zIndex = '3100';
el.innerHTML = `
<div class="modal-content" style="max-width:420px">
<div class="modal-header"><h2>Change Password</h2></div>
<div class="modal-body">
<p id="cpMustNote" class="form-hint" style="display:none;color:var(--warning,#c80)">
You must change your password before continuing.</p>
<div class="form-row"><label>Current password</label>
<input type="password" id="cpCurrent" class="form-input" autocomplete="current-password"></div>
<div class="form-row"><label>New password (min 8)</label>
<input type="password" id="cpNew" class="form-input" autocomplete="new-password"></div>
<div class="form-row"><label>Confirm new password</label>
<input type="password" id="cpConfirm" class="form-input" autocomplete="new-password"></div>
<p id="cpError" style="color:var(--error,#c33);min-height:1.2em"></p>
</div>
<div class="modal-footer">
<button class="btn" id="cpCancel">Cancel</button>
<button class="btn btn-primary" id="cpSubmit">Change password</button>
</div>
</div>`;
document.body.appendChild(el);
el.querySelector('#cpCancel').onclick = () => (el.style.display = 'none');
el.querySelector('#cpSubmit').onclick = async () => {
const current = el.querySelector('#cpCurrent').value;
const nw = el.querySelector('#cpNew').value;
const confirm = el.querySelector('#cpConfirm').value;
const err = el.querySelector('#cpError');
err.textContent = '';
if (nw.length < 8) return (err.textContent = 'New password must be at least 8 characters.');
if (nw !== confirm) return (err.textContent = 'Passwords do not match.');
const r = await apiSend('POST', '/api/me/password', { currentPassword: current, newPassword: nw });
if (!r.ok) return (err.textContent = (r.body && r.body.error) || 'Change failed.');
el.style.display = 'none';
if (window.app && window.app.showToast) window.app.showToast('Password changed');
};
cpModal = el;
return el;
}
function openChangePassword(forced) {
const el = buildChangePasswordModal();
el.querySelector('#cpMustNote').style.display = forced ? '' : 'none';
el.querySelector('#cpCancel').style.display = forced ? 'none' : '';
el.querySelector('#cpError').textContent = '';
el.style.display = 'flex';
}
// ── Fetch interceptor: surface PASSWORD_CHANGE_REQUIRED ───────────────────
function installInterceptor() {
const orig = window.fetch;
window.fetch = async function (...args) {
const res = await orig.apply(this, args);
if (res.status === 403) {
try {
const clone = res.clone();
const j = await clone.json();
if (j && j.errorCode === 'PASSWORD_CHANGE_REQUIRED') openChangePassword(true);
} catch {
/* not JSON */
}
}
return res;
};
}
// ── Admin Users panel (injected into the App Settings modal) ──────────────
function injectUsersTab() {
const modal = document.getElementById('appSettingsModal');
if (!modal || modal.querySelector('[data-tab="settings-users"]')) return;
const tabs = modal.querySelector('.modal-tabs');
const body = modal.querySelector('.modal-body');
if (!tabs || !body) return;
const btn = document.createElement('button');
btn.className = 'modal-tab-btn';
btn.dataset.tab = 'settings-users';
btn.textContent = 'Users';
tabs.appendChild(btn);
const content = document.createElement('div');
content.className = 'modal-tab-content hidden';
content.id = 'settings-users';
content.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong>Users</strong>
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
</div>
<p class="form-hint">Users share the host account; this separates workspaces, it does not sandbox
users from each other. Pair with Docker cases for isolation.</p>
<div id="adminUsersTable"></div>
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--muted,#888)"></p>`;
body.appendChild(content);
// Render whenever the tab is shown (the shared switchSettingsTab toggles it).
btn.addEventListener('click', renderUsers);
content.querySelector('#adminAddUser').onclick = addUserFlow;
}
function esc(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
}
async function renderUsers() {
const table = document.getElementById('adminUsersTable');
if (!table) return;
table.innerHTML = 'Loading…';
let users;
try {
users = await apiGet('/api/admin/users');
} catch {
table.innerHTML = 'Failed to load users.';
return;
}
const rows = users
.map((u) => {
const flags = [
u.role === 'admin' ? 'admin' : 'user',
u.disabled ? 'disabled' : 'enabled',
u.canBypassPermissions ? 'can-bypass' : '',
u.mustChangePassword ? 'must-change-pw' : '',
]
.filter(Boolean)
.join(', ');
const st = u.stats || {};
return `<tr data-u="${esc(u.username)}">
<td>${esc(u.username)}</td>
<td style="font-size:.85em;color:var(--muted,#888)">${esc(flags)}</td>
<td style="font-size:.85em">${st.liveSessions ?? 0} live · ${st.caseCount ?? 0} cases</td>
<td style="white-space:nowrap">
<button class="btn btn-xs" data-act="role">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
<button class="btn btn-xs" data-act="disabled">${u.disabled ? 'Enable' : 'Disable'}</button>
<button class="btn btn-xs" data-act="bypass">${u.canBypassPermissions ? 'Revoke bypass' : 'Grant bypass'}</button>
<button class="btn btn-xs" data-act="reset">Reset pw</button>
<button class="btn btn-xs" data-act="delete">Delete</button>
</td></tr>`;
})
.join('');
table.innerHTML = `<table style="width:100%;border-collapse:collapse" class="admin-users">
<thead><tr><th align="left">User</th><th align="left">Flags</th><th align="left">Usage</th><th></th></tr></thead>
<tbody>${rows}</tbody></table>`;
table.querySelectorAll('button[data-act]').forEach((b) => {
b.onclick = () =>
userAction(
b.closest('tr').dataset.u,
b.dataset.act,
users.find((x) => x.username === b.closest('tr').dataset.u)
);
});
}
function setMsg(t) {
const m = document.getElementById('adminUsersMsg');
if (m) m.textContent = t || '';
}
async function userAction(username, act, u) {
if (act === 'role') {
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, {
role: u.role === 'admin' ? 'user' : 'admin',
});
setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
} else if (act === 'disabled') {
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, { disabled: !u.disabled });
setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
} else if (act === 'bypass') {
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, {
canBypassPermissions: !u.canBypassPermissions,
});
setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
} else if (act === 'reset') {
if (!window.confirm(`Reset ${username}'s password? They must set a new one on next login.`)) return;
const r = await apiSend('POST', `/api/admin/users/${encodeURIComponent(username)}/reset-password`);
if (r.ok && r.data && r.data.oneTimePassword) {
window.prompt(`One-time password for ${username} (copy it now — shown once):`, r.data.oneTimePassword);
} else setMsg((r.body && r.body.error) || 'Reset failed.');
} else if (act === 'delete') {
const typed = window.prompt(`Type "${username}" to delete this user. Add " +space" to also delete their files.`);
if (typed !== username && typed !== `${username} +space`) return setMsg('Delete cancelled.');
const deleteSpace = typed.endsWith(' +space');
const r = await apiSend('DELETE', `/api/admin/users/${encodeURIComponent(username)}`, { deleteSpace });
setMsg(r.ok ? `Deleted ${username}.` : (r.body && r.body.error) || 'Delete failed.');
}
renderUsers();
}
async function addUserFlow() {
const username = window.prompt('New username (lowercase, 2-32 chars, [a-z0-9_-]):');
if (!username) return;
const admin = window.confirm('Make this user an admin? (OK = admin, Cancel = regular user)');
const r = await apiSend('POST', '/api/admin/users', { username: username.trim(), role: admin ? 'admin' : 'user' });
if (r.ok && r.data && r.data.oneTimePassword) {
window.prompt(`Created ${username}. One-time password (copy it now — shown once):`, r.data.oneTimePassword);
} else setMsg((r.body && r.body.error) || 'Create failed.');
renderUsers();
}
// ── Boot ──────────────────────────────────────────────────────────────────
async function boot() {
installInterceptor();
let me = null;
try {
me = await apiGet('/api/me');
} catch {
/* server may be pre-auth */
}
window.__codemanUser = me || { username: 'admin', role: 'admin', multiUser: false };
document.dispatchEvent(new CustomEvent('codeman:me', { detail: window.__codemanUser }));
if (window.__codemanUser.mustChangePassword) openChangePassword(true);
if (window.__codemanUser.multiUser && window.__codemanUser.role === 'admin') {
injectUsersTab();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
window.codemanAdmin = { openChangePassword, renderUsers };
})();
+3
View File
@@ -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',
};
// ═══════════════════════════════════════════════════════════════
+2
View File
@@ -1449,6 +1449,7 @@
<label>Startup Mode</label>
<select id="appSettingsClaudeMode" class="form-select">
<option value="dangerously-skip-permissions">Skip Permissions (default)</option>
<option value="auto">Auto (classifier-guarded, low prompts)</option>
<option value="normal">Normal (with prompts)</option>
<option value="allowedTools">Allowed Tools Only</option>
</select>
@@ -2494,6 +2495,7 @@
<script defer src="settings-ui.js"></script>
<script defer src="panels-ui.js"></script>
<script defer src="ultracode-panel.js"></script>
<script defer src="admin-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
+1
View File
@@ -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);
+183 -4
View File
@@ -6,17 +6,23 @@
*/
import { join, resolve, relative, isAbsolute } from 'node:path';
import { realpathSync } from 'node:fs';
import { realpathSync, existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import type { z } from 'zod';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { Session } from '../session.js';
import { ApiErrorCode, createErrorResponse } from '../types.js';
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../types.js';
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
import { SseEvent } from './sse-events.js';
import type { SessionPort } from './ports/session-port.js';
import type { EventPort } from './ports/event-port.js';
import type { AuthSessionRecord } from './ports/auth-port.js';
import type { StaleExpirationMap } from '../utils/index.js';
import { dataPath } from '../config/instance.js';
import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js';
import { SYNTHETIC_ADMIN, findUser } from '../user-store.js';
// Shared path constants used across route modules. CASES_DIR (project folders)
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
@@ -79,13 +85,186 @@ export function validateSessionFilePath(
// Maximum hook data size (prevents oversized SSE broadcasts)
const MAX_HOOK_DATA_SIZE = 8 * 1024;
/**
* Effective identity for a request. In multi-user mode this is the auth-decorated
* user; in single-user mode (or when unset) it defaults to a synthetic admin so
* downstream ownership checks are no-ops and there is ONE code path.
*/
export function getAuthUser(req: FastifyRequest): AuthUser {
return req.authUser ?? SYNTHETIC_ADMIN;
}
/**
* Whether an identity may see/act on a resource with the given owner. Always true
* in single-user mode; in multi-user, admins see everything and regular users only
* their own (an absent owner is legacy/unassigned = admin-only).
*/
export function canAccessOwned(user: AuthUser, owner: string | undefined): boolean {
if (!isMultiUserMode()) return true;
if (user.role === 'admin') return true;
return !!owner && owner === user.username;
}
/**
* The owner to stamp on a resource created by this request: the requesting user in
* multi-user mode, or undefined in single-user (so state stays owner-free and the
* flag can be removed later without leaving stray owners).
*/
export function ownerFor(req: FastifyRequest): string | undefined {
return isMultiUserMode() ? getAuthUser(req).username : undefined;
}
/**
* The cases directory for a request/user: the shared ~/codeman-cases in single-user
* mode, or the per-user ~/codeman-users/<username>/cases in multi-user (created
* lazily). Admins are NOT auto-scoped here — an admin acting on a specific user's
* case resolves through the owner-aware case resolver instead.
*/
export function resolveCasesDir(user?: AuthUser): string {
if (!isMultiUserMode() || !user) return CASES_DIR;
const dir = userCasesDir(user.username);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return dir;
}
/**
* Realpath-confine a non-admin's requested working directory to their own case
* space in multi-user mode. Returns true if allowed. Admins and single-user mode
* are unrestricted. The path need not exist yet (checked against its nearest
* existing ancestor) so newly-created case dirs pass. This is the load-bearing
* rule (plan 6.2/14.7): every file-serving surface downstream trusts workingDir.
*/
export function isWorkingDirAllowed(user: AuthUser, workingDir: string): boolean {
if (!isMultiUserMode() || user.role === 'admin') return true;
const base = userCasesDir(user.username);
// Resolve the deepest existing ancestor to defeat symlink escapes without
// requiring the leaf to exist yet.
const resolveExisting = (p: string): string => {
let cur = resolve(p);
// walk up until an existing path is found
for (;;) {
try {
return realpathSync(cur);
} catch {
const parent = resolve(cur, '..');
if (parent === cur) return cur;
cur = parent;
}
}
};
let realBase: string;
try {
realBase = realpathSync(base);
} catch {
// base does not exist yet — create it so confinement has a stable anchor
mkdirSync(base, { recursive: true });
realBase = realpathSync(base);
}
const realTarget = resolveExisting(workingDir);
if (realTarget === realBase) return true;
const rel = relative(realBase, realTarget);
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
}
/**
* Username-keyed variant of `isWorkingDirAllowed` for spawn sites that only carry
* an owner username (cron fire-time, scheduled-run loop) rather than a live request.
* Resolves the owner's role from the store; a missing/deleted user is treated as a
* non-privileged regular user (fails closed to their deterministic case space).
* No-op (true) in single-user mode or for an unset owner.
*/
export async function isWorkingDirAllowedForUsername(
username: string | undefined,
workingDir: string
): Promise<boolean> {
if (!isMultiUserMode() || !username) return true;
const user = await findUser(username);
return isWorkingDirAllowed({ username, role: user?.role ?? 'user' }, workingDir);
}
/** Whether the caller is an admin (or single-user mode, where the sole user is admin). */
export function isAdmin(req: FastifyRequest): boolean {
return !isMultiUserMode() || getAuthUser(req).role === 'admin';
}
/**
* First line of admin-only handlers: 403 FORBIDDEN + returns false when the caller
* is not an admin. Always true in single-user mode (the sole user is the admin).
*/
export function requireAdmin(req: FastifyRequest, reply: FastifyReply): boolean {
if (isAdmin(req)) return true;
reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN));
return false;
}
/**
* Session-capacity check, centralized so the global cap AND the per-user cap are
* enforced everywhere a session is created (the check was copy-pasted at 6 sites).
* Pure: takes the sessions Map so it composes with ctx.sessions / this.sessions /
* this.deps.sessions callers. Per-user cap only applies in multi-user mode.
*/
export function sessionCapacityState(
sessions: ReadonlyMap<string, Session>,
owner?: string
): { atGlobalCap: boolean; atUserCap: boolean } {
const atGlobalCap = sessions.size >= MAX_CONCURRENT_SESSIONS;
let atUserCap = false;
if (isMultiUserMode() && owner) {
let count = 0;
for (const s of sessions.values()) if (s.owner === owner) count++;
atUserCap = count >= maxSessionsPerUser();
}
return { atGlobalCap, atUserCap };
}
/**
* Route sugar: the human-readable error message when at capacity, else null. The
* caller wraps it in createErrorResponse with its own error code (OPERATION_FAILED
* vs SESSION_BUSY, matching the pre-existing per-route codes).
*/
export function sessionCapacityMessage(sessions: ReadonlyMap<string, Session>, owner?: string): string | null {
const { atGlobalCap, atUserCap } = sessionCapacityState(sessions, owner);
if (atGlobalCap) {
return `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`;
}
if (atUserCap) {
return `Your session limit (${maxSessionsPerUser()}) reached. Delete some of your sessions first.`;
}
return null;
}
/**
* Revoke every cookie session belonging to a user (optionally keeping one token,
* e.g. the caller's own during a self-service password change). Returns the count.
*/
export function revokeUserSessions(
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null,
username: string,
exceptToken?: string
): number {
if (!authSessions) return 0;
const norm = username.trim().toLowerCase();
let removed = 0;
for (const [token, record] of authSessions) {
if (record.username === norm && token !== exceptToken) {
authSessions.delete(token);
removed++;
}
}
return removed;
}
/**
* Look up a session by ID or throw a structured error.
* Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`.
*
* When `req` is passed in multi-user mode, a session the caller does not own is
* reported as NOT_FOUND (never 403), so existence of other users' sessions is not
* leaked. Single-user / admin callers are unaffected.
*/
export function findSessionOrFail(ctx: SessionPort, sessionId: string): Session {
export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: FastifyRequest): Session {
const session = ctx.sessions.get(sessionId);
if (!session) {
if (!session || (req && !canAccessOwned(getAuthUser(req), session.owner))) {
throw Object.assign(new Error(`Session ${sessionId} not found`), {
statusCode: 404,
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
+204
View File
@@ -0,0 +1,204 @@
/**
* @fileoverview Admin user-management routes (multi-user mode only).
*
* All handlers: 404 unless multi-user mode is active, requireAdmin, and audit-logged
* to ~/.codeman/admin-audit.jsonl. Endpoints (docs/multi-user-plan.md section 8):
* GET /api/admin/users
* POST /api/admin/users
* PATCH /api/admin/users/:username
* POST /api/admin/users/:username/reset-password
* POST /api/admin/users/:username/logout
* DELETE /api/admin/users/:username
*
* Self-service GET /api/me + POST /api/me/password live in me-routes.ts.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { readdirSync } from 'node:fs';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { isMultiUserMode, userCasesDir } from '../../config/multiuser.js';
import {
createUser,
deleteUser,
deleteUserSpace,
findUser,
generateOneTimePassword,
readUsers,
setPassword,
toPublicUser,
updateUser,
UserStoreError,
} from '../../user-store.js';
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
import { appendAdminAudit } from '../admin-audit.js';
import { SseEvent } from '../sse-events.js';
import type { AuthPort } from '../ports/auth-port.js';
import type { SessionPort } from '../ports/session-port.js';
import type { EventPort } from '../ports/event-port.js';
const CreateUserSchema = z.object({
username: z.string().min(1).max(64),
role: z.enum(['admin', 'user']).default('user'),
password: z.string().min(8).max(1024).optional(),
canBypassPermissions: z.boolean().optional(),
});
const UpdateUserSchema = z.object({
role: z.enum(['admin', 'user']).optional(),
disabled: z.boolean().optional(),
canBypassPermissions: z.boolean().optional(),
});
const DeleteUserSchema = z.object({ deleteSpace: z.boolean().optional() });
/** Map a UserStoreError's code onto the API error code + status. */
function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof createErrorResponse> {
if (err instanceof UserStoreError) {
const code = ApiErrorCode[err.code as keyof typeof ApiErrorCode] ?? ApiErrorCode.INVALID_INPUT;
reply.code(
err.code === 'USER_EXISTS' || err.code === 'LAST_ADMIN' ? 409 : err.code === 'USER_NOT_FOUND' ? 404 : 400
);
return createErrorResponse(code, err.message);
}
reply.code(500);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : 'error');
}
export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & AuthPort & EventPort): void {
// Gate: admin routes exist only in multi-user mode, and only for admins.
const gate = (req: FastifyRequest, reply: FastifyReply): boolean => {
if (!isMultiUserMode()) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Not found'));
return false;
}
return requireAdmin(req, reply);
};
const audit = (req: FastifyRequest, action: string, target?: string, detail?: Record<string, unknown>) =>
void appendAdminAudit({ admin: getAuthUser(req).username, action, target, ip: req.ip, detail });
// Count a user's live sessions + active cookie sessions + case folders.
const statsFor = (username: string) => {
let liveSessions = 0;
for (const s of ctx.sessions.values()) if (s.owner === username) liveSessions++;
let activeSessions = 0;
if (ctx.authSessions) for (const [, rec] of ctx.authSessions) if (rec.username === username) activeSessions++;
let caseCount = 0;
try {
caseCount = readdirSync(userCasesDir(username), { withFileTypes: true }).filter((e) => e.isDirectory()).length;
} catch {
/* no cases dir yet */
}
return { liveSessions, activeSessions, caseCount };
};
app.get('/api/admin/users', async (req, reply) => {
if (!gate(req, reply)) return;
const users = await readUsers(true);
return {
success: true,
data: users.map((u) => ({ ...toPublicUser(u), stats: statsFor(u.username) })),
};
});
app.post('/api/admin/users', async (req, reply) => {
if (!gate(req, reply)) return;
const parsed = CreateUserSchema.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input');
}
// No password given: generate a one-time password, returned ONCE, force change.
const oneTime = parsed.data.password ? undefined : generateOneTimePassword();
try {
const user = await createUser({
username: parsed.data.username,
role: parsed.data.role,
password: parsed.data.password ?? oneTime!,
canBypassPermissions: parsed.data.canBypassPermissions,
mustChangePassword: !parsed.data.password,
});
audit(req, 'user.create', user.username, { role: user.role });
ctx.broadcast(SseEvent.AdminUsersChanged, {});
return { success: true, data: { user: toPublicUser(user), oneTimePassword: oneTime } };
} catch (err) {
return storeError(reply, err);
}
});
app.patch('/api/admin/users/:username', async (req, reply) => {
if (!gate(req, reply)) return;
const { username } = req.params as { username: string };
const parsed = UpdateUserSchema.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input');
}
try {
const user = await updateUser(username, parsed.data);
// Security: revoke the target's cookie sessions on ANY successful update. role,
// disabled, and canBypassPermissions are all authorization-relevant, and the
// cookie snapshots role, so a stale cookie could otherwise retain old privileges
// (a demoted admin staying admin). Idempotent, affects only the target, and
// forces a re-auth that re-snapshots the new record.
revokeUserSessions(ctx.authSessions, user.username);
audit(req, 'user.update', user.username, parsed.data);
ctx.broadcast(SseEvent.AdminUsersChanged, {});
return { success: true, data: { user: toPublicUser(user) } };
} catch (err) {
return storeError(reply, err);
}
});
app.post('/api/admin/users/:username/reset-password', async (req, reply) => {
if (!gate(req, reply)) return;
const { username } = req.params as { username: string };
if (!(await findUser(username))) {
reply.code(404);
return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user');
}
const oneTime = generateOneTimePassword();
try {
await setPassword(username, oneTime, { mustChangePassword: true });
revokeUserSessions(ctx.authSessions, username);
audit(req, 'user.reset-password', username);
ctx.broadcast(SseEvent.AdminUsersChanged, {});
return { success: true, data: { oneTimePassword: oneTime } };
} catch (err) {
return storeError(reply, err);
}
});
app.post('/api/admin/users/:username/logout', async (req, reply) => {
if (!gate(req, reply)) return;
const { username } = req.params as { username: string };
const revoked = revokeUserSessions(ctx.authSessions, username);
audit(req, 'user.logout', username, { revoked });
return { success: true, data: { revoked } };
});
app.delete('/api/admin/users/:username', async (req, reply) => {
if (!gate(req, reply)) return;
const { username } = req.params as { username: string };
const parsed = DeleteUserSchema.safeParse(req.body ?? {});
const deleteSpace = parsed.success ? parsed.data.deleteSpace : false;
try {
// Security: validate BEFORE any teardown. deleteUser runs the authoritative
// existence + last-admin guard under lock with no side effects, so a refusal
// (409 LAST_ADMIN / 404 USER_NOT_FOUND) leaves the user's live sessions and
// cookies untouched. Only after it succeeds do we irreversibly kill sessions and
// revoke cookies. (owned is captured from the in-memory map, independent of the
// record, so it is safe to read before the delete.)
const owned = [...ctx.sessions.values()].filter((s) => s.owner === username).map((s) => s.id);
await deleteUser(username); // throws LAST_ADMIN / USER_NOT_FOUND (no side effects)
for (const id of owned) {
await ctx.cleanupSession(id, true, 'admin_delete_user').catch(() => {});
}
revokeUserSessions(ctx.authSessions, username);
if (deleteSpace) await deleteUserSpace(username);
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
ctx.broadcast(SseEvent.AdminUsersChanged, {});
return { success: true, data: { username, deletedSpace: !!deleteSpace } };
} catch (err) {
return storeError(reply, err);
}
});
}
+151 -59
View File
@@ -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<Record<string, string>> {
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
}
/** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */
async function resolveCasePath(name: string): Promise<string> {
/**
* Resolve a case name to its directory path, checking linked cases first, then the
* user's case space (per-user in multi-user mode, the shared CASES_DIR otherwise).
*/
async function resolveCasePath(name: string, user?: AuthUser): Promise<string> {
const linkedCases = await readLinkedCases();
if (linkedCases[name]) return linkedCases[name];
return join(CASES_DIR, name);
// Linked cases carry no owner (legacy/admin-only registry): a non-admin must not
// resolve arbitrary linked paths by name in multi-user mode (path-escape guard).
if (linkedCases[name] && (!isMultiUserMode() || user?.role === 'admin')) return linkedCases[name];
return join(resolveCasesDir(user), name);
}
/**
@@ -134,47 +153,54 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// ========== List Cases ==========
app.get('/api/cases', async (): Promise<CaseInfo[]> => {
app.get('/api/cases', async (req): Promise<CaseInfo[]> => {
const cases: CaseInfo[] = [];
const user = getAuthUser(req);
const admin = isAdmin(req);
// Non-admins enumerate their OWN case space; admins see the shared CASES_DIR.
const listBase = resolveCasesDir(user);
// Get cases from CASES_DIR
// Get cases from the user's (or shared) cases dir
try {
const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
const entries = await fs.readdir(listBase, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
cases.push({
name: e.name,
path: join(CASES_DIR, e.name),
hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')),
path: join(listBase, e.name),
hasClaudeMd: existsSync(join(listBase, e.name, 'CLAUDE.md')),
location: 'local',
});
}
}
} catch {
// CASES_DIR may not exist yet
// dir may not exist yet
}
// Get linked cases
// Linked cases (v1 registry has no owner) are admin-only in multi-user mode.
const linkedCases = await readLinkedCases();
const existingNames = new Set(cases.map((c) => c.name));
for (const [name, path] of Object.entries(linkedCases)) {
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
cases.push({
name,
path,
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
linked: true,
location: 'linked-local',
});
if (admin) {
for (const [name, path] of Object.entries(linkedCases)) {
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
cases.push({
name,
path,
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
linked: true,
location: 'linked-local',
});
}
}
}
// Get remote cases
// Get remote cases (owner-scoped; legacy no-owner = admin-only)
const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
const host = remoteHostMap.get(remoteCase.hostId);
if (!host || !SAFE_CASE_NAME.test(remoteCase.name)) continue;
if (!admin && !canAccessOwned(user, remoteCase.owner)) continue;
existingNames.add(remoteCase.name);
const remoteCaseInfo: CaseInfo = {
name: remoteCase.name,
@@ -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<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, description } = parseBody(CreateCaseSchema, req.body);
const casePath = validatePathWithinBase(name, CASES_DIR);
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
if (!casePath) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
}
@@ -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<ApiResponse<{ host: unknown }>> => {
// Hosts are machine-level resources: only admins may define them in multi-user mode.
const adminOnly = (req: FastifyRequest, reply: { code: (n: number) => unknown }): ApiResponse<never> | null =>
isAdmin(req)
? null
: (reply.code(403), createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'));
app.post('/api/remote-hosts', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const host = parseBody(RemoteHostSchema, req.body);
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
if (hosts.some((item) => item.id === host.id)) {
@@ -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<ApiResponse<{ host: unknown }>> => {
app.put('/api/remote-hosts/:id', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const { id } = req.params as { id: string };
const host = parseBody(RemoteHostSchema, { ...(req.body as object), id });
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
@@ -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<ApiResponse<{ id: string }>> => {
app.delete('/api/remote-hosts/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const { id } = req.params as { id: string };
const cases = await readRemoteCases(CODEMAN_CONFIG_DIR);
if (cases.some((item) => item.hostId === id)) {
@@ -311,7 +354,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
});
app.post('/api/cases/remote-link', async (req): Promise<ApiResponse<{ case: unknown }>> => {
const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const };
const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const, owner: ownerFor(req) };
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
const host = hosts.find((item) => item.id === remoteCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
@@ -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<ApiResponse<{ host: unknown }>> => {
app.post('/api/docker-hosts', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const host = parseBody(DockerHostSchema, req.body);
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
if (hosts.some((item) => item.id === host.id)) {
@@ -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<ApiResponse<{ host: unknown }>> => {
app.put('/api/docker-hosts/:id', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const { id } = req.params as { id: string };
const host = parseBody(DockerHostSchema, { ...(req.body as object), id });
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
@@ -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<ApiResponse<{ id: string }>> => {
app.delete('/api/docker-hosts/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const { id } = req.params as { id: string };
const cases = await readDockerCases(CODEMAN_CONFIG_DIR);
if (cases.some((item) => item.hostId === id)) {
@@ -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<ApiResponse<{ case: { name: string; path: string } }>> => {
app.post('/api/cases/link', async (req, reply): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
// Linking writes an arbitrary absolute path into the shared ownerless registry:
// admin-only in multi-user mode (mirrors host CRUD + the admin-only GET listing).
const denied = adminOnly(req, reply);
if (denied) return denied;
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
// Expand ~ to home directory
@@ -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<ApiResponse<{ name: string }>> => {
const { name } = req.params as { name: string };
const user = getAuthUser(req);
if (!validatePathWithinBase(name, CASES_DIR)) {
if (!validatePathWithinBase(name, resolveCasesDir(user))) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
// Fold ownership INTO the match (don't early-return): a non-owned same-named remote/
// docker case is skipped so control falls through to the caller's own local delete.
// canAccessOwned is all-true for admins/single-user, so flag-OFF stays byte-identical.
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
if (remoteCases.some((item) => item.name === name)) {
if (remoteCases.some((item) => item.name === name && canAccessOwned(user, item.owner))) {
await writeRemoteCases(
CODEMAN_CONFIG_DIR,
remoteCases.filter((item) => item.name !== name)
remoteCases.filter((item) => !(item.name === name && canAccessOwned(user, item.owner)))
);
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
return { success: true, data: { name } };
}
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');
+12 -2
View File
@@ -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 {};
+66 -9
View File
@@ -9,33 +9,77 @@
import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import { canAccessOwned, getAuthUser, isWorkingDirAllowed, ownerFor, parseBody } from '../route-helpers.js';
import { canUsernameRunPrivilegedCommands } from '../../user-store.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import type { CronJob } from '../../types/cron.js';
import type { CronPort } from '../ports/index.js';
import type { FastifyRequest } from 'fastify';
export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
// A job the caller may see/act on (own, or admin/single-user).
const canTouch = (req: FastifyRequest, job: CronJob | null | undefined): job is CronJob =>
!!job && canAccessOwned(getAuthUser(req), job.owner);
// ── Jobs ────────────────────────────────────────────────────────────────
app.get('/api/cron/jobs', async () => {
return ctx.cron.listJobs();
app.get('/api/cron/jobs', async (req) => {
const jobs = ctx.cron.listJobs();
if (!isMultiUserMode()) return jobs;
const user = getAuthUser(req);
if (user.role === 'admin') return jobs;
return (jobs as CronJob[]).filter((j) => canAccessOwned(user, j.owner));
});
app.post('/api/cron/jobs', async (req) => {
// No custom errorMessage: surface the schema's field-specific messages
// (e.g. "runAt is required for a one-time schedule").
const body = parseBody(CronJobSchema, req.body);
return { job: ctx.cron.createJob(body) };
// Section 6.2: confine the job's workingDir to the owner's case space (mirrors
// POST /api/sessions). No-op allow-all for admins/single-user. workingDir is
// required by CronJobSchema so it is always present here.
if (!isWorkingDirAllowed(getAuthUser(req), body.workingDir)) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
}
// Section 6.3: shell mode / a launchCommand is arbitrary host-account execution.
// Resolve the owner's grant from the store (AuthUser.role alone can't tell a GRANTED
// regular user from a plain one); mirrors session-routes + the cron fire-time re-check.
if (
(body.agentType === 'shell' || body.launchCommand) &&
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'Shell/launchCommand cron jobs require the can-bypass-permissions grant'
);
}
return { job: ctx.cron.createJob(body, ownerFor(req)) };
});
app.get('/api/cron/jobs/:id', async (req) => {
const { id } = req.params as { id: string };
const job = ctx.cron.getJob(id);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
if (!canTouch(req, job)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
return job;
});
app.put('/api/cron/jobs/:id', async (req) => {
const { id } = req.params as { id: string };
if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
const body = parseBody(CronJobUpdateSchema, req.body);
// Section 6.2: the update body is partial, so only confine when workingDir is set.
if (body.workingDir !== undefined && !isWorkingDirAllowed(getAuthUser(req), body.workingDir)) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
}
if (
(body.agentType === 'shell' || body.launchCommand) &&
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'Shell/launchCommand cron jobs require the can-bypass-permissions grant'
);
}
const job = ctx.cron.updateJob(id, body);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
return { job };
@@ -43,7 +87,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
app.delete('/api/cron/jobs/:id', async (req) => {
const { id } = req.params as { id: string };
if (!ctx.cron.deleteJob(id)) {
if (!canTouch(req, ctx.cron.getJob(id)) || !ctx.cron.deleteJob(id)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
}
return {};
@@ -51,6 +95,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
app.put('/api/cron/jobs/:id/enabled', async (req) => {
const { id } = req.params as { id: string };
if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
const { enabled } = parseBody(CronJobEnabledSchema, req.body, 'Invalid request body');
const job = ctx.cron.setEnabled(id, enabled);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
@@ -62,7 +107,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
app.post('/api/cron/jobs/:id/run', async (req) => {
const { id } = req.params as { id: string };
const job = ctx.cron.getJob(id);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
if (!canTouch(req, job)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
const run = await ctx.cron.runNow(id);
return { run, activeAgents: ctx.cron.countActiveAgents(job.agentType, job.id) };
});
@@ -71,10 +116,22 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
app.get('/api/cron/jobs/:id/runs', async (req) => {
const { id } = req.params as { id: string };
// Owner-gate like every other :id handler so a foreign job's run history (session
// ids, names, deep links) isn't leaked; NOT_FOUND avoids disclosing existence.
if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
return ctx.cron.listRuns(id);
});
app.get('/api/cron/runs', async () => {
return ctx.cron.listRuns();
app.get('/api/cron/runs', async (req) => {
const runs = ctx.cron.listRuns();
if (!isMultiUserMode()) return runs;
const user = getAuthUser(req);
if (user.role === 'admin') return runs;
// Non-admin: keep only runs whose owning job the caller can access (drops runs
// whose job is absent from the map — defensive; deleteJob already cascades).
const ownerByJobId = new Map<string, string | undefined>(
ctx.cron.listJobs().map((j): [string, string | undefined] => [j.id, j.owner])
);
return runs.filter((run) => canAccessOwned(user, ownerByJobId.get(run.cronJobId)));
});
}
+28 -20
View File
@@ -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'));
+2
View File
@@ -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';
+81
View File
@@ -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 };
});
}
+18 -5
View File
@@ -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 {};
});
+33 -11
View File
@@ -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 };
+35 -9
View File
@@ -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) {
+4
View File
@@ -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 } };
});
+29 -20
View File
@@ -13,13 +13,22 @@ import { Session, isExternalCliMode } from '../../session.js';
import { RespawnController } from '../../respawn-controller.js';
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js';
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js';
import {
autoConfigureRalph,
getAuthUser,
ownerFor,
resolveCasesDir,
sessionCapacityMessage,
SETTINGS_PATH,
findSessionOrFail,
parseBody,
} from '../route-helpers.js';
import { resolveClaudeModeForUsername } from '../../user-store.js';
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { buildRalphLoopPrompt } from '../../prompts/index.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
export function registerRalphRoutes(
app: FastifyInstance,
@@ -42,7 +51,7 @@ export function registerRalphRoutes(
reset?: boolean | 'full';
disableAutoEnable?: boolean;
};
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
@@ -118,7 +127,7 @@ export function registerRalphRoutes(
// Reset circuit breaker for Ralph tracker
app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
session.ralphTracker.resetCircuitBreaker();
return {};
@@ -127,7 +136,7 @@ export function registerRalphRoutes(
// Get Ralph status block and circuit breaker state
app.get('/api/sessions/:id/ralph-status', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
return {
success: true,
@@ -147,7 +156,7 @@ export function registerRalphRoutes(
// Generate @fix_plan.md content from todos
app.get('/api/sessions/:id/fix-plan', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
const content = session.ralphTracker.generateFixPlanMarkdown();
return {
@@ -163,7 +172,7 @@ export function registerRalphRoutes(
app.post('/api/sessions/:id/fix-plan/import', async (req) => {
const { id } = req.params as { id: string };
const { content } = parseBody(FixPlanImportSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
ctx.persistSessionState(session);
@@ -180,7 +189,7 @@ export function registerRalphRoutes(
// Write @fix_plan.md to session's working directory
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
const workingDir = session.workingDir;
if (!workingDir) {
@@ -207,7 +216,7 @@ export function registerRalphRoutes(
// Read @fix_plan.md from session's working directory and import
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
const workingDir = session.workingDir;
if (!workingDir) {
@@ -246,7 +255,7 @@ export function registerRalphRoutes(
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
const { id } = req.params as { id: string };
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
const workingDir = session.workingDir;
if (!workingDir) {
@@ -271,13 +280,9 @@ export function registerRalphRoutes(
// Start a Ralph Loop — creates a new session with autonomous cycling
app.post('/api/ralph-loop/start', async (req): Promise<ApiResponse> => {
// Prevent unbounded session creation
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
return createErrorResponse(
ApiErrorCode.SESSION_BUSY,
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`
);
}
const rlOwner = ownerFor(req);
const capMsg = sessionCapacityMessage(ctx.sessions, rlOwner);
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
const {
caseName,
@@ -290,11 +295,13 @@ export function registerRalphRoutes(
effort,
} = parseBody(RalphLoopStartSchema, req.body);
const casePath = join(CASES_DIR, caseName);
// Multi-user: cases live in the requesting user's space.
const rlCasesBase = resolveCasesDir(getAuthUser(req));
const casePath = join(rlCasesBase, caseName);
// Security: Path traversal protection
const rlResolvedPath = resolve(casePath);
const rlResolvedBase = resolve(CASES_DIR);
const rlResolvedBase = resolve(rlCasesBase);
const rlRelPath = relative(rlResolvedBase, rlResolvedPath);
if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
@@ -324,6 +331,7 @@ export function registerRalphRoutes(
const niceConfig = await ctx.getGlobalNiceConfig();
const rlModelConfig = await ctx.getModelConfig();
const rlClaudeModeConfig = await ctx.getClaudeModeConfig();
const rlClaudeMode = await resolveClaudeModeForUsername(rlClaudeModeConfig.claudeMode, rlOwner);
const session = new Session({
workingDir: casePath,
mux: ctx.mux,
@@ -331,10 +339,11 @@ export function registerRalphRoutes(
mode: 'claude',
niceConfig,
model: rlModelConfig?.defaultModel || undefined,
claudeMode: rlClaudeModeConfig.claudeMode,
claudeMode: rlClaudeMode,
allowedTools: rlClaudeModeConfig.allowedTools,
envOverrides,
effort,
owner: rlOwner,
});
// Configure Ralph tracker
+23 -14
View File
@@ -8,7 +8,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type PersistedRespa
import { RespawnController, type RespawnConfig } from '../../respawn-controller.js';
import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js';
import { findSessionOrFail, autoConfigureRalph, parseBody } from '../route-helpers.js';
import { findSessionOrFail, autoConfigureRalph, parseBody, canAccessOwned, getAuthUser } from '../route-helpers.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import { isExternalCliMode } from '../../session.js';
@@ -46,7 +46,11 @@ export function registerRespawnRoutes(
const { id } = req.params as { id: string };
const controller = ctx.respawnControllers.get(id);
if (!controller) {
// Multi-user: gate on the owner from the same source the data comes from, and
// return the existing neutral shape (not 404) when foreign so existence isn't
// leaked. canAccessOwned is allow-all in single-user mode → byte-identical.
const owner = ctx.sessions.get(id)?.owner ?? ctx.mux.getSession(id)?.owner;
if (!controller || !canAccessOwned(getAuthUser(req), owner)) {
return { enabled: false, status: null };
}
@@ -60,16 +64,21 @@ export function registerRespawnRoutes(
app.get('/api/sessions/:id/respawn/config', async (req) => {
const { id } = req.params as { id: string };
// Multi-user: owner-gate each branch against the source of the data, preserving
// the neutral {config:null,active:false} shape when foreign (no existence leak).
// canAccessOwned is allow-all in single-user mode → byte-identical, and this keeps
// the mux-only pre-config path working (findSessionOrFail would break it).
const user = getAuthUser(req);
const controller = ctx.respawnControllers.get(id);
if (controller) {
if (controller && canAccessOwned(user, ctx.sessions.get(id)?.owner)) {
return { config: controller.getConfig(), active: true };
}
// Return pre-saved config from mux-sessions.json
const preConfig = ctx.mux.getSession(id)?.respawnConfig;
if (preConfig) {
return { config: preConfig, active: false };
const mux = ctx.mux.getSession(id);
if (mux?.respawnConfig && canAccessOwned(user, mux.owner)) {
return { config: mux.respawnConfig, active: false };
}
return { config: null, active: false };
@@ -87,7 +96,7 @@ export function registerRespawnRoutes(
if (req.body) {
body = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
}
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
// Respawn is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
@@ -122,6 +131,9 @@ export function registerRespawnRoutes(
app.post('/api/sessions/:id/respawn/stop', async (req) => {
const { id } = req.params as { id: string };
// Owner-gate before any side effects (matches start/config/enable): a non-owner
// gets NOT_FOUND and never reaches stop/delete/clearRespawnConfig/persist.
const session = findSessionOrFail(ctx, id, req);
const controller = ctx.respawnControllers.get(id);
if (!controller) {
@@ -144,10 +156,7 @@ export function registerRespawnRoutes(
ctx.mux.clearRespawnConfig(id);
// Update state.json (respawnConfig removed)
const session = ctx.sessions.get(id);
if (session) {
ctx.persistSessionState(session);
}
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.RespawnStopped, { sessionId: id });
@@ -160,7 +169,7 @@ export function registerRespawnRoutes(
const { id } = req.params as { id: string };
// Validate respawn config to prevent arbitrary field injection
const config = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
const controller = ctx.respawnControllers.get(id);
@@ -226,7 +235,7 @@ export function registerRespawnRoutes(
respawnConfig?: Partial<RespawnConfig>;
durationMinutes?: number;
};
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
if (session.isBusy()) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
@@ -299,7 +308,7 @@ export function registerRespawnRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = reResult.data as { config?: Partial<RespawnConfig>; durationMinutes?: number };
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
// Respawn is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
+31 -6
View File
@@ -7,17 +7,35 @@ import { FastifyInstance } from 'fastify';
import { statSync } from 'node:fs';
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
import { ScheduledRunSchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import {
parseBody,
getAuthUser,
ownerFor,
isWorkingDirAllowed,
canAccessOwned,
resolveCasesDir,
} from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import type { SessionPort, EventPort, InfraPort, ScheduledRun } from '../ports/index.js';
export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & InfraPort): void {
app.get('/api/scheduled', async () => {
return Array.from(ctx.scheduledRuns.values());
app.get('/api/scheduled', async (req) => {
// Multi-user: non-admins see only their own runs (no-op in single-user).
const user = getAuthUser(req);
return Array.from(ctx.scheduledRuns.values()).filter((r) => canAccessOwned(user, r.owner));
});
app.post('/api/scheduled', async (req): Promise<{ run: ScheduledRun } | ApiResponse<never>> => {
const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body');
// Multi-user: confine the run's workingDir to the caller's own case space.
// The spawned Session (--dangerously-skip-permissions by default) trusts this
// dir; without confinement a non-admin could point it at another user's files.
// No-op for admins / single-user (isWorkingDirAllowed returns true).
if (workingDir && !isWorkingDirAllowed(getAuthUser(req), workingDir)) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is not within your allowed workspace');
}
// Validate workingDir exists and is a directory
if (workingDir) {
try {
@@ -30,7 +48,11 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
}
}
const run = await ctx.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60);
// Multi-user: default a missing workingDir to the user's own cases dir rather
// than the server's cwd. Single-user keeps process.cwd() (byte-identical).
const effectiveWorkingDir = workingDir || (isMultiUserMode() ? resolveCasesDir(getAuthUser(req)) : process.cwd());
const run = await ctx.startScheduledRun(prompt, effectiveWorkingDir, durationMinutes ?? 60, ownerFor(req));
return { run };
});
@@ -38,7 +60,9 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
const { id } = req.params as { id: string };
const run = ctx.scheduledRuns.get(id);
if (!run) {
// NOT_FOUND (not FORBIDDEN) for a foreign run so existence isn't leaked; no-op
// for admins / single-user (canAccessOwned returns true).
if (!run || !canAccessOwned(getAuthUser(req), run.owner)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
}
@@ -50,7 +74,8 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
const { id } = req.params as { id: string };
const run = ctx.scheduledRuns.get(id);
if (!run) {
// Owner-scoped read: a foreign run reads as NOT_FOUND (no-op in single-user).
if (!run || !canAccessOwned(getAuthUser(req), run.owner)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
}
+9 -5
View File
@@ -23,7 +23,7 @@
*/
import { FastifyInstance } from 'fastify';
import { parseBody } from '../route-helpers.js';
import { canAccessOwned, getAuthUser, parseBody } from '../route-helpers.js';
import { SearchQuerySchema } from '../schemas.js';
import {
searchSources,
@@ -62,13 +62,14 @@ interface SessionLike {
* Harvest the three source arrays from the live in-memory stores. Reads only
* bounded, already-loaded data — no disk I/O, no terminal buffers.
*/
function harvestSources(ctx: SessionPort & InfraPort): SearchSources {
function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) => boolean): SearchSources {
const sessions: SessionSearchInput[] = [];
const events: EventSearchInput[] = [];
const files: FileSearchInput[] = [];
for (const raw of ctx.sessions.values()) {
const s = raw as unknown as SessionLike;
const s = raw as unknown as SessionLike & { owner?: string };
if (canSee && !canSee(s.owner)) continue; // multi-user ownership scope
const sessionName = s.name ?? '';
const timestamp = s.lastActivityAt ?? s.createdAt ?? 0;
@@ -96,7 +97,8 @@ function harvestSources(ctx: SessionPort & InfraPort): SearchSources {
// Events: from the live run-summary trackers, keyed by session id.
for (const [sessionId, tracker] of ctx.runSummaryTrackers) {
const session = ctx.sessions.get(sessionId) as unknown as SessionLike | undefined;
const session = ctx.sessions.get(sessionId) as unknown as (SessionLike & { owner?: string }) | undefined;
if (canSee && !canSee(session?.owner)) continue; // multi-user ownership scope
const sessionName = session?.name ?? '';
const summary = tracker.getSummary();
// Newest events are most relevant; cap the per-session harvest.
@@ -120,6 +122,8 @@ export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & In
app.get('/api/search', async (req) => {
// Zod-validate the query. parseBody throws a structured 400 on failure.
const { q, types, limit } = parseBody(SearchQuerySchema, req.query);
const user = getAuthUser(req);
const canSee = (owner?: string) => canAccessOwned(user, owner);
const allowed: Set<SearchSourceType> | null = types
? new Set(
@@ -130,7 +134,7 @@ export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & In
)
: null;
const sources = harvestSources(ctx);
const sources = harvestSources(ctx, canSee);
// Apply the optional source-type filter before searching so excluded
// sources never contribute to (or consume budget in) the result set.
+215 -69
View File
@@ -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<ApiResponse<{ killed: number }>> => {
const sessionIds = Array.from(ctx.sessions.keys());
app.delete('/api/sessions', async (req): Promise<ApiResponse<{ killed: number }>> => {
// Security: scope the bulk sweep to sessions the caller can access — a non-admin
// must not wipe other users' sessions (canAccessOwned is allow-all for admin/single-user).
const user = getAuthUser(req);
const sessionIds = Array.from(ctx.sessions.values())
.filter((s) => canAccessOwned(user, s.owner))
.map((s) => s.id);
let killed = 0;
for (const id of sessionIds) {
@@ -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<string, { id: string; owner?: string }>;
const ownedPersisted = new Set(
Object.values(stored)
.filter((p) => canAccessOwned(uUser, p.owner))
.map((p) => p.id)
);
const isOwned = (id: string) => ownedLive.has(id) || ownedPersisted.has(id);
sLive = live.filter((l) => isOwned(l.id));
sPersisted = persisted.filter((p) => isOwned(p.id));
sLifecycle = lifecycle.filter((e) => isOwned(e.sessionId));
sHistory = [];
}
const merged = mergeUnifiedSessions({
live: sLive,
persisted: sPersisted,
lifecycle: sLifecycle,
history: sHistory,
mux,
});
const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
return filterAndPaginate(merged, {
@@ -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);
+112 -30
View File
@@ -15,6 +15,9 @@ import { randomBytes } from 'node:crypto';
import { dataPath } from '../../config/instance.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser } from '../../user-store.js';
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
import {
ConfigUpdateSchema,
SettingsUpdateSchema,
@@ -136,7 +139,7 @@ export function registerSystemRoutes(
// ========== Status ==========
app.get('/api/status', async () => ctx.getLightState());
app.get('/api/status', async (req) => ctx.getLightState(req.authUser));
// ========== Tunnel ==========
@@ -159,12 +162,19 @@ export function registerSystemRoutes(
};
});
app.get('/api/tunnel/qr', async (_req, reply) => {
app.get('/api/tunnel/qr', async (req, reply) => {
const url = ctx.tunnelManager.getUrl();
if (!url) {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running'));
}
try {
if (isMultiUserMode()) {
// A rotating global token cannot carry identity — mint a single-use token
// bound to the requesting user so the scanned code logs THEM in.
const shortCode = ctx.tunnelManager.mintUserToken(getAuthUser(req).username);
const svg = await ctx.tunnelManager.getQrSvgForCode(url, shortCode);
return { svg, authEnabled: true };
}
const authPassword = process.env.CODEMAN_PASSWORD;
if (authPassword) {
// Auth enabled — use cached SVG with embedded short code
@@ -188,10 +198,11 @@ export function registerSystemRoutes(
app.get('/q/:code', async (req, reply) => {
const shortCode = (req.params as { code: string }).code;
const multiUser = isMultiUserMode();
const authPassword = process.env.CODEMAN_PASSWORD;
// No point if auth isn't enabled — just redirect
if (!authPassword) {
// No point if auth isn't enabled — just redirect. Multi-user is always "enabled".
if (!multiUser && !authPassword) {
return reply.redirect('/');
}
@@ -203,12 +214,28 @@ export function registerSystemRoutes(
return reply.code(429).send('Too Many Requests');
}
// Validate and atomically consume the token
if (!shortCode || !ctx.tunnelManager.consumeToken(shortCode)) {
// Validate and atomically consume the token (with any bound identity).
const consumed = shortCode ? ctx.tunnelManager.consumeTokenWithIdentity(shortCode) : { ok: false };
// In multi-user mode a token MUST carry an identity (an identity-less rotating
// token can't create a scoped session), so reject those too.
if (!consumed.ok || (multiUser && !consumed.username)) {
ctx.qrAuthFailures?.set(clientIp, qrFailures + 1);
return reply.code(401).send('Invalid or expired QR code');
}
// Resolve the role for the bound user (disabled/deleted users fail closed).
// Carry the bound user's real mustChangePassword flag out of this block so the
// minted cookie enforces the lockbox instead of hardcoding false.
let identity: { username: string; role: 'admin' | 'user'; mustChangePassword: boolean } | undefined;
if (multiUser && consumed.username) {
const user = await findUser(consumed.username);
if (!user || user.disabled) {
ctx.qrAuthFailures?.set(clientIp, qrFailures + 1);
return reply.code(401).send('Invalid or expired QR code');
}
identity = { username: user.username, role: user.role, mustChangePassword: !!user.mustChangePassword };
}
// Issue session cookie (same pattern as Basic Auth success path)
const sessionToken = randomBytes(32).toString('hex');
const clientUA = req.headers['user-agent'] ?? '';
@@ -217,6 +244,9 @@ export function registerSystemRoutes(
ua: clientUA,
createdAt: Date.now(),
method: 'qr',
username: identity?.username,
role: identity?.role,
mustChangePassword: !!identity?.mustChangePassword,
});
ctx.qrAuthFailures?.delete(clientIp);
@@ -465,23 +495,52 @@ export function registerSystemRoutes(
limit: 1000,
});
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values()).map((session) => ({
id: session.id,
name: session.name,
status: session.status,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
totalCost: session.totalCost,
}));
// Multi-user: scope the digest's aggregated activity to sessions the caller
// owns (canAccessOwned is a no-op allow-all for admins/single-user).
const user = getAuthUser(req);
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values())
.filter((session) => canAccessOwned(user, session.owner))
.map((session) => ({
id: session.id,
name: session.name,
status: session.status,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
totalCost: session.totalCost,
}));
const runSummaries = Array.from(ctx.runSummaryTrackers.values()).map((tracker) => tracker.getSummary());
// Run-summary trackers are keyed by Codeman session id → filter by that session's owner.
const runSummaries = Array.from(ctx.runSummaryTrackers.entries())
.filter(([id]) => canAccessOwned(user, ctx.sessions.get(id)?.owner))
.map(([, tracker]) => tracker.getSummary());
// Map each subagent's Claude conversation id back to its owning session so the
// recent-subagent lookback is owner-scoped too (fails closed when unattributable).
const ownerByClaudeSessionId = new Map<string, string | undefined>();
for (const s of ctx.sessions.values()) {
if (s.claudeSessionId) ownerByClaudeSessionId.set(s.claudeSessionId, s.owner);
}
const subagents = subagentWatcher
.getRecentSubagents(60)
.filter((sa) => canAccessOwned(user, ownerByClaudeSessionId.get(sa.sessionId))) as AwayDigestSubagent[];
// Multi-user: the lifecycle log and daily token stats carry no owner, so scope them
// for a non-admin: keep only lifecycle entries attributable to an owned LIVE session
// (fail closed — an ended session's owner can't be resolved, so it is dropped rather
// than leaked), and withhold the machine-wide daily token totals entirely (they can't
// be per-user attributed, same as globalStats in #29). Admins/single-user keep all
// (canAccessOwned allow-all, role check false → byte-identical).
const scopedLifecycle = lifecycleEntries.filter((e) =>
canAccessOwned(user, ctx.sessions.get(e.sessionId ?? '')?.owner)
);
const nonAdminScoped = isMultiUserMode() && user.role !== 'admin';
const digest = buildAwayDigest({
range,
lifecycleEntries,
lifecycleEntries: scopedLifecycle,
runSummaries,
sessions,
dailyTokenStats: ctx.store.getDailyStats(30),
subagents: subagentWatcher.getRecentSubagents(60) as AwayDigestSubagent[],
dailyTokenStats: nonAdminScoped ? [] : ctx.store.getDailyStats(30),
subagents,
now: range.until,
});
@@ -585,7 +644,10 @@ export function registerSystemRoutes(
// letting an operator opt in from the browser without setting the env var.
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) {
const acknowledged = isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
// Multi-user mode makes the tunnel authenticated (every person has their own
// credential), so it satisfies the same requirement as CODEMAN_PASSWORD.
const acknowledged =
isMultiUserMode() || isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
if (!acknowledged) {
const msg =
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
@@ -727,7 +789,7 @@ export function registerSystemRoutes(
app.get('/api/sessions/:id/cpu-limit', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
return {
nice: session.niceConfig,
};
@@ -735,7 +797,7 @@ export function registerSystemRoutes(
app.post('/api/sessions/:id/cpu-limit', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
const body = parseBody(CpuLimitSchema, req.body, 'Invalid request body') as Partial<NiceConfig>;
@@ -796,7 +858,10 @@ export function registerSystemRoutes(
// ========== Workflow Run Monitoring (ultracode) ==========
// LEFT-pane list: lightweight run summaries (no agents[]).
app.get('/api/workflows', async (req) => {
app.get('/api/workflows', async (req, reply) => {
// Multi-user stopgap: these aggregates are process-wide (no owner concept), so
// restrict cross-user reads to admins (no-op allow-all in single-user mode).
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const { minutes } = req.query as { minutes?: string };
const runs = minutes
? workflowRunWatcher.getRecentRunSummaries(parseInt(minutes, 10))
@@ -805,7 +870,9 @@ export function registerSystemRoutes(
});
// RIGHT-pane detail: full run incl. agents[] (tokens/toolCalls/state per agent).
app.get('/api/workflows/:runId', async (req) => {
app.get('/api/workflows/:runId', async (req, reply) => {
// Multi-user stopgap: cross-user run detail is admin-only (no-op in single-user).
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const { runId } = req.params as { runId: string };
const run = workflowRunWatcher.getRun(runId);
if (!run) {
@@ -816,7 +883,10 @@ export function registerSystemRoutes(
// ========== Subagent Monitoring ==========
app.get('/api/subagents', async (req) => {
app.get('/api/subagents', async (req, reply) => {
// Multi-user stopgap: the global subagent list spans all users → admin-only
// (no-op allow-all in single-user mode). Per-session variant below stays scoped.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const { minutes } = req.query as { minutes?: string };
const subagents = minutes
? subagentWatcher.getRecentSubagents(parseInt(minutes, 10))
@@ -826,12 +896,14 @@ export function registerSystemRoutes(
app.get('/api/sessions/:id/subagents', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
const session = findSessionOrFail(ctx, id, req);
const subagents = subagentWatcher.getSubagentsForSession(session.workingDir);
return { success: true, data: subagents };
});
app.get('/api/subagents/:agentId', async (req) => {
app.get('/api/subagents/:agentId', async (req, reply) => {
// Multi-user stopgap: cross-user subagent metadata is admin-only (no-op single-user).
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const { agentId } = req.params as { agentId: string };
const info = subagentWatcher.getSubagent(agentId);
if (!info) {
@@ -840,7 +912,10 @@ export function registerSystemRoutes(
return { success: true, data: info };
});
app.get('/api/subagents/:agentId/transcript', async (req) => {
app.get('/api/subagents/:agentId/transcript', async (req, reply) => {
// Multi-user stopgap: transcript CONTENT of any user's subagent is admin-only
// (no-op allow-all in single-user mode).
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const { agentId } = req.params as { agentId: string };
const { limit, format } = req.query as { limit?: string; format?: 'raw' | 'formatted' };
const limitNum = limit ? parseInt(limit, 10) : undefined;
@@ -854,7 +929,10 @@ export function registerSystemRoutes(
return { success: true, data: transcript };
});
app.delete('/api/subagents/:agentId', async (req) => {
app.delete('/api/subagents/:agentId', async (req, reply) => {
// Multi-user stopgap: killing any user's subagent is a cross-user write → admin-only
// (no-op allow-all in single-user mode).
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const { agentId } = req.params as { agentId: string };
const info = subagentWatcher.getSubagent(agentId);
if (!info) {
@@ -868,12 +946,16 @@ export function registerSystemRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Subagent not found or already completed');
});
app.post('/api/subagents/cleanup', async () => {
app.post('/api/subagents/cleanup', async (req, reply) => {
// Multi-user stopgap: process-wide cleanup affects every user → admin-only (no-op single-user).
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const removed = subagentWatcher.cleanupNow();
return { success: true, data: { removed, remaining: subagentWatcher.getSubagents().length } };
});
app.delete('/api/subagents', async () => {
app.delete('/api/subagents', async (req, reply) => {
// Multi-user stopgap: clearing ALL users' subagents is a cross-user write → admin-only (no-op single-user).
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const cleared = subagentWatcher.clearAll();
return { success: true, data: { cleared } };
});
+12
View File
@@ -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).
+204 -10
View File
@@ -135,6 +135,8 @@ import { SseEvent } from './sse-events.js';
import { getLatestPlanUsage } from './plan-usage-latest.js';
import type { ScheduledRun } from './ports/index.js';
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
import { isMultiUserMode } from '../config/multiuser.js';
import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
import {
@@ -155,6 +157,8 @@ import {
registerSearchRoutes,
registerOrchestratorRoutes,
registerCronRoutes,
registerMeRoutes,
registerAdminRoutes,
registerWsRoutes,
} from './routes/index.js';
import { CronService } from '../cron/cron-service.js';
@@ -279,6 +283,7 @@ export class WebServer extends EventEmitter {
private authFailures: StaleExpirationMap<string, number> | null = null;
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
private userFailures: StaleExpirationMap<string, number> | null = null;
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
@@ -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<ScheduledRun> {
private async startScheduledRun(
prompt: string,
workingDir: string,
durationMinutes: number,
owner?: string
): Promise<ScheduledRun> {
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<string, unknown>, username: string): Record<string, unknown> {
const ownedIds = new Set<string>();
const ownedClaudeIds = new Set<string>();
for (const [id, s] of this.sessions) {
if (s.owner === username) {
ownedIds.add(id);
if (s.claudeSessionId) ownedClaudeIds.add(s.claudeSessionId);
}
}
const sessions = Array.isArray(base.sessions)
? (base.sessions as Array<{ owner?: string }>).filter((s) => s.owner === username)
: base.sessions;
const respawnStatus: Record<string, unknown> = {};
for (const [id, v] of Object.entries((base.respawnStatus as Record<string, unknown>) ?? {})) {
if (ownedIds.has(id)) respawnStatus[id] = v;
}
const bySession = (arr: unknown, key: 'sessionId' | 'sessionUuid') =>
Array.isArray(arr)
? (arr as Array<Record<string, unknown>>).filter((x) => ownedClaudeIds.has(String(x[key])))
: arr;
const filtered: Record<string, unknown> = {
...base,
sessions,
respawnStatus,
scheduledRuns: [], // legacy ScheduledRun has no owner yet → admin-only
subagents: bySession(base.subagents, 'sessionId'),
workflowRuns: bySession(base.workflowRuns, 'sessionUuid'),
planUsage: null, // host-plan telemetry is admin-only
};
// #29: globalStats is a machine-wide aggregate (all users' tokens/cost + active
// count) with no per-user attribution — never expose it to a non-admin. The
// header falls back to per-active-session totals when it is absent.
delete filtered.globalStats;
return filtered;
}
private computeLightState() {
const now = Date.now();
if (this.cachedLightState && now - this.cachedLightState.timestamp < WebServer.LIGHT_STATE_CACHE_TTL_MS) {
return this.cachedLightState.data;
@@ -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<void> {
// Multi-user first boot: create the initial admin from CODEMAN_USERNAME/PASSWORD
// if there are no users yet, else refuse to start (there would be no way in).
if (isMultiUserMode() && !this.testMode) {
const boot = await bootstrapInitialAdmin();
if (boot.status === 'missing-env') {
throw new Error(
'Multi-user mode is enabled but users.json has no users. Create the first admin with ' +
'`codeman users add <name> --admin` (or set CODEMAN_USERNAME/CODEMAN_PASSWORD for one-time bootstrap).'
);
}
if (boot.status === 'created') {
console.log(
`✓ Multi-user: bootstrapped initial admin "${boot.username}" from CODEMAN_USERNAME/CODEMAN_PASSWORD`
);
}
console.log('✓ Multi-user mode active (per-user accounts in users.json; CODEMAN_PASSWORD is ignored for login)');
}
await this.setupRoutes();
const lifecycleLog = getLifecycleLog();
@@ -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();
+9
View File
@@ -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;
+70 -4
View File
@@ -17,6 +17,7 @@
import type { FastifyReply } from 'fastify';
import type { BackgroundTask } from '../session.js';
import type { AuthUser } from '../types.js';
import { CleanupManager, StaleExpirationMap } from '../utils/index.js';
import { SseEvent } from './sse-events.js';
import {
@@ -38,6 +39,26 @@ const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
interface SseStreamManagerDeps {
/** Get session state with respawn info for session:updated broadcasts */
getSessionStateWithRespawn(sessionId: string): unknown;
/** Resolve a session's owner (multi-user) for SSE routing; undefined = unknown. */
resolveSessionOwner?(sessionId: string): string | undefined;
}
/**
* Optional per-broadcast routing hint (multi-user). Resolved by WebServer.broadcast
* before delegation. When absent, an event is delivered to all clients (global).
*/
export interface SseRoutingHint {
/** Deliver only to this session's owner (+ admins). */
owner?: string;
/** Deliver only to admins (machine-level events: docker builds, tunnel, update). */
adminOnly?: boolean;
/** Deliver only to this exact user (+ admins). */
username?: string;
/**
* The event is session-scoped but the owner could not be resolved — non-admins
* are starved (fail closed) rather than leaked to.
*/
sessionScoped?: boolean;
}
export class SseStreamManager {
@@ -50,6 +71,8 @@ export class SseStreamManager {
private sseClients: Map<FastifyReply, Set<string> | null> = new Map();
/** Optional client-supplied IDs → reply, for live filter updates without reconnecting */
private sseClientsById: Map<string, FastifyReply> = new Map();
/** Per-client identity (multi-user); absent for single-user clients → no filtering. */
private sseClientIdentity: Map<FastifyReply, AuthUser> = new Map();
/** SSE clients connecting from non-localhost (i.e. through tunnel) */
private remoteSseClients: Set<FastifyReply> = new Set();
/** Clients with backpressure — skip writes until 'drain' fires */
@@ -105,8 +128,15 @@ export class SseStreamManager {
this._isTunnelActive = active;
}
addClient(reply: FastifyReply, sessionFilter: Set<string> | null, isRemote: boolean, clientId?: string): void {
addClient(
reply: FastifyReply,
sessionFilter: Set<string> | null,
isRemote: boolean,
clientId?: string,
identity?: AuthUser
): void {
this.sseClients.set(reply, sessionFilter);
if (identity) this.sseClientIdentity.set(reply, identity);
if (isRemote) {
this.remoteSseClients.add(reply);
}
@@ -117,6 +147,7 @@ export class SseStreamManager {
this.sseClients.delete(prev);
this.remoteSseClients.delete(prev);
this.backpressuredClients.delete(prev);
this.sseClientIdentity.delete(prev);
}
this.sseClientsById.set(clientId, reply);
}
@@ -126,12 +157,31 @@ export class SseStreamManager {
this.sseClients.delete(reply);
this.remoteSseClients.delete(reply);
this.backpressuredClients.delete(reply);
this.sseClientIdentity.delete(reply);
// Clear any clientId mappings pointing at this reply
for (const [id, r] of this.sseClientsById) {
if (r === reply) this.sseClientsById.delete(id);
}
}
/**
* Whether an SSE event carrying `hint` may be delivered to `reply`. Clients with
* no identity (single-user) always receive everything. Admins receive everything.
* A non-admin receives an event only when the hint targets them (owner/username)
* or the event is unrouted/global; session-scoped events with an unresolved owner
* are withheld (fail closed).
*/
private canDeliver(reply: FastifyReply, hint?: SseRoutingHint): boolean {
const identity = this.sseClientIdentity.get(reply);
if (!identity || identity.role === 'admin') return true;
if (!hint) return true;
if (hint.adminOnly) return false;
if (hint.username !== undefined) return hint.username === identity.username;
if (hint.owner !== undefined) return hint.owner === identity.username;
if (hint.sessionScoped) return false; // session-scoped but owner unknown → fail closed
return true;
}
/**
* Update an existing client's session subscription filter without forcing
* an SSE reconnect. Returns true if the client was found and updated.
@@ -197,7 +247,7 @@ export class SseStreamManager {
// ========== Broadcasting ==========
broadcast(event: string, data: unknown): void {
broadcast(event: string, data: unknown, hint?: SseRoutingHint): void {
// Skip serialization entirely when no clients are listening
if (this.sseClients.size === 0) return;
@@ -224,6 +274,8 @@ export class SseStreamManager {
// active session's terminal output. Terminal events bypass this method
// entirely (see flushSessionTerminalBatch — it applies the filter).
for (const [client] of this.sseClients) {
// Multi-user ownership routing (no-op for identity-less single-user clients).
if (!this.canDeliver(client, hint)) continue;
this.sendSSEPreformatted(client, message);
}
}
@@ -314,9 +366,15 @@ export class SseStreamManager {
// terminal data is high-frequency and latency-sensitive.
const padding = this._isTunnelActive ? SSE_PADDING : '';
const message = `event: session:terminal\ndata: {"id":"${sessionId}","data":${escapedData}}\n\n` + padding;
// Raw terminal bytes are the highest-value payload: resolve the session owner
// ONCE and withhold the batch from any non-admin who is not the owner (fail
// closed if the owner is unknown). No-op for identity-less single-user clients.
const owner = this.deps.resolveSessionOwner?.(sessionId);
const termHint: SseRoutingHint = { owner, sessionScoped: true };
for (const [client, filter] of this.sseClients) {
// Skip clients that have a session filter and aren't subscribed to this session
if (filter && !filter.has(sessionId)) continue;
if (!this.canDeliver(client, termHint)) continue;
this.sendSSEPreformatted(client, message);
}
}
@@ -355,7 +413,11 @@ export class SseStreamManager {
return;
}
for (const [, { sessionId, task }] of this.taskUpdateBatches) {
this.broadcast(SseEvent.TaskUpdated, { sessionId, task });
// Multi-user: batched task updates carry session state — route to the owner
// only (fail closed if unknown), matching flushSessionTerminalBatch. No-op for
// identity-less single-user clients (canDeliver short-circuits on no identity).
const owner = this.deps.resolveSessionOwner?.(sessionId);
this.broadcast(SseEvent.TaskUpdated, { sessionId, task }, { owner, sessionScoped: true });
}
this.taskUpdateBatches.clear();
}
@@ -395,7 +457,11 @@ export class SseStreamManager {
// Single expensive serialization per batch interval
const state = this.deps.getSessionStateWithRespawn(sessionId);
if (state) {
this.broadcast(SseEvent.SessionUpdated, state);
// Multi-user: the debounced session:updated blob carries name/workingDir/
// tokens/cost — route to the session owner only (fail closed if unknown),
// matching flushSessionTerminalBatch. No-op for single-user clients.
const owner = this.deps.resolveSessionOwner?.(sessionId);
this.broadcast(SseEvent.SessionUpdated, state, { owner, sessionScoped: true });
}
}
this.stateUpdatePending.clear();
+147
View File
@@ -0,0 +1,147 @@
/**
* @fileoverview Phase 5 admin API tests (live server, port 3173).
*
* Covers the admin user-management endpoints: multi-user gate, requireAdmin,
* create (one-time password), patch + last-admin invariant, reset-password,
* disable-revokes-sessions, and delete (last-admin refusal + delete-space).
*/
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { createUser, invalidateUsersCache } from '../src/user-store.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const PORT = 3173;
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
const url = (p: string) => `http://localhost:${PORT}${p}`;
const admin = { Authorization: basic('root', 'rootpass123'), 'Content-Type': 'application/json' };
const adminNoBody = { Authorization: basic('root', 'rootpass123') };
const regular = { Authorization: basic('joe', 'joepass1234'), 'Content-Type': 'application/json' };
let server: WebServer;
let dataDir: string;
let spacesDir: string;
const saved: Record<string, string | undefined> = {};
beforeAll(async () => {
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'admin-data-'));
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'admin-spaces-'));
for (const k of [
'CODEMAN_DATA_DIR',
'CODEMAN_USER_SPACES_DIR',
'CODEMAN_MULTIUSER',
'CODEMAN_PASSWORD',
'CODEMAN_USERNAME',
]) {
saved[k] = process.env[k];
}
process.env.CODEMAN_DATA_DIR = dataDir;
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
process.env.CODEMAN_MULTIUSER = '1';
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
invalidateUsersCache();
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
await createUser({ username: 'joe', role: 'user', password: 'joepass1234' });
server = new WebServer(PORT, false, true);
await server.start();
});
afterAll(async () => {
await server?.stop();
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
invalidateUsersCache();
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
});
describe('admin API', () => {
it('rejects a non-admin (403)', async () => {
const res = await fetch(url('/api/admin/users'), { headers: regular });
expect(res.status).toBe(403);
});
it('lists users for an admin', async () => {
const res = await fetch(url('/api/admin/users'), { headers: admin });
expect(res.status).toBe(200);
const { data } = await res.json();
expect(data.map((u: { username: string }) => u.username).sort()).toEqual(['joe', 'root']);
expect(data[0]).not.toHaveProperty('password');
});
it('creates a user with a one-time password', async () => {
const res = await fetch(url('/api/admin/users'), {
method: 'POST',
headers: admin,
body: JSON.stringify({ username: 'newbie', role: 'user' }),
});
expect(res.status).toBe(200);
const { data } = await res.json();
expect(data.oneTimePassword).toBeTypeOf('string');
expect(data.user).toMatchObject({ username: 'newbie', mustChangePassword: true });
});
it('toggles canBypassPermissions via PATCH', async () => {
const res = await fetch(url('/api/admin/users/joe'), {
method: 'PATCH',
headers: admin,
body: JSON.stringify({ canBypassPermissions: true }),
});
expect(res.status).toBe(200);
expect((await res.json()).data.user.canBypassPermissions).toBe(true);
});
it('refuses to demote the last admin (409)', async () => {
const res = await fetch(url('/api/admin/users/root'), {
method: 'PATCH',
headers: admin,
body: JSON.stringify({ role: 'user' }),
});
expect(res.status).toBe(409);
expect((await res.json()).errorCode).toBe('LAST_ADMIN');
});
it('resets a password (one-time) and forces change', async () => {
const res = await fetch(url('/api/admin/users/joe/reset-password'), { method: 'POST', headers: adminNoBody });
expect(res.status).toBe(200);
const { data } = await res.json();
expect(data.oneTimePassword).toBeTypeOf('string');
// joe must now change password before other actions.
const gated = await fetch(url('/api/status'), { headers: { Authorization: basic('joe', data.oneTimePassword) } });
expect(gated.status).toBe(403);
expect((await gated.json()).errorCode).toBe('PASSWORD_CHANGE_REQUIRED');
});
it('refuses to delete the last admin, deletes a regular user + space', async () => {
const del = await fetch(url('/api/admin/users/root'), { method: 'DELETE', headers: adminNoBody });
expect(del.status).toBe(409);
await fs.mkdir(path.join(spacesDir, 'newbie', 'cases'), { recursive: true });
const del2 = await fetch(url('/api/admin/users/newbie'), {
method: 'DELETE',
headers: admin,
body: JSON.stringify({ deleteSpace: true }),
});
expect(del2.status).toBe(200);
await expect(fs.stat(path.join(spacesDir, 'newbie'))).rejects.toBeTruthy();
});
it('404s admin routes in single-user mode', async () => {
// Flip the flag off for one request path check.
process.env.CODEMAN_MULTIUSER = '';
try {
const res = await fetch(url('/api/admin/users'), { headers: admin });
expect(res.status).toBe(404);
} finally {
process.env.CODEMAN_MULTIUSER = '1';
}
});
});
+83
View File
@@ -0,0 +1,83 @@
/**
* @fileoverview Frontend test for admin-ui.js (multi-user identity boot + admin
* Users tab + change-password modal). Builds a JSDOM window in-test under the
* default node env (constructing the DOM in-test avoids the vitest environment
* comment-directive gotcha) and evaluates the real module against it.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const ADMIN_UI = readFileSync(new URL('../src/web/public/admin-ui.js', import.meta.url), 'utf-8');
const INDEX_HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
function resp(status: number, body: unknown) {
const r = {
status,
ok: status >= 200 && status < 300,
json: async () => body,
clone() {
return r;
},
};
return r;
}
async function bootWith(me: Record<string, unknown>) {
const dom = new JSDOM(
`<!doctype html><body>
<div class="modal" id="appSettingsModal"><div class="modal-tabs"></div><div class="modal-body"></div></div>
</body>`,
{ url: 'http://localhost/', runScripts: 'outside-only' }
);
const win = dom.window as unknown as Window & typeof globalThis & { __codemanUser?: Record<string, unknown> };
win.fetch = (async (path: string) => {
if (path === '/api/me') return resp(200, { success: true, data: me });
if (path === '/api/admin/users') return resp(200, { success: true, data: [] });
return resp(200, { success: true });
}) as unknown as typeof fetch;
(win as unknown as { eval: (s: string) => void }).eval(ADMIN_UI);
// Let the async boot() (fetch /api/me → DOM inject) settle.
for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0));
return { dom, win };
}
describe('admin-ui boot', () => {
it('exposes the identity and injects the Users tab for a multi-user admin', async () => {
const { win } = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
expect(win.__codemanUser).toMatchObject({ username: 'root', role: 'admin', multiUser: true });
const btn = win.document.querySelector('[data-tab="settings-users"]');
expect(btn).toBeTruthy();
expect(win.document.getElementById('settings-users')).toBeTruthy();
});
it('does NOT inject the Users tab for a regular user', async () => {
const { win } = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
});
it('does NOT inject the Users tab in single-user mode', async () => {
const { win } = await bootWith({ username: 'admin', role: 'admin', multiUser: false, mustChangePassword: false });
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
});
it('shows the change-password modal when mustChangePassword is set', async () => {
const { win } = await bootWith({ username: 'dave', role: 'user', multiUser: true, mustChangePassword: true });
const modal = win.document.getElementById('changePasswordModal') as HTMLElement | null;
expect(modal).toBeTruthy();
expect(modal!.style.display).toBe('flex');
// Forced: the cancel button is hidden.
expect((modal!.querySelector('#cpCancel') as HTMLElement).style.display).toBe('none');
});
});
describe('index.html wiring', () => {
it('loads admin-ui.js after settings-ui.js and before session-ui.js', () => {
const settings = INDEX_HTML.indexOf('settings-ui.js');
const admin = INDEX_HTML.indexOf('admin-ui.js');
const session = INDEX_HTML.indexOf('session-ui.js');
expect(admin).toBeGreaterThan(settings);
expect(session).toBeGreaterThan(admin);
});
});
+83
View File
@@ -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');
});
});
+4 -1
View File
@@ -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 () => {
+207
View File
@@ -0,0 +1,207 @@
/**
* @fileoverview Phase 2 multi-user auth integration tests (live server, port 3170+).
*
* Verifies the multi-user auth branch end to end: per-user Basic verify, cookie
* identity, wrong-password / disabled-user rejection, the mustChangePassword
* lockbox + self-service change, per-account rate limiting, and QR identity binding
* (tunnel-manager unit level). Single-user auth is covered by auth-security.test.ts.
*
* Ports: 3170 (multi-user server), 3171 (rate-limit server).
*/
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { TunnelManager } from '../src/tunnel-manager.js';
import { createUser, invalidateUsersCache } from '../src/user-store.js';
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const PORT = 3170;
const RATE_PORT = 3171;
function basic(user: string, pass: string): string {
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
}
function cookieFrom(res: Response): string | null {
const raw = res.headers.get('set-cookie');
const m = raw?.match(/codeman_session=([^;]+)/);
return m ? `codeman_session=${m[1]}` : null;
}
let server: WebServer;
let rateServer: WebServer;
let dataDir: string;
let spacesDir: string;
const saved: Record<string, string | undefined> = {};
beforeAll(async () => {
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mu-auth-data-'));
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mu-auth-spaces-'));
for (const k of [
'CODEMAN_DATA_DIR',
'CODEMAN_USER_SPACES_DIR',
'CODEMAN_MULTIUSER',
'CODEMAN_PASSWORD',
'CODEMAN_USERNAME',
]) {
saved[k] = process.env[k];
}
process.env.CODEMAN_DATA_DIR = dataDir;
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
process.env.CODEMAN_MULTIUSER = '1';
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
invalidateUsersCache();
await createUser({ username: 'alice', role: 'admin', password: 'alicepass1' });
await createUser({ username: 'bob', role: 'user', password: 'bobpass123' });
await createUser({ username: 'carol', role: 'user', password: 'carolpass1' });
await createUser({ username: 'carol', role: 'user', password: 'x' }).catch(() => {}); // no-op dup guard
await createUser({ username: 'dave', role: 'user', password: 'davepass12', mustChangePassword: true });
// Disable carol after creation.
const { updateUser } = await import('../src/user-store.js');
await updateUser('carol', { disabled: true });
server = new WebServer(PORT, false, true);
await server.start();
});
afterAll(async () => {
await server?.stop();
await rateServer?.stop().catch(() => {});
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
invalidateUsersCache();
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
});
const url = (p: string) => `http://localhost:${PORT}${p}`;
describe('multi-user auth', () => {
it('rejects unauthenticated requests', async () => {
const res = await fetch(url('/api/status'));
expect(res.status).toBe(401);
});
it('authenticates a valid user and issues an identity cookie', async () => {
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('alice', 'alicepass1') } });
expect(res.status).toBe(200);
const cookie = cookieFrom(res);
expect(cookie).toBeTruthy();
const me = await fetch(url('/api/me'), { headers: { Cookie: cookie! } });
expect(me.status).toBe(200);
const body = await me.json();
expect(body.data).toMatchObject({ username: 'alice', role: 'admin', mustChangePassword: false });
});
it('reports role for a regular user', async () => {
const res = await fetch(url('/api/me'), { headers: { Authorization: basic('bob', 'bobpass123') } });
expect(res.status).toBe(200);
expect((await res.json()).data).toMatchObject({ username: 'bob', role: 'user' });
});
it('rejects a wrong password', async () => {
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('bob', 'wrongwrong') } });
expect(res.status).toBe(401);
});
it('rejects a disabled user even with the correct password', async () => {
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('carol', 'carolpass1') } });
expect(res.status).toBe(401);
});
it('is case-insensitive on the username', async () => {
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('ALICE', 'alicepass1') } });
expect(res.status).toBe(200);
});
it('enforces the mustChangePassword lockbox and clears it on self-service change', async () => {
// Basic auth as dave succeeds (cookie issued) but non-exempt routes 403.
const authed = await fetch(url('/api/status'), { headers: { Authorization: basic('dave', 'davepass12') } });
expect(authed.status).toBe(403);
const body = await authed.json();
expect(body.errorCode).toBe('PASSWORD_CHANGE_REQUIRED');
const cookie = cookieFrom(authed);
expect(cookie).toBeTruthy();
// /api/me is exempt.
const me = await fetch(url('/api/me'), { headers: { Cookie: cookie! } });
expect(me.status).toBe(200);
expect((await me.json()).data.mustChangePassword).toBe(true);
// Wrong current password is refused.
const bad = await fetch(url('/api/me/password'), {
method: 'POST',
headers: { Cookie: cookie!, 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: 'nope', newPassword: 'brandnew123' }),
});
expect(bad.status).toBe(403);
// Correct change clears the flag.
const ok = await fetch(url('/api/me/password'), {
method: 'POST',
headers: { Cookie: cookie!, 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: 'davepass12', newPassword: 'brandnew123' }),
});
expect(ok.status).toBe(200);
// Same cookie now reaches a non-exempt route.
const after = await fetch(url('/api/status'), { headers: { Cookie: cookie! } });
expect(after.status).toBe(200);
});
it('verify-first: a correct password is never rate-limited and self-heals failures (#17)', async () => {
rateServer = new WebServer(RATE_PORT, false, true);
await rateServer.start();
const rurl = (p: string) => `http://localhost:${RATE_PORT}${p}`;
// Nine wrong passwords (one below the cap) are each rejected 401 — not throttled yet.
for (let i = 0; i < AUTH_FAILURE_MAX - 1; i++) {
const res = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', `bad-${i}`) } });
expect(res.status).toBe(401);
}
// Finding #17: the CORRECT password must ALWAYS win (verified BEFORE the per-username
// throttle) — the accumulated failures can never lock the account out — and success
// clears the failure buckets. Previously this returned 429 (the DoS being fixed).
const good = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'bobpass123') } });
expect(good.status).toBe(200);
// Self-heal: a fresh wrong attempt is 401 again (the counter was reset by the success).
const afterReset = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'nope') } });
expect(afterReset.status).toBe(401);
// Sustained wrong passwords ARE still throttled: 429 once the cap is reached.
let limited = false;
for (let i = 0; i < AUTH_FAILURE_MAX + 1 && !limited; i++) {
const res = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', `x-${i}`) } });
limited = res.status === 429;
}
expect(limited).toBe(true);
});
});
describe('QR token identity (tunnel-manager)', () => {
it('binds a minted token to a user and returns it on consume (single-use)', () => {
const tm = new TunnelManager();
const code = tm.mintUserToken('alice');
expect(code).toHaveLength(6);
const first = tm.consumeTokenWithIdentity(code);
expect(first).toEqual({ ok: true, username: 'alice' });
// single-use
expect(tm.consumeTokenWithIdentity(code)).toEqual({ ok: false });
});
it('unknown code is rejected', () => {
const tm = new TunnelManager();
expect(tm.consumeTokenWithIdentity('ZZZZZZ')).toEqual({ ok: false });
});
});
+178
View File
@@ -0,0 +1,178 @@
/**
* @fileoverview Phase 3 ownership-scoping tests (live server, port 3172).
*
* Verifies multi-user isolation at the API level: case lists are disjoint per user,
* a non-admin cannot read/kill another user's session, workingDir confinement +
* shell gate + host-CRUD admin gate are enforced, and admins see everything.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { createUser, invalidateUsersCache } from '../src/user-store.js';
import { canAccessOwned, findSessionOrFail, sessionCapacityState } from '../src/web/route-helpers.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const PORT = 3172;
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
let server: WebServer;
let dataDir: string;
let spacesDir: string;
const saved: Record<string, string | undefined> = {};
const url = (p: string) => `http://localhost:${PORT}${p}`;
// Route returns are wrapped in the {success,data} envelope; unwrap to the payload.
async function getJson(p: string, headers: Record<string, string>): Promise<unknown> {
const body = await (await fetch(url(p), { headers })).json();
return body && typeof body === 'object' && 'data' in body ? (body as { data: unknown }).data : body;
}
const alice = { Authorization: basic('alice', 'alicepass1') };
const bob = { Authorization: basic('bob', 'bobpass1234') };
const admin = { Authorization: basic('root', 'rootpass123') };
beforeAll(async () => {
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'own-data-'));
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'own-spaces-'));
for (const k of [
'CODEMAN_DATA_DIR',
'CODEMAN_USER_SPACES_DIR',
'CODEMAN_MULTIUSER',
'CODEMAN_PASSWORD',
'CODEMAN_USERNAME',
]) {
saved[k] = process.env[k];
}
process.env.CODEMAN_DATA_DIR = dataDir;
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
process.env.CODEMAN_MULTIUSER = '1';
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
invalidateUsersCache();
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
await createUser({ username: 'alice', role: 'user', password: 'alicepass1' });
await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' });
server = new WebServer(PORT, false, true);
await server.start();
});
afterAll(async () => {
await server?.stop();
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
invalidateUsersCache();
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
});
describe('case scoping', () => {
it('creates cases in per-user spaces and lists them disjointly', async () => {
const mk = await fetch(url('/api/cases'), {
method: 'POST',
headers: { ...alice, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'aliceproj' }),
});
expect(mk.status).toBe(200);
// Case folder is under alice's space.
expect(await exists(path.join(spacesDir, 'alice', 'cases', 'aliceproj'))).toBe(true);
const aliceList = (await getJson('/api/cases', alice)) as Array<{ name: string }>;
expect(aliceList.map((c) => c.name)).toContain('aliceproj');
const bobList = (await getJson('/api/cases', bob)) as Array<{ name: string }>;
expect(bobList.map((c) => c.name)).not.toContain('aliceproj');
});
});
describe('host CRUD is admin-only', () => {
it('rejects a non-admin defining a docker host', async () => {
const res = await fetch(url('/api/docker-hosts'), {
method: 'POST',
headers: { ...bob, 'Content-Type': 'application/json' },
body: JSON.stringify({ id: 'h1', label: 'x', image: 'codeman/agent:base' }),
});
expect(res.status).toBe(403);
});
it('allows an admin to list docker hosts', async () => {
const res = await fetch(url('/api/docker-hosts'), { headers: admin });
expect(res.status).toBe(200);
});
});
describe('session creation gates', () => {
it('confines a non-admin workingDir to their space', async () => {
const foreign = path.join(spacesDir, 'alice', 'cases', 'aliceproj');
const res = await fetch(url('/api/sessions'), {
method: 'POST',
headers: { ...bob, 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: foreign }),
});
expect(res.status).toBe(403);
});
it('refuses shell mode for a non-granted user', async () => {
const mine = path.join(spacesDir, 'bob', 'cases');
await fs.mkdir(mine, { recursive: true });
const res = await fetch(url('/api/sessions'), {
method: 'POST',
headers: { ...bob, 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: mine, mode: 'shell' }),
});
expect(res.status).toBe(403);
});
});
// The session-scoping logic (findSessionOrFail owner check, list filter, per-user
// cap) is tested directly against the helpers under the same multi-user env, since
// real session spawning is no-op'd in test mode and does not durably populate the
// live map. These are the exact functions every session route uses.
describe('session-scoping helpers (multi-user)', () => {
const fakeSession = (owner?: string) => ({ owner }) as unknown as import('../src/session.js').Session;
const ctxWith = (map: Map<string, unknown>) => ({ sessions: map }) as never;
const reqAs = (username: string, role: 'admin' | 'user') => ({ authUser: { username, role } }) as never;
it('canAccessOwned isolates non-admins to their own', () => {
expect(canAccessOwned({ username: 'alice', role: 'user' }, 'alice')).toBe(true);
expect(canAccessOwned({ username: 'alice', role: 'user' }, 'bob')).toBe(false);
expect(canAccessOwned({ username: 'alice', role: 'user' }, undefined)).toBe(false);
expect(canAccessOwned({ username: 'root', role: 'admin' }, 'bob')).toBe(true);
});
it('findSessionOrFail 404s a foreign session for a non-admin, returns it for owner/admin', () => {
const map = new Map<string, unknown>([['s1', fakeSession('alice')]]);
expect(() => findSessionOrFail(ctxWith(map), 's1', reqAs('bob', 'user'))).toThrow();
expect(findSessionOrFail(ctxWith(map), 's1', reqAs('alice', 'user'))).toBeDefined();
expect(findSessionOrFail(ctxWith(map), 's1', reqAs('root', 'admin'))).toBeDefined();
});
it('per-user session cap counts only the owner sessions', () => {
const map = new Map<string, unknown>([
['a', fakeSession('alice')],
['b', fakeSession('alice')],
['c', fakeSession('bob')],
]);
process.env.CODEMAN_MAX_SESSIONS_PER_USER = '2';
expect(sessionCapacityState(map as never, 'alice').atUserCap).toBe(true);
expect(sessionCapacityState(map as never, 'bob').atUserCap).toBe(false);
delete process.env.CODEMAN_MAX_SESSIONS_PER_USER;
});
});
async function exists(p: string): Promise<boolean> {
try {
await fs.stat(p);
return true;
} catch {
return false;
}
}
+2 -2
View File
@@ -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);
});
});
+10 -2
View File
@@ -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');
});
});
});
+301
View File
@@ -0,0 +1,301 @@
/**
* @fileoverview Unit tests for the multi-user store (src/user-store.ts).
*
* Pure helpers (hashing/verify/params-upgrade/username validation/6.3 resolvers)
* plus the IO layer against a per-test temp data dir (CODEMAN_DATA_DIR) so nothing
* touches the real ~/.codeman. No server, no tmux.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'node:fs/promises';
import { existsSync, statSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
bootstrapInitialAdmin,
canRunPrivilegedCommands,
countEnabledAdmins,
createUser,
DEFAULT_SCRYPT_PARAMS,
deleteUser,
deleteUserSpace,
findUser,
generateOneTimePassword,
hashPassword,
hasUsers,
invalidateUsersCache,
isValidUsername,
needsRehash,
normalizeUsername,
readUsers,
resolveClaudeModeForUser,
setPassword,
toPublicUser,
touchLastLogin,
updateUser,
UserStoreError,
verifyPasswordHash,
} from '../src/user-store.js';
let tmpDir: string;
let spacesDir: string;
const savedEnv: Record<string, string | undefined> = {};
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-users-'));
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-spaces-'));
for (const k of [
'CODEMAN_DATA_DIR',
'CODEMAN_USER_SPACES_DIR',
'CODEMAN_MULTIUSER',
'CODEMAN_MAX_USERS',
'CODEMAN_USERNAME',
'CODEMAN_PASSWORD',
]) {
savedEnv[k] = process.env[k];
}
process.env.CODEMAN_DATA_DIR = tmpDir;
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
delete process.env.CODEMAN_MAX_USERS;
invalidateUsersCache();
});
afterEach(async () => {
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
invalidateUsersCache();
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
});
describe('username validation', () => {
it('accepts valid slugs', () => {
for (const n of ['alice', 'bob99', 'a1', 'x_y-z', 'user-name_1']) {
expect(isValidUsername(n)).toBe(true);
}
});
it('rejects invalid slugs', () => {
for (const n of [
'',
'a',
'A',
'1',
'_leading',
'-leading',
'has space',
'has.dot',
'a/b',
'..',
'toolongxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
]) {
expect(isValidUsername(n)).toBe(false);
}
});
it('accepts mixed-case input by normalizing (case-insensitive usernames)', () => {
expect(isValidUsername('Alice')).toBe(true);
expect(normalizeUsername(' ALICE ')).toBe('alice');
});
});
describe('password hashing', () => {
it('round-trips a correct password and rejects a wrong one', async () => {
const h = await hashPassword('correct horse');
expect(h.algo).toBe('scrypt');
expect(h.salt).toMatch(/^[0-9a-f]+$/);
expect(await verifyPasswordHash('correct horse', h)).toBe(true);
expect(await verifyPasswordHash('wrong password', h)).toBe(false);
});
it('produces a distinct salt each time', async () => {
const a = await hashPassword('same');
const b = await hashPassword('same');
expect(a.salt).not.toBe(b.salt);
expect(a.hash).not.toBe(b.hash);
});
it('never throws on a malformed record', async () => {
expect(await verifyPasswordHash('x', { algo: 'scrypt', N: 1, r: 1, p: 1, salt: 'zz', hash: '' })).toBe(false);
// @ts-expect-error deliberately malformed
expect(await verifyPasswordHash('x', { algo: 'bogus' })).toBe(false);
});
it('needsRehash detects weaker params', async () => {
const h = await hashPassword('pw', DEFAULT_SCRYPT_PARAMS);
expect(needsRehash(h)).toBe(false);
expect(needsRehash({ ...h, N: 1024 })).toBe(true);
expect(needsRehash({ ...h, algo: 'md5' as unknown as 'scrypt' })).toBe(true);
});
it('generateOneTimePassword returns a >=8 char url-safe string', () => {
const pw = generateOneTimePassword();
expect(pw.length).toBeGreaterThanOrEqual(8);
expect(pw).toMatch(/^[A-Za-z0-9_-]+$/);
});
});
describe('resolveClaudeModeForUser (section 6.3)', () => {
it('admins are unrestricted', () => {
expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'admin' })).toBe(
'dangerously-skip-permissions'
);
});
it('granted regular users keep bypass', () => {
expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'user', canBypassPermissions: true })).toBe(
'dangerously-skip-permissions'
);
});
it('non-granted regular users downgrade skip -> auto', () => {
expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'user' })).toBe('auto');
expect(resolveClaudeModeForUser(undefined, { role: 'user' })).toBe('auto');
});
it('non-granted regular users pass through modes already <= auto', () => {
expect(resolveClaudeModeForUser('auto', { role: 'user' })).toBe('auto');
expect(resolveClaudeModeForUser('normal', { role: 'user' })).toBe('normal');
expect(resolveClaudeModeForUser('allowedTools', { role: 'user' })).toBe('allowedTools');
});
it('canRunPrivilegedCommands follows the same grant', () => {
expect(canRunPrivilegedCommands({ role: 'admin' })).toBe(true);
expect(canRunPrivilegedCommands({ role: 'user', canBypassPermissions: true })).toBe(true);
expect(canRunPrivilegedCommands({ role: 'user' })).toBe(false);
});
});
describe('user store IO', () => {
it('creates, reads back, and writes users.json at mode 0600 atomically', async () => {
expect(await hasUsers()).toBe(false);
const u = await createUser({ username: 'Alice', role: 'admin', password: 'password1' });
expect(u.username).toBe('alice');
expect(u.role).toBe('admin');
expect(await hasUsers()).toBe(true);
const file = path.join(tmpDir, 'users.json');
expect(existsSync(file)).toBe(true);
// 0600 on POSIX
if (process.platform !== 'win32') {
expect(statSync(file).mode & 0o777).toBe(0o600);
}
// no leftover tmp file
expect(existsSync(file + '.tmp')).toBe(false);
const found = await findUser('ALICE');
expect(found?.username).toBe('alice');
expect(toPublicUser(found!)).not.toHaveProperty('password');
});
it('rejects duplicate usernames case-insensitively', async () => {
await createUser({ username: 'bob', role: 'user', password: 'password1' });
await expect(createUser({ username: 'BOB', role: 'user', password: 'password2' })).rejects.toMatchObject({
code: 'USER_EXISTS',
});
});
it('rejects invalid username and short password', async () => {
await expect(createUser({ username: 'Bad Name', role: 'user', password: 'password1' })).rejects.toBeInstanceOf(
UserStoreError
);
await expect(createUser({ username: 'good', role: 'user', password: 'short' })).rejects.toMatchObject({
code: 'INVALID_INPUT',
});
});
it('enforces MAX_USERS', async () => {
process.env.CODEMAN_MAX_USERS = '2';
await createUser({ username: 'a1', role: 'admin', password: 'password1' });
await createUser({ username: 'a2', role: 'user', password: 'password1' });
await expect(createUser({ username: 'a3', role: 'user', password: 'password1' })).rejects.toMatchObject({
code: 'INVALID_INPUT',
});
});
it('setPassword changes the hash and can clear mustChangePassword', async () => {
await createUser({ username: 'carol', role: 'user', password: 'password1', mustChangePassword: true });
const before = await findUser('carol');
expect(before?.mustChangePassword).toBe(true);
await setPassword('carol', 'password2', { mustChangePassword: false });
const after = await findUser('carol');
expect(after?.mustChangePassword).toBe(false);
expect(await verifyPasswordHash('password2', after!.password)).toBe(true);
expect(await verifyPasswordHash('password1', after!.password)).toBe(false);
});
it('touchLastLogin records a timestamp', async () => {
await createUser({ username: 'dave', role: 'user', password: 'password1' });
expect((await findUser('dave'))?.lastLoginAt).toBeUndefined();
await touchLastLogin('dave');
expect((await findUser('dave'))?.lastLoginAt).toBeTypeOf('number');
});
});
describe('last-admin invariants', () => {
it('cannot demote the last enabled admin', async () => {
await createUser({ username: 'root', role: 'admin', password: 'password1' });
await createUser({ username: 'joe', role: 'user', password: 'password1' });
expect(countEnabledAdmins(await readUsers(true))).toBe(1);
await expect(updateUser('root', { role: 'user' })).rejects.toMatchObject({ code: 'LAST_ADMIN' });
await expect(updateUser('root', { disabled: true })).rejects.toMatchObject({ code: 'LAST_ADMIN' });
});
it('cannot delete the last enabled admin', async () => {
await createUser({ username: 'root', role: 'admin', password: 'password1' });
await expect(deleteUser('root')).rejects.toMatchObject({ code: 'LAST_ADMIN' });
});
it('allows demote/delete when another admin remains', async () => {
await createUser({ username: 'root', role: 'admin', password: 'password1' });
await createUser({ username: 'root2', role: 'admin', password: 'password1' });
await expect(updateUser('root', { role: 'user' })).resolves.toMatchObject({ role: 'user' });
await createUser({ username: 'root3', role: 'admin', password: 'password1' });
await expect(deleteUser('root2')).resolves.toBeUndefined();
});
it('updateUser toggles canBypassPermissions', async () => {
await createUser({ username: 'grantee', role: 'user', password: 'password1' });
const updated = await updateUser('grantee', { canBypassPermissions: true });
expect(updated.canBypassPermissions).toBe(true);
});
});
describe('deleteUserSpace guards (section 8)', () => {
it('deletes a real space dir inside USER_SPACES_DIR', async () => {
const dir = path.join(spacesDir, 'ed', 'cases', 'proj');
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(spacesDir, 'ed', 'cases', 'proj', 'f.txt'), 'x');
expect(existsSync(path.join(spacesDir, 'ed'))).toBe(true);
await deleteUserSpace('ed');
expect(existsSync(path.join(spacesDir, 'ed'))).toBe(false);
});
it('is a no-op when the space does not exist', async () => {
await expect(deleteUserSpace('ghost')).resolves.toBeUndefined();
});
it('refuses to delete a symlinked user space', async () => {
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-outside-'));
await fs.symlink(outside, path.join(spacesDir, 'evil'));
await expect(deleteUserSpace('evil')).rejects.toMatchObject({ code: 'INVALID_INPUT' });
// the symlink target still exists (was not followed + removed)
expect(existsSync(outside)).toBe(true);
await fs.rm(outside, { recursive: true, force: true });
});
});
describe('bootstrapInitialAdmin', () => {
it('creates the initial admin from env when no users exist', async () => {
process.env.CODEMAN_MULTIUSER = '1';
process.env.CODEMAN_USERNAME = 'boss';
process.env.CODEMAN_PASSWORD = 'password1';
const r = await bootstrapInitialAdmin();
expect(r).toMatchObject({ status: 'created', username: 'boss' });
expect((await findUser('boss'))?.role).toBe('admin');
});
it('reports missing-env when no users and no credentials', async () => {
delete process.env.CODEMAN_USERNAME;
delete process.env.CODEMAN_PASSWORD;
expect(await bootstrapInitialAdmin()).toMatchObject({ status: 'missing-env' });
});
it('reports exists when users already present', async () => {
await createUser({ username: 'someone', role: 'admin', password: 'password1' });
expect(await bootstrapInitialAdmin()).toMatchObject({ status: 'exists' });
});
});