feat(multiuser): phase 3, ownership threading + scoping

Threads per-user ownership through sessions, cases, cron, and the permission
policy. All scoping is a no-op in single-user mode (isMultiUserMode() guards).

Sessions
- Session.owner stamped at every create path from req.authUser / job.owner:
  POST /api/sessions, /api/run, /api/quick-start, ralph start, cron launch,
  plan generation. Round-trips through recovery (MuxSession.owner mirror, read
  muxSession.owner ?? savedState?.owner) and the mux layer.
- findSessionOrFail(ctx, id, req) now does a NOT_FOUND owner check (never 403, so
  other users' session existence is not leaked); wired at ~50 call sites.
- List endpoints filtered by owner: GET /api/sessions, /api/sessions/unified
  (live+persisted+lifecycle scoped, host-wide transcripts admin-only), cron jobs.

Permission policy (section 6.3)
- resolveClaudeModeForUsername wraps getClaudeModeConfig at every spawn site so a
  non-granted user is forced to --permission-mode auto (bypass -> auto), including
  recovery (or a reboot would un-downgrade). buildPromptArgs now respects the
  session's claudeMode, closing the one-shot (runPrompt) bypass hole.
- Shell mode and cron launchCommand require canBypassPermissions: 403 at
  POST /api/sessions, /api/quick-start create, cron job create, AND cron fire time
  (re-checked against the owner's current grant).

Cases
- resolveCasesDir(user): per-user ~/codeman-users/<name>/cases in multi-user, the
  shared ~/codeman-cases otherwise. All case CRUD + ralph + plan + quick-start
  resolve through it. resolveCasePath is owner-aware.
- GET /api/cases scoped per user (own folders; legacy linked cases admin-only;
  remote/docker cases owner-filtered). RemoteCase/DockerCase gain owner, stamped
  at link/quickcreate/import.
- Remote + Docker host CRUD is admin-only.
- Non-admin workingDir confinement (the linchpin): realpath must resolve inside the
  user's space, enforced at POST /api/sessions and /api/run BEFORE any disk write.

Limits
- sessionCapacityState / sessionCapacityMessage centralize the global + per-user
  cap (CODEMAN_MAX_SESSIONS_PER_USER, default global/2), replacing the 6 copy-pasted
  MAX_CONCURRENT_SESSIONS checks.

Tests: test/ownership-scoping.test.ts (case isolation, host-CRUD gate, workingDir +
shell gates, and the scoping helpers). Deferred to phase 4: WS owner gate, SSE
fan-out filtering, file-route preview/thumbnail helper scoping, push routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 04:02:46 +02:00
parent 4d8857f72a
commit 453605a58f
19 changed files with 680 additions and 164 deletions
+23 -8
View File
@@ -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) {