mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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();
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+5
-1
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -409,6 +409,17 @@ export async function deleteUserSpace(username: string): Promise<void> {
|
||||
/** 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<boolean> {
|
||||
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.
|
||||
|
||||
+106
-4
@@ -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/<username>/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<string, Session>,
|
||||
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<string, Session>, 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.
|
||||
|
||||
@@ -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<Record<string, string>> {
|
||||
return readJsonConfig<Record<string, string>>(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<string> {
|
||||
/**
|
||||
* 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<string> {
|
||||
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<CaseInfo[]> => {
|
||||
app.get('/api/cases', async (req): Promise<CaseInfo[]> => {
|
||||
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<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
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<ApiResponse<{ host: unknown }>> => {
|
||||
// Hosts are machine-level resources: only admins may define them in multi-user mode.
|
||||
const adminOnly = (req: FastifyRequest, reply: { code: (n: number) => unknown }): ApiResponse<never> | 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<ApiResponse<{ host: unknown }>> => {
|
||||
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<ApiResponse<{ host: unknown }>> => {
|
||||
app.put('/api/remote-hosts/:id', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
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<ApiResponse<{ id: string }>> => {
|
||||
app.delete('/api/remote-hosts/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
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<ApiResponse<{ case: unknown }>> => {
|
||||
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<ApiResponse<{ host: unknown }>> => {
|
||||
app.post('/api/docker-hosts', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
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<ApiResponse<{ host: unknown }>> => {
|
||||
app.put('/api/docker-hosts/:id', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
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<ApiResponse<{ id: string }>> => {
|
||||
app.delete('/api/docker-hosts/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
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<ApiResponse<{ name: string }>> => {
|
||||
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');
|
||||
|
||||
|
||||
@@ -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) };
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<ApiResponse> => {
|
||||
// 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
|
||||
|
||||
@@ -87,7 +87,7 @@ export function registerRespawnRoutes(
|
||||
if (req.body) {
|
||||
body = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
||||
}
|
||||
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<RespawnConfig>;
|
||||
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<RespawnConfig>;
|
||||
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<RespawnConfig>; 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)) {
|
||||
|
||||
@@ -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<string, { id: string; owner?: string }>;
|
||||
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);
|
||||
|
||||
@@ -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<NiceConfig>;
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
@@ -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<string, string | undefined> = {};
|
||||
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<string, string>): Promise<unknown> {
|
||||
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<string, unknown>) => ({ 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<string, unknown>([['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<string, unknown>([
|
||||
['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<boolean> {
|
||||
try {
|
||||
await fs.stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user