diff --git a/CLAUDE.md b/CLAUDE.md index b12ff6e7..3e448c97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -239,7 +239,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside sessions, **NOT a `SessionMode`**, and is proxied through Codeman's own origin (`/webview//...`). ⚠️ The proxy is NOT an API surface: its capability-based auth exemption stays fenced to safe methods and non-route paths (`test/webview-auth-exemption.test.ts`). ⚠️ Iframes omit `allow-same-origin` unless `trusted`, and `Authorization`/`codeman_session` are stripped upstream in both modes. ⚠️ **Egress guard**: link-local and cloud-metadata targets are refused at save time, by a sync hostname check at each connect (IP literals skip DNS), AND on the resolved address (`webview-egress.ts`); use the `undici` package's own `fetch` + `Agent`, never Node's global fetch. ⚠️ Loopback links in agent output auto-open as proxied web tabs (`openLinkThroughWebTabIfLoopback`), but never auto-route `*.localhost` (prompt-injectable DNS). Capabilities are revoked on logout (`revokeOwner`). → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md` -**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. ⚠️ `settings.json` is ONE server-wide file, so every write to it is admin-only (`PUT /api/settings`, `/api/execution/model-config`, `/api/cases/order`, plus `PUT /api/config`: 403 `FORBIDDEN` before parsing), a non-admin's quick-start/Ralph start skips `lastUsedCase`, and the browser skips those writes for a non-admin via `_settingsReadOnly()` (settings-ui.js; `test/routes/system-routes-settings-admin-only.test.ts`). → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md` +**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. ⚠️ `settings.json` is ONE server-wide file, so every write to it is admin-only (`PUT /api/settings`, `/api/execution/model-config`, `/api/cases/order`, plus `PUT /api/config`: 403 `FORBIDDEN` before parsing), a non-admin's quick-start/Ralph start skips `lastUsedCase`, its GET hides `voiceSettings.apiKey`/`caseOrder`/`lastUsedCase` from a non-admin (`settingsForNonAdmin`), and the browser skips those writes for a non-admin via `_settingsReadOnly()` (settings-ui.js; also true once a write came back 403 `FORBIDDEN`, since `/api/me` can be slow or fail; their run mode and respawn presets stay per-device, `_applyOwnSettings`) (`test/routes/system-routes-settings-admin-only.test.ts`). System ops are admin-only too (`/api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate`, `/api/cleanup-state`), and a non-admin's `/api/auth/revoke` revokes only their own logins (`test/routes/system-routes-system-ops-admin-only.test.ts`). → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md` **Away digest**: `GET /api/away-digest` aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in `web/away-digest.ts`. ⚠️ Returns `{success:true,digest}`, a legacy raw-ish shape consistent with the other raw GET handlers in `system-routes.ts`; frontend and tests read `.digest`. → [architecture-invariants#away-digest](docs/architecture-invariants.md#away-digest) diff --git a/docs/api-reference.md b/docs/api-reference.md index b23aab68..1cbb925a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -78,10 +78,15 @@ the HTTP status. |-------------|------|---------| | `INVALID_INPUT` | 400 | Malformed request / failed validation | | `UNAUTHORIZED` | 401 | Authentication required or failed | +| `FORBIDDEN` | 403 | Signed in, but not allowed (e.g. a non-admin calling an admin-only route in multi-user mode) | +| `PASSWORD_CHANGE_REQUIRED` | 403 | Multi-user: the user must change their password before anything else | | `NOT_FOUND` | 404 | Resource does not exist | +| `USER_NOT_FOUND` | 404 | Multi-user: no user with that name | | `SESSION_BUSY` | 409 | Session is busy | | `CONFLICT` | 409 | Conflicts with current state (e.g. already running) | | `ALREADY_EXISTS` | 409 | Resource already exists | +| `USER_EXISTS` | 409 | Multi-user: a user with that name already exists | +| `LAST_ADMIN` | 409 | Multi-user: refusing to demote, disable or delete the last enabled admin | | `OPERATION_FAILED` | 422 | Well-formed but could not be completed | | `RATE_LIMITED` | 429 | Too many requests | | `INTERNAL_ERROR` | 500 | Unexpected server error | @@ -1081,18 +1086,31 @@ A local case whose folder is the top of a git repository can keep **worktrees** ## Settings -`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user. **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. +`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. | Method | Path | Body | Notes | | ------ | ----------------------------- | ------------------------------------ | ----- | -| `GET` | `/api/settings` | none | The stored settings object. Never writes. | +| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. | | `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable without `CODEMAN_PASSWORD` is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`). | | `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). | | `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. | | `PUT` | `/api/cases/order` | `{ order: string[] }` | The one case order every user's `GET /api/cases` is sorted by. `403 FORBIDDEN` for a non-admin in multi-user mode. | | `PUT` | `/api/config` | `{ pollIntervalMs?, defaultTimeoutMs?, maxConcurrentSessions?, respawn? }` (strict) | The process-wide app config in `state.json`. `403 FORBIDDEN` for a non-admin in multi-user mode. | -A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (every user can read it). +A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key). + +### System operations + +The same multi-user rule covers the operations that act on the whole server. For a non-admin: + +| Method | Path | Multi-user non-admin | +| ------ | ----------------------------- | -------------------- | +| `POST` | `/api/system/update` | `403 FORBIDDEN` (the update restarts the server under every user) | +| `POST` | `/api/system/span-displays` | `403 FORBIDDEN` (opens a window on the server's desktop) | +| `POST` | `/api/tunnel/qr/regenerate` | `403 FORBIDDEN` (it clears every user's outstanding QR login code) | +| `POST` | `/api/cleanup-state` | `403 FORBIDDEN` (drops every user's stale session records) | +| `POST` | `/api/auth/revoke` | Revokes only the caller's own login sessions: the bare form (`{}`) signs them out everywhere, and a `sessionToken` is revoked only when it is theirs. For an admin, and in single-user mode, the bare form still revokes every login session | +| `GET` | `/api/tunnel/info` | `authSessions` lists only the caller's own logins | ## CLI management diff --git a/docs/multi-user-plan.md b/docs/multi-user-plan.md index 16ea25e3..c8ae1cdc 100644 --- a/docs/multi-user-plan.md +++ b/docs/multi-user-plan.md @@ -161,8 +161,8 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab | Screenshots `/api/screenshots` (deprecated) | Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots//` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat | | Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins | | File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs | -| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). **Implemented (1.42.0):** `PUT /api/settings`, `PUT /api/execution/model-config`, `PUT /api/config` and `PUT /api/cases/order` answer 403 `FORBIDDEN` to a non-admin before parsing anything; quick-start and Ralph loop start skip a non-admin's `lastUsedCase`; the browser skips those writes for a non-admin (`_settingsReadOnly()`, settings-ui.js). Per-user server settings: out of scope v1| -| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only | +| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). **Implemented (1.42.0):** `PUT /api/settings`, `PUT /api/execution/model-config`, `PUT /api/config` and `PUT /api/cases/order` answer 403 `FORBIDDEN` to a non-admin before parsing anything; quick-start and Ralph loop start skip a non-admin's `lastUsedCase`; the browser skips those writes for a non-admin (`_settingsReadOnly()`, settings-ui.js) and keeps their own run mode and respawn presets on the device; a non-admin's `GET /api/settings` omits `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase` (`settingsForNonAdmin`). Per-user server settings: out of scope v1| +| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only. **Implemented (1.42.0):** `POST /api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate` and `/api/cleanup-state` answer 403 `FORBIDDEN` to a non-admin; a non-admin's `POST /api/auth/revoke` revokes only their own login sessions and `GET /api/tunnel/info` lists only those | | `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 diff --git a/docs/wiki/Multi-User-Mode.md b/docs/wiki/Multi-User-Mode.md index d9e62c67..65d66a10 100644 --- a/docs/wiki/Multi-User-Mode.md +++ b/docs/wiki/Multi-User-Mode.md @@ -67,6 +67,8 @@ switches, the run mode, respawn presets, the case order, the Cloudflare tunnel, models. Every user can **read** them, but only an **admin** can change them. For a regular user the settings write routes answer `403 FORBIDDEN` (see the [HTTP API](HTTP-API) and [`docs/api-reference.md`](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)). +A regular user reads them without what is the admin's: the Deepgram key an admin saved for +voice dictation, and the admin's case names (case order, last used case). In the browser this means: @@ -74,10 +76,17 @@ In the browser this means: header buttons, keyboard bar and the rest of the per-device keys) on that device. If they also changed a server-wide setting, one notice says it can only be changed by an admin, and the setting keeps the server's value. -- A regular user's run-mode pick and respawn presets are kept in that browser and not sent - to the server, and their last used case is not recorded server-wide. What an admin set - still loads on the next visit. +- A regular user's run-mode pick and respawn presets are their own: kept in that browser + under their name, not sent to the server, and still theirs on the next visit (over what + an admin set). Their last used case is not recorded server-wide. - Reordering cases and switching the tunnel on or off are admin actions. +- If the page could not tell yet who is signed in (a slow or failed sign-in check), the + server's refusal is read the same way: the same notice, and nothing more is sent. + +The operations that act on the whole server are admin actions too: updating Codeman, +opening the multi-display window, regenerating the tunnel QR code (it clears every user's +pending QR login) and cleaning up stale session state. **Revoke All** signs a regular user +out of their own logins only, and the tunnel panel lists only their own. Per-user server settings are not part of this version.