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,--multiuserflag, 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,mustChangePasswordlockbox,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.ownerat every create path + recovery mirror;findSessionOrFailowner check + list filtering; §6.3 permission policy (resolveClaudeModeForUserat all spawn sites incl. one-shots viabuildPromptArgs; shell/launchCommand grant); per-user case spaces (resolveCasesDir) + owner-scoped case list + admin-only host CRUD;workingDirconfinement;sessionCapacityStateper-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);getLightStateper-identity filtering; file-route preview/thumbnail/history +GET /api/searchscoping. - 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.
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(orCODEMAN_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-casesfor 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.
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 tocat /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'slaunchCommandruns 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 theauto-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
autopermission mode (section 6.3), whose safety classifier blocks destructive actions and credential exfiltration. That reduces, but does not eliminate, cross-user snooping; thecanBypassPermissionsgrant 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.
| 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().
- Username rules:
^[a-z0-9][a-z0-9_-]{1,31}$(it becomes a folder name), stored lowercase, unique case-insensitively. Reserveadmin? No: any name can be admin; role is a field, not a name. - Hashing:
scryptfromnode:cryptowith per-user salt, compared viatimingSafeEqual. 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 theremote-hosts.ts/docker-hosts.tspattern):readUsers(),writeUsers(),verifyPassword(),createUser(),setPassword(),deleteUser(), plus pure helpers (isValidUsername,hashPassword) that are unit-testable without IO. In-process cache with short TTL likereadSettings, invalidated on every write; the short TTL also covers the CLI (section 10) editingusers.jsonwhile the server runs (cross-process changes picked up within the TTL).
~/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): stringsingle-user mode: returnsCASES_DIR(today's~/codeman-cases); multi-user: returnsjoin(USER_SPACES_DIR, user.username, 'cases'), creating it lazily on first use. CASES_DIRstays 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) besidecases/. - Legacy
~/codeman-casesin multi-user mode: surfaces to admins only, as a read-only "Unassigned (legacy)" group in the case list, with an admin actionPOST /api/admin/cases/assign { case, username }thatfs.renames the folder into a user's space (same-filesystem move, cheap). No automatic migration.
Keep the existing single-user branch untouched. Add a parallel multi-user branch selected once at registration time:
- Credential check: Basic header parsed into
username:password, verified against the user store (scrypt +timingSafeEqual). Disabled users fail closed. - Cookie sessions: same
codeman_sessioncookie andStaleExpirationMap, butAuthSessionRecordgainsusernameandrole. 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. - Request identity: decorate
req.authUser = { username, role }(Fastify decorateRequest). In single-user modereq.authUseris{ username: 'admin', role: 'admin' }when auth is on, and a synthetic admin when auth is off, so downstream code has ONE code path. - Rate limiting: keep the per-IP bucket; add a per-username failure bucket (same
StaleExpirationMappattern) so a botnet cannot brute-force one account across IPs, and one flaky user behind a NAT cannot lock out the rest. mustChangePasswordgate: when set, every API request exceptGET /api/me,POST /api/me/password, and static assets returns 403 witherrorCode: 'PASSWORD_CHANGE_REQUIRED'; the frontend intercepts that code and shows the change-password modal.- 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).
- 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.
- WS upgrade identity (
ws-routes.ts): the global authonRequesthook does run on the upgrade request (@fastify/websocketv11 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 decoratedreq.authUserand 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. - QR auth (
/q/:coderedemption insystem-routes.ts, minting intunnel-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 authenticatedPOST /api/tunnel/qrmints a single-use, short-TTL token bound toreq.authUser.username(field onQrTokenRecord); redemption creates a cookie session for that user. Existing rate-limit buckets (qrAuthFailures, globalQR_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).
Sessiongainsowner?: string(constructor option), persisted inSessionState.owner, included intoState(), round-tripped through recovery (mux-sessions.jsonentries carry it,restoreMuxSessionspasses it back, exactly likeremote/docker).- Every session-creating path stamps the owner from
req.authUser. Verified inventory ofnew Session(...)call sites:POST /api/sessions(session-routes.ts:444),POST /api/quick-start(:1956),POST /api/runone-shot (:1652), Ralph start (ralph-routes.ts:327), cron (cron-service.ts:352;CronJobgainsowner, stamped at job create, launched as the job's owner), legacyScheduledRunloop (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, soownersurvives 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:
ownermust ALSO be mirrored onMuxSession(mux-sessions.json) and read back mux-first likeremote/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 infindSessionOrFail(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 BOTHsessionManager.getSessionAND direct map access (ctx.sessions.get(/.has(): ws-routes and hook-event-routes reach sessions that way and bypassfindSessionOrFail. - Admins see everything; every session row carries
ownerso the UI can badge it.
- All
CASES_DIRcall sites switch toresolveCasesDir(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), andplan-routes.ts:231(easy to miss). Case-name-to-path resolution is currently DUPLICATED (resolveCasePathin 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.jsonare arrays of objects, so entries simply gainowner?: string(absent = legacy: admin-only).linked-cases.jsonis a flatRecord<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-hostsand 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 arbitrarydocker runargs 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/sessionsaccepts ANY host directory asworkingDir(the only check isstatSync().isDirectory(), session-routes.ts:305-318), and file-routes/attachments confine reads tosession.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'sworkingDirmust realpath-resolve inside their own space, enforced atPOST /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.
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:
autoonly. A non-admin's Claude sessions are forced to--permission-mode autoregardless of the globalclaudeModesetting.normalandallowedToolsare also permitted (they are strictly more restrictive than auto), butdangerously-skip-permissionsis NOT. - Bypass is an explicit admin grant:
canBypassPermissions: trueon the user record (defaultfalse, 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)inuser-store.ts, applied server-side at option-resolution time, BEFORE the Session constructor, so both downstream arg builders inherit it for free (buildPermissionArgsin session-cli-builder.ts for the direct-PTY path ANDbuildClaudePermissionFlagsin tmux-manager.ts for tmux panes; there are two builders, not one). Call sites wheregetClaudeModeConfig()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
autosilently (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 yoloare refused for non-granted users (Gemini falls back toauto_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 cronlaunchCommandare 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 403FORBIDDENon shell session/quick-start creation and on cron jobs carryinglaunchCommand(checked at create AND at fire time). Folding them undercanBypassPermissionskeeps 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.
| 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 |
/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.authUserand store{ username, role }with the client. Concretely: extendaddClient(reply, sessionFilter, isRemote, clientId)to take the identity and change thesseClientsmap value to{ filter, identity }(or add a parallelMap<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;usernametargets one user;sessionIdresolves owner via SessionManager;adminOnlyfor machine-level events (docker image builds, tunnel, self-update); no hint = broadcast to all (connection status etc.).- Enforce the identity check in BOTH
broadcast()ANDflushSessionTerminalBatch(): the terminal batch path does not go throughbroadcast(), and it carries the highest-value payload (raw terminal bytes). - Sweep of the ~120 backend event constants in
sse-events.ts: mechanically, everythingsession:*,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/subscribecompose with (never override) the ownership filter: the subscription filter can only narrow within what the identity allows.
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).
GET /api/meon boot (app.js init): storeswindow.__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
owneron foreign sessions; regular users see no change. - New module
admin-ui.jsif the settings-ui.js addition gets large (load order after settings-ui, before session-ui), else keep inside settings-ui.js. Follow the@fileoverview+@loadorderconvention either way.
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".
- New
src/config/multiuser.ts:isMultiUserMode(),USER_SPACES_DIR(~/codeman-users, overridable viaCODEMAN_USER_SPACES_DIRfor tests),MAX_USERS(default 25), per-user session cap (default: global cap / 2, envCODEMAN_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 oneassertSessionCapacity(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.
| 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) |
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.
- Not a security boundary at the agent layer (section 2). Decided: ship with loud documentation; Docker cases are the isolation story.
findSessionOrFailas the single enforcement point for ~30 session routes: any route that fetches sessions another way must be audited in Phase 3 (grep forsessionManager.getSessionoutside route-helpers).- 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.
- Basic-auth password-change UX is mediocre (browser re-prompt). Accepted for v1; Phase 6 fixes it properly.
- Legacy case migration is manual (admin assigns). No silent moves of user data.
- 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. workingDirconfinement (6.2) is the single most load-bearing rule: every file-serving and agent-spawning surface downstream trustssession.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).- 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.
- 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. - Per-user settings (respawn presets, notification prefs): global-only in v1. Worth a
users/<name>/settings.jsonoverlay later? - Should regular users be allowed to create Docker cases on admin-defined hosts (proposed: yes) or is Docker entirely admin-only?
- Session handoff: does an admin need "reassign session/case to another user"? (Cheap to add next to
cases/assign; not in v1 scope.) - Permission-mode grants (section 6.3): one
canBypassPermissionsflag covering Claude/Codex/Gemini bypass equivalents PLUS shell mode and cronlaunchCommand(proposed: one flag, keep it one-bit), or split intocanBypassPermissions+canRunArbitraryCommands? And should admins be able to set a per-user DEFAULT mode (for example forcenormalfor an intern) rather than just gating bypass? - 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.
{ "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, }, ], }