diff --git a/.changeset/max-sessions-env.md b/.changeset/max-sessions-env.md index ff2516d6..8c28fb8b 100644 --- a/.changeset/max-sessions-env.md +++ b/.changeset/max-sessions-env.md @@ -2,4 +2,4 @@ 'aicodeman': minor --- -The cap of 50 concurrent sessions can now be set with `CODEMAN_MAX_SESSIONS` (a positive integer, read at startup), for a machine with the memory for more sessions or a small one that should run fewer. Without it nothing changes. A value that is not a positive integer falls back to 50, so a typo never blocks session creation. In multi-user mode the per-user cap still defaults to half the global cap, so it follows the new value unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. +The cap of 50 concurrent sessions can now be set with `CODEMAN_MAX_SESSIONS` (1 to 500, read at startup; a larger value is clamped to 500), for a machine with the memory for more sessions or a small one that should run fewer. Without it nothing changes. A value that is not a positive integer falls back to 50, so a typo never blocks session creation. In multi-user mode the per-user cap still defaults to half the global cap, so it follows the new value unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a50539c8..7db9b5d0 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1124,7 +1124,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### Buffers, uploads, and terminal history -Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. +Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50 (env `CODEMAN_MAX_SESSIONS`, 1 to 500), max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. **Prompt uploads live in `/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. ⚠️ And because a live session's workspace can go onto a network mount that later stops answering, the route probes `session.workingDir` BOUNDED (`probePathKind(..., { pastCap: true })`) before the first disk touch, refusing an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED) exactly as `POST /api/sessions` does, so a mount already unreachable cannot freeze a libuv worker on every upload (`ensureUploadDir()`'s own lstat/mkdir then start on a reachable path; a mount that dies between the probe and that write is the same residual race `POST /api/sessions` accepts, since a pre-check cannot bound the write that follows it). Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`, `test/routes/session-create-unreachable-path.test.ts`. diff --git a/docs/cron-guide.md b/docs/cron-guide.md index bc08c52d..b5c37fc3 100644 --- a/docs/cron-guide.md +++ b/docs/cron-guide.md @@ -376,7 +376,7 @@ boot. Sessions the jobs create persist through the normal session-recovery path. | Readiness settle | 2000ms | `CRON_READY_SETTLE_MS` | | Run-history cap (global) | 500 | `MAX_CRON_RUN_HISTORY` (`config/map-limits.ts`) | | Saved-jobs cap | 100 | `MAX_CRON_JOBS` (`config/map-limits.ts`) | -| Concurrent-session cap | 50 | `MAX_CONCURRENT_SESSIONS` | +| Concurrent-session cap | 50 | `MAX_CONCURRENT_SESSIONS` (env `CODEMAN_MAX_SESSIONS`) | | Prompt-file size cap | 1 MiB | `MAX_PROMPT_FILE_BYTES` (`cron-service.ts`) | | `name` length | 1–200 | `CronJobSchema` | | `promptText` length | ≤ 100000 | `CronJobSchema` | diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 2e7238ce..52bc7416 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -221,6 +221,7 @@ Some things are configured before the server starts, not in the UI: | `CODEMAN_FILE_PICKER_ROOTS` | Extra roots for the path picker. | | `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. | | `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). | +| `CODEMAN_MAX_SESSIONS` | Cap on concurrent sessions. 50 by default, 1 to 500 (a larger value is clamped to 500, anything that is not a positive integer falls back to 50). Read at startup. In multi-user mode the per-user cap defaults to half of it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. | | `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. | | `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image or document. 50 MB by default. | | `CODEMAN_MAX_PASTE_VIDEO_BYTES` | Cap on one attached video (phone Attach, drop). 1 GB by default; videos stream to disk, so this bounds disk use, not memory. | diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index 2799f542..5324e571 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -113,7 +113,7 @@ Every JSON response: `{"success":true,"data":…}` or | `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope, see [Auth and credentials](#auth-and-credentials) | | `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) | | `NOT_FOUND` | 404 | no such session, or one this caller does not own. Also quick-start's answer for an unknown remote or docker host | -| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | +| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global cap, 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 500), and in multi-user mode the per-user cap, which defaults to half the global cap, **25** by default (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | | `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state | | `OPERATION_FAILED` | 422 | well-formed but could not be completed | | `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full; back off, switching sessions will not help | @@ -367,7 +367,7 @@ name is in front" rather than "nothing is installed". `shell` has no CLI to prob is absent, `jq -r` prints the literal string `null`, and every later call then targets `/api/v1/sessions/null`, burning the full readiness budget and reporting jq noise instead of the real cause. The failure codes here are `SESSION_BUSY` (a **session** cap: -the global 50, or the per-user 25 in multi-user mode, never the waiter cap), +the global cap (50 by default), or the per-user cap (25 by default) in multi-user mode, never the waiter cap), `NOT_FOUND` (an unknown remote host or docker host named by the case), `FORBIDDEN`, `CONFLICT`, `OPERATION_FAILED` and `INVALID_INPUT`. None of them are retryable in a loop. @@ -401,7 +401,7 @@ differences that break copied code: `POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or `POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker. - Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's - `SESSION_BUSY` (409), from the same global-50 / per-user-25 caps + `SESSION_BUSY` (409), from the same global / per-user caps (50 / 25 by default) (`sessionCapacityMessage()` in `route-helpers.ts`). ⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit @@ -796,8 +796,8 @@ response echoes. | `match` string | 1–200 characters, literal only | 400; `regex=` is rejected outright | | `from=buffer` scan window | 256 KB tail of the terminal buffer | a marker older than that tail is invisible even with `from=buffer` | | wait-output snippet context | 80 characters either side | `wait.snippet` is bounded, not the whole line | -| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`) | 409 `SESSION_BUSY` on quick-start | -| sessions, per user | 25 in multi-user mode (half the global cap) | the same 409, with a different message | +| sessions, process-wide | 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 500) | 409 `SESSION_BUSY` on quick-start | +| sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message | | SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab | | active bash tools tracked | 20 per session | oldest entries drop off `active-tools` | | auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential | diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 2799f542..5324e571 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -113,7 +113,7 @@ Every JSON response: `{"success":true,"data":…}` or | `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope, see [Auth and credentials](#auth-and-credentials) | | `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) | | `NOT_FOUND` | 404 | no such session, or one this caller does not own. Also quick-start's answer for an unknown remote or docker host | -| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | +| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global cap, 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 500), and in multi-user mode the per-user cap, which defaults to half the global cap, **25** by default (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | | `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state | | `OPERATION_FAILED` | 422 | well-formed but could not be completed | | `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full; back off, switching sessions will not help | @@ -367,7 +367,7 @@ name is in front" rather than "nothing is installed". `shell` has no CLI to prob is absent, `jq -r` prints the literal string `null`, and every later call then targets `/api/v1/sessions/null`, burning the full readiness budget and reporting jq noise instead of the real cause. The failure codes here are `SESSION_BUSY` (a **session** cap: -the global 50, or the per-user 25 in multi-user mode, never the waiter cap), +the global cap (50 by default), or the per-user cap (25 by default) in multi-user mode, never the waiter cap), `NOT_FOUND` (an unknown remote host or docker host named by the case), `FORBIDDEN`, `CONFLICT`, `OPERATION_FAILED` and `INVALID_INPUT`. None of them are retryable in a loop. @@ -401,7 +401,7 @@ differences that break copied code: `POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or `POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker. - Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's - `SESSION_BUSY` (409), from the same global-50 / per-user-25 caps + `SESSION_BUSY` (409), from the same global / per-user caps (50 / 25 by default) (`sessionCapacityMessage()` in `route-helpers.ts`). ⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit @@ -796,8 +796,8 @@ response echoes. | `match` string | 1–200 characters, literal only | 400; `regex=` is rejected outright | | `from=buffer` scan window | 256 KB tail of the terminal buffer | a marker older than that tail is invisible even with `from=buffer` | | wait-output snippet context | 80 characters either side | `wait.snippet` is bounded, not the whole line | -| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`) | 409 `SESSION_BUSY` on quick-start | -| sessions, per user | 25 in multi-user mode (half the global cap) | the same 409, with a different message | +| sessions, process-wide | 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 500) | 409 `SESSION_BUSY` on quick-start | +| sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message | | SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab | | active bash tools tracked | 20 per session | oldest entries drop off `active-tools` | | auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential | diff --git a/src/config/map-limits.ts b/src/config/map-limits.ts index 0d27a2f6..3007060f 100644 --- a/src/config/map-limits.ts +++ b/src/config/map-limits.ts @@ -22,21 +22,31 @@ const DEFAULT_MAX_CONCURRENT_SESSIONS = 50; /** - * Resolve the session cap from `CODEMAN_MAX_SESSIONS` (a positive integer). - * Anything else (unset, empty, 0, negative, fractional, not a number) falls back - * to {@link DEFAULT_MAX_CONCURRENT_SESSIONS}, so a typo never locks session creation. + * Highest value `CODEMAN_MAX_SESSIONS` can raise the cap to. Other bounds assume + * the session count stays small: an owner's tab layout holds at most + * `MAX_TAB_REFS` (512) refs and `PUT /api/session-order` accepts at most 500 ids, + * so a larger cap would let session creation fail with a 500 instead of the + * capacity message. */ -function resolveMaxConcurrentSessions(env: NodeJS.ProcessEnv = process.env): number { - const raw = env.CODEMAN_MAX_SESSIONS?.trim(); +export const MAX_CONCURRENT_SESSIONS_CEILING = 500; + +/** + * Resolve the session cap from `CODEMAN_MAX_SESSIONS` (a positive integer, + * clamped to {@link MAX_CONCURRENT_SESSIONS_CEILING}). Anything else (unset, + * empty, 0, negative, fractional, not a number) falls back to + * {@link DEFAULT_MAX_CONCURRENT_SESSIONS}, so a typo never locks session creation. + */ +function resolveMaxConcurrentSessions(): number { + const raw = process.env.CODEMAN_MAX_SESSIONS?.trim(); if (!raw || !/^\d+$/.test(raw)) return DEFAULT_MAX_CONCURRENT_SESSIONS; const n = Number(raw); - return Number.isSafeInteger(n) && n >= 1 ? n : DEFAULT_MAX_CONCURRENT_SESSIONS; + return n >= 1 ? Math.min(n, MAX_CONCURRENT_SESSIONS_CEILING) : DEFAULT_MAX_CONCURRENT_SESSIONS; } /** * Maximum concurrent sessions allowed. * Each session consumes significant resources (PTY, buffers, watchers). - * 50 unless `CODEMAN_MAX_SESSIONS` says otherwise; read once at startup. + * 50 unless `CODEMAN_MAX_SESSIONS` says otherwise (1 to 500); read once at startup. */ export const MAX_CONCURRENT_SESSIONS = resolveMaxConcurrentSessions(); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 196e14c3..7d7b5630 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1981,7 +1981,8 @@ export const CaseOrderSchema = z.object({ /** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */ export const SessionOrderUpdateSchema = z.object({ // Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only - // its open-tab order (max sessions is 50 by default) — 500 leaves ample headroom while + // its open-tab order (max sessions is 50 by default, at most 500 via CODEMAN_MAX_SESSIONS, + // see MAX_CONCURRENT_SESSIONS_CEILING) — 500 leaves enough room while // keeping a hostile/buggy client from persisting megabytes into state.json. order: z.array(z.string().max(100)).max(500), }); diff --git a/test/config/max-sessions.test.ts b/test/config/max-sessions.test.ts index 58f46e26..5d689ddd 100644 --- a/test/config/max-sessions.test.ts +++ b/test/config/max-sessions.test.ts @@ -18,10 +18,10 @@ function sessionsOf(n: number, owner?: string): ReadonlyMap { /** Fresh module graph, so map-limits re-reads the environment at import. */ async function loadWithEnv(env: Record): Promise { vi.resetModules(); - vi.stubEnv('CODEMAN_MAX_SESSIONS', undefined as unknown as string); - vi.stubEnv('CODEMAN_MAX_SESSIONS_PER_USER', undefined as unknown as string); - vi.stubEnv('CODEMAN_MULTIUSER', undefined as unknown as string); - for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value as string); + vi.stubEnv('CODEMAN_MAX_SESSIONS', undefined); + vi.stubEnv('CODEMAN_MAX_SESSIONS_PER_USER', undefined); + vi.stubEnv('CODEMAN_MULTIUSER', undefined); + for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value); return import('../../src/web/route-helpers.js'); } @@ -52,14 +52,18 @@ describe('global session cap (CODEMAN_MAX_SESSIONS)', () => { expect(sessionCapacityState(sessionsOf(3)).atGlobalCap).toBe(true); }); - it.each(['', '0', '-5', '2.5', 'abc', '1e3', '99999999999999999999'])( - 'falls back to 50 for an unusable value %j', - async (value) => { - const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value }); - expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false); - expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true); - } - ); + it.each(['1000', '501', '99999999999999999999'])('clamps %j to the ceiling of 500', async (value) => { + const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value }); + expect(sessionCapacityState(sessionsOf(499)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(500)).atGlobalCap).toBe(true); + expect(sessionCapacityMessage(sessionsOf(500))).toContain('(500)'); + }); + + it.each(['', '0', '-5', '2.5', 'abc', '1e3'])('falls back to 50 for an unusable value %j', async (value) => { + const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value }); + expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true); + }); it('derives the multi-user per-user cap (half the global cap) from it', async () => { const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({