The tunnel Upload URL page (upload.html) has been broken since the response envelope landed in 458fb81c: it reads j.filename and j.files while the server answers { success, data: { filename } } and { success, data: { files } }, so every upload reported "Saved: undefined" and the recent list stayed empty. Nothing else reads ~/.codeman/screenshots/, and handing a file to an agent goes through POST /api/sessions/:id/paste-image into the session's own workspace, so the page, its Settings row, the suffix branch of the tunnel row helper (now folded into its one caller) and the Upload URL i18n key go.
The three /api/screenshots routes keep working unchanged and log one deprecation warning per process on first use, naming paste-image as the replacement. Per docs/versioning-policy.md they are removed in a later MAJOR, after at least one MINOR release that carries the warning; the docs, CLAUDE.md and the multi-user plan say so.
Static caching: with upload.html gone no HTML is served by @fastify/static any more (every page has its own no-cache route; on a built tree only the precompressed index.html.gz artifact is reachable, as application/gzip, and nothing requests it). The .html branch of setHeaders was therefore dead and goes with the test that fetched upload.html to reach it; a comment now says a new static HTML page needs its own route. The index.html no-cache assertion on the route stays.
47 KiB
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,--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.
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(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.
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 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.
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)
{
"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. 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).
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): 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.
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:
- 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).
6. Ownership Threading (the big refactor)
6.1 Sessions
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.
6.2 Cases
- 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.
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:
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.
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 (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/<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.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.
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/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.
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 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.
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 and the audit log 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
- 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.
15. Open Questions (answer before Phase 3)
- 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.