diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index e1fb64cc..17080b1e 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -15,6 +15,8 @@ import { SseEvent } from '../web/sse-events.js'; import { CronJobSchema } from '../web/schemas.js'; import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.js'; import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js'; +import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js'; +import { sessionCapacityState } from '../web/route-helpers.js'; import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js'; import { DEFAULT_BLOCKED_TREES, @@ -108,7 +110,7 @@ export class CronService { // ──────────────────────────── Mutations ─────────────────────────── - createJob(input: CronJobInput): CronJob { + createJob(input: CronJobInput, owner?: string): CronJob { if (Object.keys(this.store.getCronJobs()).length >= MAX_CRON_JOBS) { throw this.badRequest(`Maximum number of cron jobs (${MAX_CRON_JOBS}) reached`); } @@ -117,6 +119,7 @@ export class CronService { const job: CronJob = { id: uuidv4(), name: input.name, + owner, agentType: input.agentType, workingDir: input.workingDir, launchCommand: input.launchCommand, @@ -336,10 +339,23 @@ export class CronService { await this.closePreviousRunSessions(job, run.id); } - // Respect the global session cap. - if (this.deps.sessions.size >= MAX_CONCURRENT_SESSIONS) { + // Respect the global cap AND the owner's per-user cap (multi-user). + const cap = sessionCapacityState(this.deps.sessions, job.owner); + if (cap.atGlobalCap) { return this.failRun(job, run, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`); } + if (cap.atUserCap) { + return this.failRun(job, run, `Owner's per-user session limit reached`); + } + + // Section 6.3: shell mode / launchCommand are arbitrary host-account execution. + // Re-check the owner's grant at FIRE time (it may have been revoked since create). + if (job.agentType === 'shell' || job.launchCommand) { + const allowed = await canUsernameRunPrivilegedCommands(job.owner); + if (!allowed) { + return this.failRun(job, run, 'Owner lacks the can-bypass-permissions grant for shell/launchCommand jobs'); + } + } // Create + start the session (mirrors the quick-start route flow). let session: Session; @@ -348,6 +364,7 @@ export class CronService { const globalNice = await this.deps.getGlobalNiceConfig(); const modelConfig = await this.deps.getModelConfig(); const claudeModeConfig = await this.deps.getClaudeModeConfig(); + const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner); const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined; session = new Session({ workingDir: job.workingDir, @@ -357,8 +374,9 @@ export class CronService { useMux: true, niceConfig: globalNice, model, - claudeMode: claudeModeConfig.claudeMode, + claudeMode: effectiveClaudeMode, allowedTools: claudeModeConfig.allowedTools, + owner: job.owner, }); this.deps.addSession(session); this.store.incrementSessionsCreated(); diff --git a/src/mux-interface.ts b/src/mux-interface.ts index c8de3dc0..d6e58d4e 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -114,6 +114,8 @@ export interface RespawnPaneOptions { remote?: SessionRemote; /** Docker execution metadata for local tmux sessions wrapping `docker exec` */ docker?: SessionDocker; + /** Owning username (multi-user); redundant on respawn since the Session object survives, kept for shape parity. */ + owner?: string; } /** Options for pane buffer capture (COD-47 full-history mode). */ diff --git a/src/session-cli-builder.ts b/src/session-cli-builder.ts index 546ce557..1e970c45 100644 --- a/src/session-cli-builder.ts +++ b/src/session-cli-builder.ts @@ -82,8 +82,16 @@ export function buildInteractiveArgs( * @param model - Optional model override * @returns Array of CLI arguments */ -export function buildPromptArgs(prompt: string, model?: string): string[] { - const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json']; +export function buildPromptArgs( + prompt: string, + model?: string, + claudeMode: ClaudeMode = 'dangerously-skip-permissions', + allowedTools?: string +): string[] { + // Respect the session's permission mode instead of always skipping, so a + // multi-user non-granted user's one-shot runs classifier-guarded (auto) rather + // than with full bypass. Defaults to skip-permissions (unchanged single-user). + const args = ['-p', '--verbose', ...buildPermissionArgs(claudeMode, allowedTools), '--output-format', 'stream-json']; if (model) { args.push('--model', model); } diff --git a/src/session.ts b/src/session.ts index ef762781..aadb1bad 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1413,6 +1413,7 @@ export class Session extends EventEmitter { historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, + owner: this._owner, }, createSessionOptions: { sessionId: this.id, @@ -1432,6 +1433,7 @@ export class Session extends EventEmitter { historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, + owner: this._owner, }, spawnErrLabel: 'mux attachment', }); @@ -1803,6 +1805,7 @@ export class Session extends EventEmitter { historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, + owner: this._owner, }, createSessionOptions: { sessionId: this.id, @@ -1814,6 +1817,7 @@ export class Session extends EventEmitter { historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, + owner: this._owner, }, spawnErrLabel: 'shell mux attachment', }); @@ -1941,7 +1945,7 @@ export class Session extends EventEmitter { model ? `(model: ${model})` : '' ); - const args = buildPromptArgs(prompt, model); + const args = buildPromptArgs(prompt, model, this._claudeMode, this._allowedTools); try { this.ptyProcess = pty.spawn('claude', args, { diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 8f8bd982..904ace6c 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -1489,6 +1489,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, + owner, } = options; const muxName = `codeman-${sessionId.slice(0, 8)}`; @@ -1509,6 +1510,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { workingDir, remote, docker, + owner, mode, attached: false, name, @@ -1685,6 +1687,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { workingDir, remote, docker, + owner, mode, attached: false, name, diff --git a/src/types/cron.ts b/src/types/cron.ts index 05bfdf13..9c05b8c4 100644 --- a/src/types/cron.ts +++ b/src/types/cron.ts @@ -36,6 +36,8 @@ export type ConcurrencyPolicy = 'warn_only' | 'skip_if_same_agent_running'; export interface CronJob { id: string; name: string; + /** Owning username in multi-user mode; the job launches as this user. Undefined in single-user. */ + owner?: string; /** Reuses Codeman's existing session modes; 'shell' covers Terminal/custom. */ agentType: SessionMode; workingDir: string; diff --git a/src/types/session.ts b/src/types/session.ts index 3c943ea3..3e50941d 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -85,6 +85,8 @@ export interface RemoteHost extends RemoteSshOptions { export interface RemoteCase { name: string; type: 'remote'; + /** Owning username in multi-user mode; absent = legacy/unassigned (admin-only). */ + owner?: string; hostId: string; remotePath: string; } @@ -174,6 +176,8 @@ export interface DockerHost { export interface DockerCase { name: string; type: 'docker'; + /** Owning username in multi-user mode; absent = legacy/unassigned (admin-only). */ + owner?: string; hostId: string; /** Absolute HOST directory: the bind-mount source AND Session.workingDir (real host bytes). */ hostWorkspacePath: string; diff --git a/src/user-store.ts b/src/user-store.ts index cca566e6..c26586fe 100644 --- a/src/user-store.ts +++ b/src/user-store.ts @@ -409,6 +409,17 @@ export async function deleteUserSpace(username: string): Promise { /** The synthetic admin used in single-user mode so downstream has one code path. */ export const SYNTHETIC_ADMIN: AuthUser = { username: 'admin', role: 'admin' }; +/** + * Whether a username may run arbitrary commands (shell mode, cron launchCommand, + * other CLIs' bypass). Single-user or an unset owner: allowed. In multi-user a + * MISSING user (e.g. deleted) fails closed (non-privileged). Used at cron fire time. + */ +export async function canUsernameRunPrivilegedCommands(username: string | undefined): Promise { + if (!isMultiUserMode() || !username) return true; + const user = await findUser(username); + return canRunPrivilegedCommands(user ?? { role: 'user' }); +} + /** * Resolve the effective Claude mode for a username by looking up the grant. In * single-user mode (or for an unknown owner) the global mode passes through. diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index 922ffd8b..0459473f 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -6,13 +6,14 @@ */ import { join, resolve, relative, isAbsolute } from 'node:path'; -import { realpathSync } from 'node:fs'; +import { realpathSync, existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import { homedir } from 'node:os'; import type { z } from 'zod'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { Session } from '../session.js'; import { ApiErrorCode, createErrorResponse, type AuthUser } from '../types.js'; +import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; import { SseEvent } from './sse-events.js'; import type { SessionPort } from './ports/session-port.js'; @@ -20,7 +21,7 @@ import type { EventPort } from './ports/event-port.js'; import type { AuthSessionRecord } from './ports/auth-port.js'; import type { StaleExpirationMap } from '../utils/index.js'; import { dataPath } from '../config/instance.js'; -import { isMultiUserMode } from '../config/multiuser.js'; +import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js'; import { SYNTHETIC_ADMIN } from '../user-store.js'; // Shared path constants used across route modules. CASES_DIR (project folders) @@ -104,17 +105,118 @@ export function canAccessOwned(user: AuthUser, owner: string | undefined): boole return !!owner && owner === user.username; } +/** + * The owner to stamp on a resource created by this request: the requesting user in + * multi-user mode, or undefined in single-user (so state stays owner-free and the + * flag can be removed later without leaving stray owners). + */ +export function ownerFor(req: FastifyRequest): string | undefined { + return isMultiUserMode() ? getAuthUser(req).username : undefined; +} + +/** + * The cases directory for a request/user: the shared ~/codeman-cases in single-user + * mode, or the per-user ~/codeman-users//cases in multi-user (created + * lazily). Admins are NOT auto-scoped here — an admin acting on a specific user's + * case resolves through the owner-aware case resolver instead. + */ +export function resolveCasesDir(user?: AuthUser): string { + if (!isMultiUserMode() || !user) return CASES_DIR; + const dir = userCasesDir(user.username); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + return dir; +} + +/** + * Realpath-confine a non-admin's requested working directory to their own case + * space in multi-user mode. Returns true if allowed. Admins and single-user mode + * are unrestricted. The path need not exist yet (checked against its nearest + * existing ancestor) so newly-created case dirs pass. This is the load-bearing + * rule (plan 6.2/14.7): every file-serving surface downstream trusts workingDir. + */ +export function isWorkingDirAllowed(user: AuthUser, workingDir: string): boolean { + if (!isMultiUserMode() || user.role === 'admin') return true; + const base = userCasesDir(user.username); + // Resolve the deepest existing ancestor to defeat symlink escapes without + // requiring the leaf to exist yet. + const resolveExisting = (p: string): string => { + let cur = resolve(p); + // walk up until an existing path is found + for (;;) { + try { + return realpathSync(cur); + } catch { + const parent = resolve(cur, '..'); + if (parent === cur) return cur; + cur = parent; + } + } + }; + let realBase: string; + try { + realBase = realpathSync(base); + } catch { + // base does not exist yet — create it so confinement has a stable anchor + mkdirSync(base, { recursive: true }); + realBase = realpathSync(base); + } + const realTarget = resolveExisting(workingDir); + if (realTarget === realBase) return true; + const rel = relative(realBase, realTarget); + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); +} + +/** Whether the caller is an admin (or single-user mode, where the sole user is admin). */ +export function isAdmin(req: FastifyRequest): boolean { + return !isMultiUserMode() || getAuthUser(req).role === 'admin'; +} + /** * First line of admin-only handlers: 403 FORBIDDEN + returns false when the caller * is not an admin. Always true in single-user mode (the sole user is the admin). */ export function requireAdmin(req: FastifyRequest, reply: FastifyReply): boolean { - if (!isMultiUserMode()) return true; - if (getAuthUser(req).role === 'admin') return true; + if (isAdmin(req)) return true; reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN)); return false; } +/** + * Session-capacity check, centralized so the global cap AND the per-user cap are + * enforced everywhere a session is created (the check was copy-pasted at 6 sites). + * Pure: takes the sessions Map so it composes with ctx.sessions / this.sessions / + * this.deps.sessions callers. Per-user cap only applies in multi-user mode. + */ +export function sessionCapacityState( + sessions: ReadonlyMap, + owner?: string +): { atGlobalCap: boolean; atUserCap: boolean } { + const atGlobalCap = sessions.size >= MAX_CONCURRENT_SESSIONS; + let atUserCap = false; + if (isMultiUserMode() && owner) { + let count = 0; + for (const s of sessions.values()) if (s.owner === owner) count++; + atUserCap = count >= maxSessionsPerUser(); + } + return { atGlobalCap, atUserCap }; +} + +/** + * Route sugar: the human-readable error message when at capacity, else null. The + * caller wraps it in createErrorResponse with its own error code (OPERATION_FAILED + * vs SESSION_BUSY, matching the pre-existing per-route codes). + */ +export function sessionCapacityMessage(sessions: ReadonlyMap, owner?: string): string | null { + const { atGlobalCap, atUserCap } = sessionCapacityState(sessions, owner); + if (atGlobalCap) { + return `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`; + } + if (atUserCap) { + return `Your session limit (${maxSessionsPerUser()}) reached. Delete some of your sessions first.`; + } + return null; +} + /** * Revoke every cookie session belonging to a user (optionally keeping one token, * e.g. the caller's own during a self-service password change). Returns the count. diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index ee899add..8a51d77c 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -28,9 +28,21 @@ import { import { exportDockerCase, importDockerBundle, listDockerExports, exportBundleName } from '../../docker-export.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { writeHooksConfig } from '../../hooks-config.js'; -import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js'; +import { + canAccessOwned, + getAuthUser, + isAdmin, + ownerFor, + resolveCasesDir, + SETTINGS_PATH, + validatePathWithinBase, + parseBody, + readJsonConfig, +} from '../route-helpers.js'; +import type { AuthUser } from '../../types.js'; import { SseEvent } from '../sse-events.js'; import type { EventPort, ConfigPort } from '../ports/index.js'; +import type { FastifyRequest } from 'fastify'; import { dataPath, getDataDir } from '../../config/instance.js'; import { checkDockerAvailable, @@ -78,11 +90,14 @@ async function readLinkedCases(): Promise> { return readJsonConfig>(LINKED_CASES_FILE, 'linked cases', {}); } -/** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */ -async function resolveCasePath(name: string): Promise { +/** + * Resolve a case name to its directory path, checking linked cases first, then the + * user's case space (per-user in multi-user mode, the shared CASES_DIR otherwise). + */ +async function resolveCasePath(name: string, user?: AuthUser): Promise { const linkedCases = await readLinkedCases(); if (linkedCases[name]) return linkedCases[name]; - return join(CASES_DIR, name); + return join(resolveCasesDir(user), name); } /** @@ -134,47 +149,54 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // ========== List Cases ========== - app.get('/api/cases', async (): Promise => { + app.get('/api/cases', async (req): Promise => { const cases: CaseInfo[] = []; + const user = getAuthUser(req); + const admin = isAdmin(req); + // Non-admins enumerate their OWN case space; admins see the shared CASES_DIR. + const listBase = resolveCasesDir(user); - // Get cases from CASES_DIR + // Get cases from the user's (or shared) cases dir try { - const entries = await fs.readdir(CASES_DIR, { withFileTypes: true }); + const entries = await fs.readdir(listBase, { withFileTypes: true }); for (const e of entries) { if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) { cases.push({ name: e.name, - path: join(CASES_DIR, e.name), - hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')), + path: join(listBase, e.name), + hasClaudeMd: existsSync(join(listBase, e.name, 'CLAUDE.md')), location: 'local', }); } } } catch { - // CASES_DIR may not exist yet + // dir may not exist yet } - // Get linked cases + // Linked cases (v1 registry has no owner) are admin-only in multi-user mode. const linkedCases = await readLinkedCases(); const existingNames = new Set(cases.map((c) => c.name)); - for (const [name, path] of Object.entries(linkedCases)) { - if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) { - cases.push({ - name, - path, - hasClaudeMd: existsSync(join(path, 'CLAUDE.md')), - linked: true, - location: 'linked-local', - }); + if (admin) { + for (const [name, path] of Object.entries(linkedCases)) { + if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) { + cases.push({ + name, + path, + hasClaudeMd: existsSync(join(path, 'CLAUDE.md')), + linked: true, + location: 'linked-local', + }); + } } } - // Get remote cases + // Get remote cases (owner-scoped; legacy no-owner = admin-only) const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host])); for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) { const host = remoteHostMap.get(remoteCase.hostId); if (!host || !SAFE_CASE_NAME.test(remoteCase.name)) continue; + if (!admin && !canAccessOwned(user, remoteCase.owner)) continue; existingNames.add(remoteCase.name); const remoteCaseInfo: CaseInfo = { name: remoteCase.name, @@ -202,6 +224,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config for (const dockerCase of await readDockerCases(CODEMAN_CONFIG_DIR)) { const host = dockerHostMap.get(dockerCase.hostId); if (!host || !SAFE_CASE_NAME.test(dockerCase.name)) continue; + if (!admin && !canAccessOwned(user, dockerCase.owner)) continue; existingNames.add(dockerCase.name); const container = dockerCase.container ?? dockerContainerName(dockerCase.name); const dockerCaseInfo: CaseInfo = { @@ -243,7 +266,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.post('/api/cases', async (req): Promise> => { const { name, description } = parseBody(CreateCaseSchema, req.body); - const casePath = validatePathWithinBase(name, CASES_DIR); + const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req))); if (!casePath) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); } @@ -274,7 +297,15 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.get('/api/remote-hosts', async () => readRemoteHosts(CODEMAN_CONFIG_DIR)); - app.post('/api/remote-hosts', async (req): Promise> => { + // Hosts are machine-level resources: only admins may define them in multi-user mode. + const adminOnly = (req: FastifyRequest, reply: { code: (n: number) => unknown }): ApiResponse | null => + isAdmin(req) + ? null + : (reply.code(403), createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode')); + + app.post('/api/remote-hosts', async (req, reply): Promise> => { + const denied = adminOnly(req, reply); + if (denied) return denied; const host = parseBody(RemoteHostSchema, req.body); const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); if (hosts.some((item) => item.id === host.id)) { @@ -284,7 +315,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return { success: true, data: { host } }; }); - app.put('/api/remote-hosts/:id', async (req): Promise> => { + app.put('/api/remote-hosts/:id', async (req, reply): Promise> => { + const denied = adminOnly(req, reply); + if (denied) return denied; const { id } = req.params as { id: string }; const host = parseBody(RemoteHostSchema, { ...(req.body as object), id }); const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); @@ -296,7 +329,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return { success: true, data: { host } }; }); - app.delete('/api/remote-hosts/:id', async (req): Promise> => { + app.delete('/api/remote-hosts/:id', async (req, reply): Promise> => { + const denied = adminOnly(req, reply); + if (denied) return denied; const { id } = req.params as { id: string }; const cases = await readRemoteCases(CODEMAN_CONFIG_DIR); if (cases.some((item) => item.hostId === id)) { @@ -311,7 +346,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config }); app.post('/api/cases/remote-link', async (req): Promise> => { - const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const }; + const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const, owner: ownerFor(req) }; const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); const host = hosts.find((item) => item.id === remoteCase.hostId); if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); @@ -321,7 +356,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config if ( remoteCases.some((item) => item.name === remoteCase.name) || linkedCases[remoteCase.name] || - existsSync(join(CASES_DIR, remoteCase.name)) + existsSync(join(resolveCasesDir(getAuthUser(req)), remoteCase.name)) ) { return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); } @@ -343,7 +378,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.get('/api/docker-hosts', async () => readDockerHosts(CODEMAN_CONFIG_DIR)); - app.post('/api/docker-hosts', async (req): Promise> => { + app.post('/api/docker-hosts', async (req, reply): Promise> => { + const denied = adminOnly(req, reply); + if (denied) return denied; const host = parseBody(DockerHostSchema, req.body); const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR); if (hosts.some((item) => item.id === host.id)) { @@ -353,7 +390,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return { success: true, data: { host } }; }); - app.put('/api/docker-hosts/:id', async (req): Promise> => { + app.put('/api/docker-hosts/:id', async (req, reply): Promise> => { + const denied = adminOnly(req, reply); + if (denied) return denied; const { id } = req.params as { id: string }; const host = parseBody(DockerHostSchema, { ...(req.body as object), id }); const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR); @@ -365,7 +404,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return { success: true, data: { host } }; }); - app.delete('/api/docker-hosts/:id', async (req): Promise> => { + app.delete('/api/docker-hosts/:id', async (req, reply): Promise> => { + const denied = adminOnly(req, reply); + if (denied) return denied; const { id } = req.params as { id: string }; const cases = await readDockerCases(CODEMAN_CONFIG_DIR); if (cases.some((item) => item.hostId === id)) { @@ -386,7 +427,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config ): Promise< ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }> > => { - const dockerCase = { ...parseBody(DockerCaseLinkSchema, req.body), type: 'docker' as const }; + const dockerCase = { + ...parseBody(DockerCaseLinkSchema, req.body), + type: 'docker' as const, + owner: ownerFor(req), + }; const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR); const host = hosts.find((item) => item.id === dockerCase.hostId); if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); @@ -396,7 +441,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config if ( dockerCases.some((item) => item.name === dockerCase.name) || linkedCases[dockerCase.name] || - existsSync(join(CASES_DIR, dockerCase.name)) + existsSync(join(resolveCasesDir(getAuthUser(req)), dockerCase.name)) ) { return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); } @@ -460,7 +505,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config > => { const body = parseBody(DockerQuickCreateSchema, req.body); const { name, description } = body; - const casePath = validatePathWithinBase(name, CASES_DIR); + const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req))); if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); // Collision across every case kind. @@ -525,7 +570,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config availability.error || 'docker daemon is not available' ); } - const dockerCase = { name, type: 'docker' as const, hostId: host.id, hostWorkspacePath: casePath }; + const dockerCase = { + name, + type: 'docker' as const, + hostId: host.id, + hostWorkspacePath: casePath, + owner: ownerFor(req), + }; const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), name); if (!imageGate.ok) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error); @@ -644,7 +695,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config if ( dockerCases.some((item) => item.name === newCaseName) || linkedCases[newCaseName] || - existsSync(join(CASES_DIR, newCaseName)) + existsSync(join(resolveCasesDir(getAuthUser(req)), newCaseName)) ) { return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); } @@ -681,6 +732,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config hostId, hostWorkspacePath: destWorkspacePath, containerWorkdir: result.manifest.containerWorkdir, + owner: ownerFor(req), }; await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, newCase]); ctx.broadcast(SseEvent.DockerImportComplete, { name: newCaseName, path: destWorkspacePath, type: 'docker' }); @@ -700,7 +752,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } // Check if case name already exists in CASES_DIR - const casePath = join(CASES_DIR, name); + const casePath = join(resolveCasesDir(getAuthUser(req)), name); if (existsSync(casePath)) { return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.'); } @@ -736,7 +788,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.delete('/api/cases/:name', async (req): Promise> => { const { name } = req.params as { name: string }; - if (!validatePathWithinBase(name, CASES_DIR)) { + if (!validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)))) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } @@ -790,7 +842,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } // Case in CASES_DIR — delete the entire directory - const casePath = join(CASES_DIR, name); + const casePath = join(resolveCasesDir(getAuthUser(req)), name); if (!existsSync(casePath)) { return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`); } @@ -832,7 +884,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.get('/api/cases/:name', async (req) => { const { name } = req.params as { name: string }; - if (!validatePathWithinBase(name, CASES_DIR)) { + if (!validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)))) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } @@ -875,13 +927,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config }; } - const casePath = await resolveCasePath(name); + const casePath = await resolveCasePath(name, getAuthUser(req)); if (!existsSync(casePath)) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found'); } - const linked = casePath !== join(CASES_DIR, name); + const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name); return { name, path: casePath, @@ -894,12 +946,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.get('/api/cases/:name/fix-plan', async (req) => { const { name } = req.params as { name: string }; - if (!validatePathWithinBase(name, CASES_DIR)) { + if (!validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)))) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } // Get case path (check linked cases first, then CASES_DIR) - const casePath = await resolveCasePath(name); + const casePath = await resolveCasePath(name, getAuthUser(req)); const fixPlanPath = join(casePath, '@fix_plan.md'); @@ -999,11 +1051,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => { const { caseName } = req.params as { caseName: string }; - if (!validatePathWithinBase(caseName, CASES_DIR)) { + if (!validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)))) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } - const casePath = await resolveCasePath(caseName); + const casePath = await resolveCasePath(caseName, getAuthUser(req)); const wizardDir = join(casePath, 'ralph-wizard'); @@ -1042,7 +1094,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // Cache disabled to ensure fresh prompts when starting new plan generations app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => { const { caseName, filePath } = req.params as { caseName: string; filePath: string }; - if (!validatePathWithinBase(caseName, CASES_DIR)) { + if (!validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)))) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } @@ -1051,7 +1103,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config reply.header('Pragma', 'no-cache'); reply.header('Expires', '0'); - const casePath = await resolveCasePath(caseName); + const casePath = await resolveCasePath(caseName, getAuthUser(req)); const wizardDir = join(casePath, 'ralph-wizard'); diff --git a/src/web/routes/cron-routes.ts b/src/web/routes/cron-routes.ts index 9aae6de2..273a1deb 100644 --- a/src/web/routes/cron-routes.ts +++ b/src/web/routes/cron-routes.ts @@ -9,33 +9,59 @@ import { FastifyInstance } from 'fastify'; import { ApiErrorCode, createErrorResponse } from '../../types.js'; import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js'; -import { parseBody } from '../route-helpers.js'; +import { canAccessOwned, getAuthUser, ownerFor, parseBody } from '../route-helpers.js'; +import { canRunPrivilegedCommands } from '../../user-store.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import type { CronJob } from '../../types/cron.js'; import type { CronPort } from '../ports/index.js'; +import type { FastifyRequest } from 'fastify'; export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void { + // A job the caller may see/act on (own, or admin/single-user). + const canTouch = (req: FastifyRequest, job: CronJob | null | undefined): job is CronJob => + !!job && canAccessOwned(getAuthUser(req), job.owner); + // ── Jobs ──────────────────────────────────────────────────────────────── - app.get('/api/cron/jobs', async () => { - return ctx.cron.listJobs(); + app.get('/api/cron/jobs', async (req) => { + const jobs = ctx.cron.listJobs(); + if (!isMultiUserMode()) return jobs; + const user = getAuthUser(req); + if (user.role === 'admin') return jobs; + return (jobs as CronJob[]).filter((j) => canAccessOwned(user, j.owner)); }); app.post('/api/cron/jobs', async (req) => { // No custom errorMessage: surface the schema's field-specific messages // (e.g. "runAt is required for a one-time schedule"). const body = parseBody(CronJobSchema, req.body); - return { job: ctx.cron.createJob(body) }; + // Section 6.3: shell mode / a launchCommand is arbitrary host-account execution. + if ((body.agentType === 'shell' || body.launchCommand) && !canRunPrivilegedCommands(getAuthUser(req))) { + return createErrorResponse( + ApiErrorCode.FORBIDDEN, + 'Shell/launchCommand cron jobs require the can-bypass-permissions grant' + ); + } + return { job: ctx.cron.createJob(body, ownerFor(req)) }; }); app.get('/api/cron/jobs/:id', async (req) => { const { id } = req.params as { id: string }; const job = ctx.cron.getJob(id); - if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found'); + if (!canTouch(req, job)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found'); return job; }); app.put('/api/cron/jobs/:id', async (req) => { const { id } = req.params as { id: string }; + if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found'); const body = parseBody(CronJobUpdateSchema, req.body); + if ((body.agentType === 'shell' || body.launchCommand) && !canRunPrivilegedCommands(getAuthUser(req))) { + return createErrorResponse( + ApiErrorCode.FORBIDDEN, + 'Shell/launchCommand cron jobs require the can-bypass-permissions grant' + ); + } const job = ctx.cron.updateJob(id, body); if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found'); return { job }; @@ -43,7 +69,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void { app.delete('/api/cron/jobs/:id', async (req) => { const { id } = req.params as { id: string }; - if (!ctx.cron.deleteJob(id)) { + if (!canTouch(req, ctx.cron.getJob(id)) || !ctx.cron.deleteJob(id)) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found'); } return {}; @@ -51,6 +77,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void { app.put('/api/cron/jobs/:id/enabled', async (req) => { const { id } = req.params as { id: string }; + if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found'); const { enabled } = parseBody(CronJobEnabledSchema, req.body, 'Invalid request body'); const job = ctx.cron.setEnabled(id, enabled); if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found'); @@ -62,7 +89,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void { app.post('/api/cron/jobs/:id/run', async (req) => { const { id } = req.params as { id: string }; const job = ctx.cron.getJob(id); - if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found'); + if (!canTouch(req, job)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found'); const run = await ctx.cron.runNow(id); return { run, activeAgents: ctx.cron.countActiveAgents(job.agentType, job.id) }; }); diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 6d4da5fb..b80a116a 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -371,7 +371,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even app.get('/api/sessions/:id/files', async (req) => { const { id } = req.params as { id: string }; const { depth, showHidden } = req.query as { depth?: string; showHidden?: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const maxDepth = Math.min(parseInt(depth || '5', 10), 10); const includeHidden = showHidden === 'true'; @@ -495,7 +495,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even app.get('/api/sessions/:id/file-content', async (req) => { const { id } = req.params as { id: string }; const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); if (!filePath) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'); @@ -648,7 +648,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even app.get('/api/sessions/:id/file-raw', async (req, reply) => { const { id } = req.params as { id: string }; const { path: filePath, download } = req.query as { path?: string; download?: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); if (!filePath) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); @@ -737,7 +737,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even // attachment-history list are layered on separately. app.post('/api/sessions/:id/attachments', async (req, reply) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const body = (req.body || {}) as { path?: string }; if (!body.path || typeof body.path !== 'string') { @@ -831,7 +831,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => { const { id, attachmentId } = req.params as { id: string; attachmentId: string }; const { download } = req.query as { download?: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const record = getAttachmentOr404(reply, id, attachmentId); if (!record) return; const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir); @@ -941,7 +941,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even app.get('/api/sessions/:id/tail-file', async (req, reply) => { const { id } = req.params as { id: string }; const { path: filePath, lines } = req.query as { path?: string; lines?: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); if (!filePath) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); @@ -1003,7 +1003,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even // malformed error envelope instead of wrapping it). app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => { const { id, streamId } = req.params as { id: string; streamId: string }; - findSessionOrFail(ctx, id); // Validates session exists + findSessionOrFail(ctx, id, req); // Validates session exists const closed = fileStreamManager.closeStream(streamId); return { closed }; }); @@ -1024,7 +1024,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even return; } - const session = findSessionOrFail(ctx, sessionId); + const session = findSessionOrFail(ctx, sessionId, req); const validated = validateSessionFilePath(session.workingDir, filePath); if (!validated) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); diff --git a/src/web/routes/plan-routes.ts b/src/web/routes/plan-routes.ts index e1fe3b46..2bc347e4 100644 --- a/src/web/routes/plan-routes.ts +++ b/src/web/routes/plan-routes.ts @@ -17,7 +17,15 @@ import { PlanTaskUpdateSchema, PlanTaskAddSchema, } from '../schemas.js'; -import { findSessionOrFail, parseBody, CASES_DIR, validatePathWithinBase } from '../route-helpers.js'; +import { + findSessionOrFail, + getAuthUser, + ownerFor, + parseBody, + resolveCasesDir, + validatePathWithinBase, +} from '../route-helpers.js'; +import { resolveClaudeModeForUsername } from '../../user-store.js'; import { SseEvent } from '../sse-events.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js'; @@ -124,12 +132,19 @@ Return ONLY a JSON array. Each item MUST have: NOW: Generate the implementation plan for the task above. Think step by step.`; - // Create temporary session for the AI call using Opus 4.5 for deep reasoning + // Create temporary session for the AI call using Opus 4.5 for deep reasoning. + // Section 6.3: downgrade a non-granted user's one-shot to a classifier-guarded mode. + const planOwner = ownerFor(req); + const planClaudeModeConfig = await ctx.getClaudeModeConfig(); + const planClaudeMode = await resolveClaudeModeForUsername(planClaudeModeConfig.claudeMode, planOwner); const session = new Session({ workingDir: process.cwd(), mux: ctx.mux, useMux: false, // No mux needed for one-shot mode: 'claude', + claudeMode: planClaudeMode, + allowedTools: planClaudeModeConfig.allowedTools, + owner: planOwner, }); // Use configured model for plan generation, falling back to opus @@ -228,7 +243,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Determine output directory for saving wizard results let outputDir: string | undefined; if (caseName) { - const casePath = validatePathWithinBase(caseName, CASES_DIR); + const casePath = validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req))); if (casePath && existsSync(casePath)) { outputDir = join(casePath, 'ralph-wizard'); @@ -359,7 +374,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => { const { id, taskId } = req.params as { id: string; taskId: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const tracker = session.ralphTracker; if (!tracker) { @@ -385,7 +400,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; app.post('/api/sessions/:id/plan/checkpoint', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const tracker = session.ralphTracker; if (!tracker) { @@ -401,7 +416,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; app.get('/api/sessions/:id/plan/history', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const tracker = session.ralphTracker; if (!tracker) { @@ -415,7 +430,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; app.post('/api/sessions/:id/plan/rollback/:version', async (req) => { const { id, version } = req.params as { id: string; version: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const tracker = session.ralphTracker; if (!tracker) { @@ -435,7 +450,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; app.post('/api/sessions/:id/plan/task', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const tracker = session.ralphTracker; if (!tracker) { diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index 12e2f349..df34ef8b 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -13,13 +13,22 @@ import { Session, isExternalCliMode } from '../../session.js'; import { RespawnController } from '../../respawn-controller.js'; import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js'; import { SseEvent } from '../sse-events.js'; -import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js'; +import { + autoConfigureRalph, + getAuthUser, + ownerFor, + resolveCasesDir, + sessionCapacityMessage, + SETTINGS_PATH, + findSessionOrFail, + parseBody, +} from '../route-helpers.js'; +import { resolveClaudeModeForUsername } from '../../user-store.js'; import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { buildRalphLoopPrompt } from '../../prompts/index.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; -import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js'; export function registerRalphRoutes( app: FastifyInstance, @@ -42,7 +51,7 @@ export function registerRalphRoutes( reset?: boolean | 'full'; disableAutoEnable?: boolean; }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); // Ralph tracker is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { @@ -118,7 +127,7 @@ export function registerRalphRoutes( // Reset circuit breaker for Ralph tracker app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); session.ralphTracker.resetCircuitBreaker(); return {}; @@ -127,7 +136,7 @@ export function registerRalphRoutes( // Get Ralph status block and circuit breaker state app.get('/api/sessions/:id/ralph-status', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); return { success: true, @@ -147,7 +156,7 @@ export function registerRalphRoutes( // Generate @fix_plan.md content from todos app.get('/api/sessions/:id/fix-plan', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const content = session.ralphTracker.generateFixPlanMarkdown(); return { @@ -163,7 +172,7 @@ export function registerRalphRoutes( app.post('/api/sessions/:id/fix-plan/import', async (req) => { const { id } = req.params as { id: string }; const { content } = parseBody(FixPlanImportSchema, req.body, 'Invalid request body'); - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const importedCount = session.ralphTracker.importFixPlanMarkdown(content); ctx.persistSessionState(session); @@ -180,7 +189,7 @@ export function registerRalphRoutes( // Write @fix_plan.md to session's working directory app.post('/api/sessions/:id/fix-plan/write', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const workingDir = session.workingDir; if (!workingDir) { @@ -207,7 +216,7 @@ export function registerRalphRoutes( // Read @fix_plan.md from session's working directory and import app.post('/api/sessions/:id/fix-plan/read', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const workingDir = session.workingDir; if (!workingDir) { @@ -246,7 +255,7 @@ export function registerRalphRoutes( app.post('/api/sessions/:id/ralph-prompt/write', async (req) => { const { id } = req.params as { id: string }; const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body'); - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const workingDir = session.workingDir; if (!workingDir) { @@ -271,13 +280,9 @@ export function registerRalphRoutes( // Start a Ralph Loop — creates a new session with autonomous cycling app.post('/api/ralph-loop/start', async (req): Promise => { - // Prevent unbounded session creation - if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) { - return createErrorResponse( - ApiErrorCode.SESSION_BUSY, - `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` - ); - } + const rlOwner = ownerFor(req); + const capMsg = sessionCapacityMessage(ctx.sessions, rlOwner); + if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg); const { caseName, @@ -290,11 +295,13 @@ export function registerRalphRoutes( effort, } = parseBody(RalphLoopStartSchema, req.body); - const casePath = join(CASES_DIR, caseName); + // Multi-user: cases live in the requesting user's space. + const rlCasesBase = resolveCasesDir(getAuthUser(req)); + const casePath = join(rlCasesBase, caseName); // Security: Path traversal protection const rlResolvedPath = resolve(casePath); - const rlResolvedBase = resolve(CASES_DIR); + const rlResolvedBase = resolve(rlCasesBase); const rlRelPath = relative(rlResolvedBase, rlResolvedPath); if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); @@ -324,6 +331,7 @@ export function registerRalphRoutes( const niceConfig = await ctx.getGlobalNiceConfig(); const rlModelConfig = await ctx.getModelConfig(); const rlClaudeModeConfig = await ctx.getClaudeModeConfig(); + const rlClaudeMode = await resolveClaudeModeForUsername(rlClaudeModeConfig.claudeMode, rlOwner); const session = new Session({ workingDir: casePath, mux: ctx.mux, @@ -331,10 +339,11 @@ export function registerRalphRoutes( mode: 'claude', niceConfig, model: rlModelConfig?.defaultModel || undefined, - claudeMode: rlClaudeModeConfig.claudeMode, + claudeMode: rlClaudeMode, allowedTools: rlClaudeModeConfig.allowedTools, envOverrides, effort, + owner: rlOwner, }); // Configure Ralph tracker diff --git a/src/web/routes/respawn-routes.ts b/src/web/routes/respawn-routes.ts index 2688d93d..c5533668 100644 --- a/src/web/routes/respawn-routes.ts +++ b/src/web/routes/respawn-routes.ts @@ -87,7 +87,7 @@ export function registerRespawnRoutes( if (req.body) { body = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial; } - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); // Respawn is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { @@ -160,7 +160,7 @@ export function registerRespawnRoutes( const { id } = req.params as { id: string }; // Validate respawn config to prevent arbitrary field injection const config = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const controller = ctx.respawnControllers.get(id); @@ -226,7 +226,7 @@ export function registerRespawnRoutes( respawnConfig?: Partial; durationMinutes?: number; }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); if (session.isBusy()) { return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); @@ -299,7 +299,7 @@ export function registerRespawnRoutes( return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const body = reResult.data as { config?: Partial; durationMinutes?: number }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); // Respawn is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index af8abe51..a9e38f53 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -38,13 +38,21 @@ import { } from '../schemas.js'; import { autoConfigureRalph, + canAccessOwned, CASES_DIR, findSessionOrFail, + getAuthUser, + isWorkingDirAllowed, + ownerFor, parseBody, persistAndBroadcastSession, + resolveCasesDir, + sessionCapacityMessage, SETTINGS_PATH, validatePathWithinBase, } from '../route-helpers.js'; +import { canRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { writeHooksConfig, @@ -67,7 +75,6 @@ import { type MuxStatInput, } from '../../services/unified-session-service.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; -import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js'; import { RunSummaryTracker } from '../../run-summary.js'; import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js'; @@ -285,24 +292,38 @@ export function registerSessionRoutes( // ========== Session Listing ========== - app.get('/api/sessions', async () => { - return ctx.getLightSessionsState(); + app.get('/api/sessions', async (req) => { + const list = ctx.getLightSessionsState(); + if (!isMultiUserMode()) return list; + const user = getAuthUser(req); + if (user.role === 'admin') return list; + return (list as Array<{ owner?: string }>).filter((s) => canAccessOwned(user, s.owner)); }); // ========== Session Creation ========== app.post('/api/sessions', async (req) => { - // Prevent unbounded session creation - if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.` - ); - } + const owner = ownerFor(req); + // Global + per-user session cap. + const capMsg = sessionCapacityMessage(ctx.sessions, owner); + if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg); const body = parseBody(CreateSessionSchema, req.body); const workingDir = body.workingDir || process.cwd(); + // Multi-user: shell mode is arbitrary command execution as the host account, + // gated behind the same grant as bypass (section 6.3). + if (body.mode === 'shell' && !canRunPrivilegedCommands(getAuthUser(req))) { + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant'); + } + + // Multi-user linchpin (section 6.2): a non-admin's workingDir must resolve + // inside their own case space. Enforced BEFORE any disk-mutating call below so + // a foreign path can never be written into. + if (!isWorkingDirAllowed(getAuthUser(req), workingDir)) { + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace'); + } + // Validate workingDir exists and is a directory if (body.workingDir) { try { @@ -320,16 +341,18 @@ export function registerSessionRoutes( // For keys the caller is actively setting, strip any stale disk entry a prior // Codeman version may have written. Scope limited to: // - Claude mode (OpenCode/Codex/Gemini don't read .claude/settings.local.json) - // - workingDir inside CASES_DIR (Codeman's managed territory — we never mutate - // .claude/settings.local.json in arbitrary user repos that POST /api/sessions - // can target, because those may have hand-authored values). + // - workingDir inside CASES_DIR / the per-user case space (Codeman's managed + // territory — we never mutate .claude/settings.local.json in arbitrary user + // repos that POST /api/sessions can target, as those may have hand-authored + // values). + const managedCasesBase = resolveCasesDir(getAuthUser(req)); const canStripDisk = body.mode !== 'opencode' && body.mode !== 'codex' && body.mode !== 'gemini' && body.envOverrides && Object.keys(body.envOverrides).length > 0 && - workingDir.startsWith(CASES_DIR + '/'); + (workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/')); if (canStripDisk) { await stripCaseEnvKeys(workingDir, Object.keys(body.envOverrides!)); } @@ -438,6 +461,8 @@ export function registerSessionRoutes( ? modelConfig?.defaultModel || undefined : undefined; const claudeModeConfig = await ctx.getClaudeModeConfig(); + // Section 6.3: force non-granted users to a classifier-guarded mode. + const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner); const terminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const session = new Session({ workingDir, @@ -447,7 +472,7 @@ export function registerSessionRoutes( useMux: true, niceConfig: globalNice, model, - claudeMode: claudeModeConfig.claudeMode, + claudeMode: effectiveClaudeMode, allowedTools: claudeModeConfig.allowedTools, openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, codexConfig: mode === 'codex' ? body.codexConfig : undefined, @@ -456,6 +481,7 @@ export function registerSessionRoutes( envOverrides: body.envOverrides, effort: body.effort, tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit, + owner, }); ctx.addSession(session); @@ -476,7 +502,7 @@ export function registerSessionRoutes( app.put('/api/sessions/:id/name', async (req) => { const { id } = req.params as { id: string }; const body = parseBody(SessionNameSchema, req.body, 'Invalid request body'); - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH); session.name = name; @@ -491,7 +517,7 @@ export function registerSessionRoutes( app.put('/api/sessions/:id/color', async (req) => { const { id } = req.params as { id: string }; const body = parseBody(SessionColorSchema, req.body, 'Invalid request body'); - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink']; if (!validColors.includes(body.color)) { @@ -538,7 +564,7 @@ export function registerSessionRoutes( app.get('/api/sessions/:id', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); // Use light state (no full buffers) — terminal buffer available via /terminal endpoint. // Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard). @@ -553,7 +579,7 @@ export function registerSessionRoutes( app.get('/api/sessions/:id/output', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); return { success: true, @@ -569,7 +595,7 @@ export function registerSessionRoutes( app.get('/api/sessions/:id/ralph-state', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); return { success: true, @@ -585,7 +611,7 @@ export function registerSessionRoutes( app.get('/api/sessions/:id/run-summary', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const tracker = ctx.runSummaryTrackers.get(id); if (!tracker) { @@ -605,7 +631,7 @@ export function registerSessionRoutes( app.get('/api/sessions/:id/active-tools', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); return { success: true, @@ -624,7 +650,7 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/run', async (req) => { const { id } = req.params as { id: string }; const { prompt } = parseBody(RunPromptSchema, req.body); - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); if (session.isBusy()) { return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); @@ -651,7 +677,7 @@ export function registerSessionRoutes( return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const { clearBreaker } = bodyResult.data; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); if (session.isBusy()) { return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); @@ -707,7 +733,7 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/shell', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); if (session.isBusy()) { return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); @@ -740,7 +766,7 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/input', async (req) => { const { id } = req.params as { id: string }; const { input, useMux, seq, clientId } = parseBody(SessionInputWithLimitSchema, req.body); - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const inputStr = String(input); if (inputStr.length > MAX_INPUT_LENGTH) { @@ -799,7 +825,7 @@ export function registerSessionRoutes( return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`); } - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const muxName = session.muxName; if (!muxName) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session'); @@ -831,7 +857,7 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/resize', async (req) => { const { id } = req.params as { id: string }; const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body); - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); session.resize(cols, rows, { viewportType, force }); return {}; @@ -917,7 +943,7 @@ export function registerSessionRoutes( app.get('/api/sessions/:id/last-response', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); // Codex sessions don't write to ~/.claude/projects — their transcripts // live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the @@ -1368,7 +1394,7 @@ export function registerSessionRoutes( app.get('/api/sessions/:id/terminal', async (req) => { const { id } = req.params as { id: string }; const query = req.query as { tail?: string; full?: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); // `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded // the page and wants the whole scroll history back, so we capture the ENTIRE @@ -1501,7 +1527,7 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/auto-clear', async (req) => { const { id } = req.params as { id: string }; const body = parseBody(AutoClearSchema, req.body, 'Invalid request body'); - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); session.setAutoClear(body.enabled, body.threshold); persistAndBroadcastSession(ctx, session); @@ -1522,7 +1548,7 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/auto-compact', async (req) => { const { id } = req.params as { id: string }; const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body'); - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); session.setAutoCompact(body.enabled, body.threshold, body.prompt); persistAndBroadcastSession(ctx, session); @@ -1544,7 +1570,7 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/auto-resume', async (req) => { const { id } = req.params as { id: string }; const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body'); - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); session.setAutoResume(body.enabled); persistAndBroadcastSession(ctx, session); @@ -1565,7 +1591,7 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/image-watcher', async (req) => { const { id } = req.params as { id: string }; const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body'); - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); if (body.enabled) { imageWatcher.watchSession(session.id, session.workingDir); @@ -1590,7 +1616,7 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/flicker-filter', async (req) => { const { id } = req.params as { id: string }; const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body'); - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); session.flickerFilterEnabled = body.enabled; persistAndBroadcastSession(ctx, session); @@ -1610,13 +1636,9 @@ export function registerSessionRoutes( // ========== Quick Run ========== app.post('/api/run', async (req) => { - // Prevent unbounded session creation - if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) { - return createErrorResponse( - ApiErrorCode.SESSION_BUSY, - `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached` - ); - } + const runOwner = ownerFor(req); + const capMsg = sessionCapacityMessage(ctx.sessions, runOwner); + if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg); const { prompt, @@ -1629,6 +1651,11 @@ export function registerSessionRoutes( } const dir = workingDir || process.cwd(); + // Multi-user: confine a non-admin's one-shot working dir to their space. + if (!isWorkingDirAllowed(getAuthUser(req), dir)) { + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace'); + } + // Validate workingDir exists and is a directory if (workingDir) { try { @@ -1641,7 +1668,17 @@ export function registerSessionRoutes( } } - const session = new Session({ workingDir: dir, envOverrides: runEnvOverrides }); + // Section 6.3: the one-shot spawn path (runPrompt/buildPromptArgs) respects the + // session's claudeMode, so resolve it for the owner (bypass -> auto for non-granted). + const runClaudeModeConfig = await ctx.getClaudeModeConfig(); + const runClaudeMode = await resolveClaudeModeForUsername(runClaudeModeConfig.claudeMode, runOwner); + const session = new Session({ + workingDir: dir, + envOverrides: runEnvOverrides, + claudeMode: runClaudeMode, + allowedTools: runClaudeModeConfig.allowedTools, + owner: runOwner, + }); ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); @@ -1671,13 +1708,9 @@ export function registerSessionRoutes( // ========== Quick Start ========== app.post('/api/quick-start', async (req) => { - // Prevent unbounded session creation - if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) { - return createErrorResponse( - ApiErrorCode.SESSION_BUSY, - `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` - ); - } + const owner = ownerFor(req); + const capMsg = sessionCapacityMessage(ctx.sessions, owner); + if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg); const { caseName = 'testcase', @@ -1690,6 +1723,11 @@ export function registerSessionRoutes( effort, } = parseBody(QuickStartSchema, req.body); + // Multi-user: shell mode is arbitrary host-account execution, gated by the grant. + if (mode === 'shell' && !canRunPrivilegedCommands(getAuthUser(req))) { + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant'); + } + // Resolve the remote case FIRST — the CLI executes on the REMOTE host over ssh, // so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and // would wrongly reject a machine that hasn't got the CLI installed locally. @@ -1834,7 +1872,8 @@ export function registerSessionRoutes( } catch { // File missing or unparseable — treat as empty registry } - casePath = linkedCases[caseName] || validatePathWithinBase(caseName, CASES_DIR); + // Multi-user: resolve local cases inside the requesting user's case space. + casePath = linkedCases[caseName] || validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req))); if (!casePath) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); } @@ -1922,6 +1961,7 @@ export function registerSessionRoutes( ? qsModelConfig?.defaultModel || undefined : undefined; const qsClaudeModeConfig = await ctx.getClaudeModeConfig(); + const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner); const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const session = new Session({ workingDir: resolvedCasePath, @@ -1931,8 +1971,9 @@ export function registerSessionRoutes( mode: mode, niceConfig: niceConfig, model: qsModel, - claudeMode: qsClaudeModeConfig.claudeMode, + claudeMode: qsEffectiveClaudeMode, allowedTools: qsClaudeModeConfig.allowedTools, + owner, openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined, codexConfig: mode === 'codex' ? codexConfig : undefined, geminiConfig: mode === 'gemini' ? geminiConfig : undefined, @@ -2414,7 +2455,37 @@ export function registerSessionRoutes( // Mux stats are optional. } - const merged = mergeUnifiedSessions({ live, persisted, lifecycle, history, mux }); + // Multi-user: a non-admin only sees their own sessions; host-wide transcript + // history (not tied to an owned session) is admin-only. + let sLive = live; + let sPersisted = persisted; + let sLifecycle = lifecycle; + let sHistory = history; + const uUser = getAuthUser(req); + if (isMultiUserMode() && uUser.role !== 'admin') { + const ownedLive = new Set( + [...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id) + ); + const stored = ctx.store.getState().sessions as Record; + const ownedPersisted = new Set( + Object.values(stored) + .filter((p) => canAccessOwned(uUser, p.owner)) + .map((p) => p.id) + ); + const isOwned = (id: string) => ownedLive.has(id) || ownedPersisted.has(id); + sLive = live.filter((l) => isOwned(l.id)); + sPersisted = persisted.filter((p) => isOwned(p.id)); + sLifecycle = lifecycle.filter((e) => isOwned(e.sessionId)); + sHistory = []; + } + + const merged = mergeUnifiedSessions({ + live: sLive, + persisted: sPersisted, + lifecycle: sLifecycle, + history: sHistory, + mux, + }); const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined; const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined; return filterAndPaginate(merged, { @@ -2473,7 +2544,7 @@ export function registerSessionRoutes( return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)'); } - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); if (!req.isMultipart()) { reply.code(400); diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 6a90e6d1..3b39be2d 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -758,7 +758,7 @@ export function registerSystemRoutes( app.get('/api/sessions/:id/cpu-limit', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); return { nice: session.niceConfig, }; @@ -766,7 +766,7 @@ export function registerSystemRoutes( app.post('/api/sessions/:id/cpu-limit', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const body = parseBody(CpuLimitSchema, req.body, 'Invalid request body') as Partial; @@ -857,7 +857,7 @@ export function registerSystemRoutes( app.get('/api/sessions/:id/subagents', async (req) => { const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id); + const session = findSessionOrFail(ctx, id, req); const subagents = subagentWatcher.getSubagentsForSession(session.workingDir); return { success: true, data: subagents }; }); diff --git a/src/web/server.ts b/src/web/server.ts index d07e483b..074ec7d8 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -136,7 +136,7 @@ import { getLatestPlanUsage } from './plan-usage-latest.js'; import type { ScheduledRun } from './ports/index.js'; import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js'; import { isMultiUserMode } from '../config/multiuser.js'; -import { bootstrapInitialAdmin, hasUsers } from '../user-store.js'; +import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js'; import { installRouteErrorHandler } from './route-error-handler.js'; import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js'; import { @@ -2238,7 +2238,16 @@ export class WebServer extends EventEmitter { const sessionName = savedState?.name || muxSession.name || muxSession.muxName; // Create a session object for this mux session - const recoveryClaudeMode = await this.getClaudeModeConfig(); + // Owner round-trips like remote/docker: mux-sessions.json carries + // MuxSession.owner, state.json carries SessionState.owner. Recovery must + // re-resolve the permission mode with the RECOVERED owner or a reboot + // would silently un-downgrade a non-granted user's restored session. + const recoveredOwner = muxSession.owner ?? savedState?.owner; + const recoveryClaudeModeConfig = await this.getClaudeModeConfig(); + const recoveryClaudeMode = { + claudeMode: await resolveClaudeModeForUsername(recoveryClaudeModeConfig.claudeMode, recoveredOwner), + allowedTools: recoveryClaudeModeConfig.allowedTools, + }; // Recover envOverrides from the internal __envOverrides field written by // session-manager (see updateSessionState). Cast to read the non-public field. // Note: a legacy CLAUDE_CODE_EFFORT_LEVEL entry is auto-migrated to `effort` @@ -2275,6 +2284,7 @@ export class WebServer extends EventEmitter { // MuxSession.docker; state.json carries SessionState.docker), so recovery // rebuilds the `docker exec` launch instead of a broken local command. docker: muxSession.docker ?? savedState?.docker, + owner: recoveredOwner, }); // Update session name if it was a "Restored:" placeholder or doesn't match saved name diff --git a/test/ownership-scoping.test.ts b/test/ownership-scoping.test.ts new file mode 100644 index 00000000..aa6ad7df --- /dev/null +++ b/test/ownership-scoping.test.ts @@ -0,0 +1,178 @@ +/** + * @fileoverview Phase 3 ownership-scoping tests (live server, port 3172). + * + * Verifies multi-user isolation at the API level: case lists are disjoint per user, + * a non-admin cannot read/kill another user's session, workingDir confinement + + * shell gate + host-CRUD admin gate are enforced, and admins see everything. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { WebServer } from '../src/web/server.js'; +import { TmuxManager } from '../src/tmux-manager.js'; +import { createUser, invalidateUsersCache } from '../src/user-store.js'; +import { canAccessOwned, findSessionOrFail, sessionCapacityState } from '../src/web/route-helpers.js'; + +vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); + +const PORT = 3172; +const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64'); + +let server: WebServer; +let dataDir: string; +let spacesDir: string; +const saved: Record = {}; +const url = (p: string) => `http://localhost:${PORT}${p}`; + +// Route returns are wrapped in the {success,data} envelope; unwrap to the payload. +async function getJson(p: string, headers: Record): Promise { + const body = await (await fetch(url(p), { headers })).json(); + return body && typeof body === 'object' && 'data' in body ? (body as { data: unknown }).data : body; +} +const alice = { Authorization: basic('alice', 'alicepass1') }; +const bob = { Authorization: basic('bob', 'bobpass1234') }; +const admin = { Authorization: basic('root', 'rootpass123') }; + +beforeAll(async () => { + dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'own-data-')); + spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'own-spaces-')); + for (const k of [ + 'CODEMAN_DATA_DIR', + 'CODEMAN_USER_SPACES_DIR', + 'CODEMAN_MULTIUSER', + 'CODEMAN_PASSWORD', + 'CODEMAN_USERNAME', + ]) { + saved[k] = process.env[k]; + } + process.env.CODEMAN_DATA_DIR = dataDir; + process.env.CODEMAN_USER_SPACES_DIR = spacesDir; + process.env.CODEMAN_MULTIUSER = '1'; + delete process.env.CODEMAN_PASSWORD; + delete process.env.CODEMAN_USERNAME; + invalidateUsersCache(); + + await createUser({ username: 'root', role: 'admin', password: 'rootpass123' }); + await createUser({ username: 'alice', role: 'user', password: 'alicepass1' }); + await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' }); + + server = new WebServer(PORT, false, true); + await server.start(); +}); + +afterAll(async () => { + await server?.stop(); + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + invalidateUsersCache(); + await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {}); + await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {}); +}); + +describe('case scoping', () => { + it('creates cases in per-user spaces and lists them disjointly', async () => { + const mk = await fetch(url('/api/cases'), { + method: 'POST', + headers: { ...alice, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'aliceproj' }), + }); + expect(mk.status).toBe(200); + + // Case folder is under alice's space. + expect(await exists(path.join(spacesDir, 'alice', 'cases', 'aliceproj'))).toBe(true); + + const aliceList = (await getJson('/api/cases', alice)) as Array<{ name: string }>; + expect(aliceList.map((c) => c.name)).toContain('aliceproj'); + + const bobList = (await getJson('/api/cases', bob)) as Array<{ name: string }>; + expect(bobList.map((c) => c.name)).not.toContain('aliceproj'); + }); +}); + +describe('host CRUD is admin-only', () => { + it('rejects a non-admin defining a docker host', async () => { + const res = await fetch(url('/api/docker-hosts'), { + method: 'POST', + headers: { ...bob, 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'h1', label: 'x', image: 'codeman/agent:base' }), + }); + expect(res.status).toBe(403); + }); + + it('allows an admin to list docker hosts', async () => { + const res = await fetch(url('/api/docker-hosts'), { headers: admin }); + expect(res.status).toBe(200); + }); +}); + +describe('session creation gates', () => { + it('confines a non-admin workingDir to their space', async () => { + const foreign = path.join(spacesDir, 'alice', 'cases', 'aliceproj'); + const res = await fetch(url('/api/sessions'), { + method: 'POST', + headers: { ...bob, 'Content-Type': 'application/json' }, + body: JSON.stringify({ workingDir: foreign }), + }); + expect(res.status).toBe(403); + }); + + it('refuses shell mode for a non-granted user', async () => { + const mine = path.join(spacesDir, 'bob', 'cases'); + await fs.mkdir(mine, { recursive: true }); + const res = await fetch(url('/api/sessions'), { + method: 'POST', + headers: { ...bob, 'Content-Type': 'application/json' }, + body: JSON.stringify({ workingDir: mine, mode: 'shell' }), + }); + expect(res.status).toBe(403); + }); +}); + +// The session-scoping logic (findSessionOrFail owner check, list filter, per-user +// cap) is tested directly against the helpers under the same multi-user env, since +// real session spawning is no-op'd in test mode and does not durably populate the +// live map. These are the exact functions every session route uses. +describe('session-scoping helpers (multi-user)', () => { + const fakeSession = (owner?: string) => ({ owner }) as unknown as import('../src/session.js').Session; + const ctxWith = (map: Map) => ({ sessions: map }) as never; + const reqAs = (username: string, role: 'admin' | 'user') => ({ authUser: { username, role } }) as never; + + it('canAccessOwned isolates non-admins to their own', () => { + expect(canAccessOwned({ username: 'alice', role: 'user' }, 'alice')).toBe(true); + expect(canAccessOwned({ username: 'alice', role: 'user' }, 'bob')).toBe(false); + expect(canAccessOwned({ username: 'alice', role: 'user' }, undefined)).toBe(false); + expect(canAccessOwned({ username: 'root', role: 'admin' }, 'bob')).toBe(true); + }); + + it('findSessionOrFail 404s a foreign session for a non-admin, returns it for owner/admin', () => { + const map = new Map([['s1', fakeSession('alice')]]); + expect(() => findSessionOrFail(ctxWith(map), 's1', reqAs('bob', 'user'))).toThrow(); + expect(findSessionOrFail(ctxWith(map), 's1', reqAs('alice', 'user'))).toBeDefined(); + expect(findSessionOrFail(ctxWith(map), 's1', reqAs('root', 'admin'))).toBeDefined(); + }); + + it('per-user session cap counts only the owner sessions', () => { + const map = new Map([ + ['a', fakeSession('alice')], + ['b', fakeSession('alice')], + ['c', fakeSession('bob')], + ]); + process.env.CODEMAN_MAX_SESSIONS_PER_USER = '2'; + expect(sessionCapacityState(map as never, 'alice').atUserCap).toBe(true); + expect(sessionCapacityState(map as never, 'bob').atUserCap).toBe(false); + delete process.env.CODEMAN_MAX_SESSIONS_PER_USER; + }); +}); + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +}