mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +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 { CronJobSchema } from '../web/schemas.js';
|
||||||
import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.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 { 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 { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_BLOCKED_TREES,
|
DEFAULT_BLOCKED_TREES,
|
||||||
@@ -108,7 +110,7 @@ export class CronService {
|
|||||||
|
|
||||||
// ──────────────────────────── Mutations ───────────────────────────
|
// ──────────────────────────── Mutations ───────────────────────────
|
||||||
|
|
||||||
createJob(input: CronJobInput): CronJob {
|
createJob(input: CronJobInput, owner?: string): CronJob {
|
||||||
if (Object.keys(this.store.getCronJobs()).length >= MAX_CRON_JOBS) {
|
if (Object.keys(this.store.getCronJobs()).length >= MAX_CRON_JOBS) {
|
||||||
throw this.badRequest(`Maximum number of cron jobs (${MAX_CRON_JOBS}) reached`);
|
throw this.badRequest(`Maximum number of cron jobs (${MAX_CRON_JOBS}) reached`);
|
||||||
}
|
}
|
||||||
@@ -117,6 +119,7 @@ export class CronService {
|
|||||||
const job: CronJob = {
|
const job: CronJob = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
owner,
|
||||||
agentType: input.agentType,
|
agentType: input.agentType,
|
||||||
workingDir: input.workingDir,
|
workingDir: input.workingDir,
|
||||||
launchCommand: input.launchCommand,
|
launchCommand: input.launchCommand,
|
||||||
@@ -336,10 +339,23 @@ export class CronService {
|
|||||||
await this.closePreviousRunSessions(job, run.id);
|
await this.closePreviousRunSessions(job, run.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respect the global session cap.
|
// Respect the global cap AND the owner's per-user cap (multi-user).
|
||||||
if (this.deps.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
const cap = sessionCapacityState(this.deps.sessions, job.owner);
|
||||||
|
if (cap.atGlobalCap) {
|
||||||
return this.failRun(job, run, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`);
|
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).
|
// Create + start the session (mirrors the quick-start route flow).
|
||||||
let session: Session;
|
let session: Session;
|
||||||
@@ -348,6 +364,7 @@ export class CronService {
|
|||||||
const globalNice = await this.deps.getGlobalNiceConfig();
|
const globalNice = await this.deps.getGlobalNiceConfig();
|
||||||
const modelConfig = await this.deps.getModelConfig();
|
const modelConfig = await this.deps.getModelConfig();
|
||||||
const claudeModeConfig = await this.deps.getClaudeModeConfig();
|
const claudeModeConfig = await this.deps.getClaudeModeConfig();
|
||||||
|
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner);
|
||||||
const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined;
|
const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined;
|
||||||
session = new Session({
|
session = new Session({
|
||||||
workingDir: job.workingDir,
|
workingDir: job.workingDir,
|
||||||
@@ -357,8 +374,9 @@ export class CronService {
|
|||||||
useMux: true,
|
useMux: true,
|
||||||
niceConfig: globalNice,
|
niceConfig: globalNice,
|
||||||
model,
|
model,
|
||||||
claudeMode: claudeModeConfig.claudeMode,
|
claudeMode: effectiveClaudeMode,
|
||||||
allowedTools: claudeModeConfig.allowedTools,
|
allowedTools: claudeModeConfig.allowedTools,
|
||||||
|
owner: job.owner,
|
||||||
});
|
});
|
||||||
this.deps.addSession(session);
|
this.deps.addSession(session);
|
||||||
this.store.incrementSessionsCreated();
|
this.store.incrementSessionsCreated();
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ export interface RespawnPaneOptions {
|
|||||||
remote?: SessionRemote;
|
remote?: SessionRemote;
|
||||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||||
docker?: SessionDocker;
|
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). */
|
/** Options for pane buffer capture (COD-47 full-history mode). */
|
||||||
|
|||||||
@@ -82,8 +82,16 @@ export function buildInteractiveArgs(
|
|||||||
* @param model - Optional model override
|
* @param model - Optional model override
|
||||||
* @returns Array of CLI arguments
|
* @returns Array of CLI arguments
|
||||||
*/
|
*/
|
||||||
export function buildPromptArgs(prompt: string, model?: string): string[] {
|
export function buildPromptArgs(
|
||||||
const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json'];
|
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) {
|
if (model) {
|
||||||
args.push('--model', model);
|
args.push('--model', model);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -1413,6 +1413,7 @@ export class Session extends EventEmitter {
|
|||||||
historyLimit: this._tmuxHistoryLimit,
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
remote: this._remote,
|
remote: this._remote,
|
||||||
docker: this._docker,
|
docker: this._docker,
|
||||||
|
owner: this._owner,
|
||||||
},
|
},
|
||||||
createSessionOptions: {
|
createSessionOptions: {
|
||||||
sessionId: this.id,
|
sessionId: this.id,
|
||||||
@@ -1432,6 +1433,7 @@ export class Session extends EventEmitter {
|
|||||||
historyLimit: this._tmuxHistoryLimit,
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
remote: this._remote,
|
remote: this._remote,
|
||||||
docker: this._docker,
|
docker: this._docker,
|
||||||
|
owner: this._owner,
|
||||||
},
|
},
|
||||||
spawnErrLabel: 'mux attachment',
|
spawnErrLabel: 'mux attachment',
|
||||||
});
|
});
|
||||||
@@ -1803,6 +1805,7 @@ export class Session extends EventEmitter {
|
|||||||
historyLimit: this._tmuxHistoryLimit,
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
remote: this._remote,
|
remote: this._remote,
|
||||||
docker: this._docker,
|
docker: this._docker,
|
||||||
|
owner: this._owner,
|
||||||
},
|
},
|
||||||
createSessionOptions: {
|
createSessionOptions: {
|
||||||
sessionId: this.id,
|
sessionId: this.id,
|
||||||
@@ -1814,6 +1817,7 @@ export class Session extends EventEmitter {
|
|||||||
historyLimit: this._tmuxHistoryLimit,
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
remote: this._remote,
|
remote: this._remote,
|
||||||
docker: this._docker,
|
docker: this._docker,
|
||||||
|
owner: this._owner,
|
||||||
},
|
},
|
||||||
spawnErrLabel: 'shell mux attachment',
|
spawnErrLabel: 'shell mux attachment',
|
||||||
});
|
});
|
||||||
@@ -1941,7 +1945,7 @@ export class Session extends EventEmitter {
|
|||||||
model ? `(model: ${model})` : ''
|
model ? `(model: ${model})` : ''
|
||||||
);
|
);
|
||||||
|
|
||||||
const args = buildPromptArgs(prompt, model);
|
const args = buildPromptArgs(prompt, model, this._claudeMode, this._allowedTools);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.ptyProcess = pty.spawn('claude', args, {
|
this.ptyProcess = pty.spawn('claude', args, {
|
||||||
|
|||||||
@@ -1489,6 +1489,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||||
remote,
|
remote,
|
||||||
docker,
|
docker,
|
||||||
|
owner,
|
||||||
} = options;
|
} = options;
|
||||||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||||||
|
|
||||||
@@ -1509,6 +1510,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
workingDir,
|
workingDir,
|
||||||
remote,
|
remote,
|
||||||
docker,
|
docker,
|
||||||
|
owner,
|
||||||
mode,
|
mode,
|
||||||
attached: false,
|
attached: false,
|
||||||
name,
|
name,
|
||||||
@@ -1685,6 +1687,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
workingDir,
|
workingDir,
|
||||||
remote,
|
remote,
|
||||||
docker,
|
docker,
|
||||||
|
owner,
|
||||||
mode,
|
mode,
|
||||||
attached: false,
|
attached: false,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export type ConcurrencyPolicy = 'warn_only' | 'skip_if_same_agent_running';
|
|||||||
export interface CronJob {
|
export interface CronJob {
|
||||||
id: string;
|
id: string;
|
||||||
name: 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. */
|
/** Reuses Codeman's existing session modes; 'shell' covers Terminal/custom. */
|
||||||
agentType: SessionMode;
|
agentType: SessionMode;
|
||||||
workingDir: string;
|
workingDir: string;
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export interface RemoteHost extends RemoteSshOptions {
|
|||||||
export interface RemoteCase {
|
export interface RemoteCase {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'remote';
|
type: 'remote';
|
||||||
|
/** Owning username in multi-user mode; absent = legacy/unassigned (admin-only). */
|
||||||
|
owner?: string;
|
||||||
hostId: string;
|
hostId: string;
|
||||||
remotePath: string;
|
remotePath: string;
|
||||||
}
|
}
|
||||||
@@ -174,6 +176,8 @@ export interface DockerHost {
|
|||||||
export interface DockerCase {
|
export interface DockerCase {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'docker';
|
type: 'docker';
|
||||||
|
/** Owning username in multi-user mode; absent = legacy/unassigned (admin-only). */
|
||||||
|
owner?: string;
|
||||||
hostId: string;
|
hostId: string;
|
||||||
/** Absolute HOST directory: the bind-mount source AND Session.workingDir (real host bytes). */
|
/** Absolute HOST directory: the bind-mount source AND Session.workingDir (real host bytes). */
|
||||||
hostWorkspacePath: string;
|
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. */
|
/** The synthetic admin used in single-user mode so downstream has one code path. */
|
||||||
export const SYNTHETIC_ADMIN: AuthUser = { username: 'admin', role: 'admin' };
|
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
|
* 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.
|
* 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 { 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 fs from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { Session } from '../session.js';
|
import { Session } from '../session.js';
|
||||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../types.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 { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||||
import { SseEvent } from './sse-events.js';
|
import { SseEvent } from './sse-events.js';
|
||||||
import type { SessionPort } from './ports/session-port.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 { AuthSessionRecord } from './ports/auth-port.js';
|
||||||
import type { StaleExpirationMap } from '../utils/index.js';
|
import type { StaleExpirationMap } from '../utils/index.js';
|
||||||
import { dataPath } from '../config/instance.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';
|
import { SYNTHETIC_ADMIN } from '../user-store.js';
|
||||||
|
|
||||||
// Shared path constants used across route modules. CASES_DIR (project folders)
|
// 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;
|
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
|
* 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).
|
* is not an admin. Always true in single-user mode (the sole user is the admin).
|
||||||
*/
|
*/
|
||||||
export function requireAdmin(req: FastifyRequest, reply: FastifyReply): boolean {
|
export function requireAdmin(req: FastifyRequest, reply: FastifyReply): boolean {
|
||||||
if (!isMultiUserMode()) return true;
|
if (isAdmin(req)) return true;
|
||||||
if (getAuthUser(req).role === 'admin') return true;
|
|
||||||
reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN));
|
reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN));
|
||||||
return false;
|
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,
|
* 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.
|
* 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 { exportDockerCase, importDockerBundle, listDockerExports, exportBundleName } from '../../docker-export.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
import { writeHooksConfig } from '../../hooks-config.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 { SseEvent } from '../sse-events.js';
|
||||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||||
import {
|
import {
|
||||||
checkDockerAvailable,
|
checkDockerAvailable,
|
||||||
@@ -78,11 +90,14 @@ async function readLinkedCases(): Promise<Record<string, string>> {
|
|||||||
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
|
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();
|
const linkedCases = await readLinkedCases();
|
||||||
if (linkedCases[name]) return linkedCases[name];
|
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 ==========
|
// ========== List Cases ==========
|
||||||
|
|
||||||
app.get('/api/cases', async (): Promise<CaseInfo[]> => {
|
app.get('/api/cases', async (req): Promise<CaseInfo[]> => {
|
||||||
const cases: 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 {
|
try {
|
||||||
const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
|
const entries = await fs.readdir(listBase, { withFileTypes: true });
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
|
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
|
||||||
cases.push({
|
cases.push({
|
||||||
name: e.name,
|
name: e.name,
|
||||||
path: join(CASES_DIR, e.name),
|
path: join(listBase, e.name),
|
||||||
hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')),
|
hasClaudeMd: existsSync(join(listBase, e.name, 'CLAUDE.md')),
|
||||||
location: 'local',
|
location: 'local',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 linkedCases = await readLinkedCases();
|
||||||
const existingNames = new Set(cases.map((c) => c.name));
|
const existingNames = new Set(cases.map((c) => c.name));
|
||||||
for (const [name, path] of Object.entries(linkedCases)) {
|
if (admin) {
|
||||||
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
for (const [name, path] of Object.entries(linkedCases)) {
|
||||||
cases.push({
|
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
||||||
name,
|
cases.push({
|
||||||
path,
|
name,
|
||||||
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
path,
|
||||||
linked: true,
|
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
||||||
location: 'linked-local',
|
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 remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||||
const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
|
const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
|
||||||
for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
|
for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
|
||||||
const host = remoteHostMap.get(remoteCase.hostId);
|
const host = remoteHostMap.get(remoteCase.hostId);
|
||||||
if (!host || !SAFE_CASE_NAME.test(remoteCase.name)) continue;
|
if (!host || !SAFE_CASE_NAME.test(remoteCase.name)) continue;
|
||||||
|
if (!admin && !canAccessOwned(user, remoteCase.owner)) continue;
|
||||||
existingNames.add(remoteCase.name);
|
existingNames.add(remoteCase.name);
|
||||||
const remoteCaseInfo: CaseInfo = {
|
const remoteCaseInfo: CaseInfo = {
|
||||||
name: remoteCase.name,
|
name: remoteCase.name,
|
||||||
@@ -202,6 +224,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
for (const dockerCase of await readDockerCases(CODEMAN_CONFIG_DIR)) {
|
for (const dockerCase of await readDockerCases(CODEMAN_CONFIG_DIR)) {
|
||||||
const host = dockerHostMap.get(dockerCase.hostId);
|
const host = dockerHostMap.get(dockerCase.hostId);
|
||||||
if (!host || !SAFE_CASE_NAME.test(dockerCase.name)) continue;
|
if (!host || !SAFE_CASE_NAME.test(dockerCase.name)) continue;
|
||||||
|
if (!admin && !canAccessOwned(user, dockerCase.owner)) continue;
|
||||||
existingNames.add(dockerCase.name);
|
existingNames.add(dockerCase.name);
|
||||||
const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
|
const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
|
||||||
const dockerCaseInfo: CaseInfo = {
|
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 } }>> => {
|
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||||
const { name, description } = parseBody(CreateCaseSchema, req.body);
|
const { name, description } = parseBody(CreateCaseSchema, req.body);
|
||||||
|
|
||||||
const casePath = validatePathWithinBase(name, CASES_DIR);
|
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
|
||||||
if (!casePath) {
|
if (!casePath) {
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
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.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 host = parseBody(RemoteHostSchema, req.body);
|
||||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||||
if (hosts.some((item) => item.id === host.id)) {
|
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 } };
|
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 { id } = req.params as { id: string };
|
||||||
const host = parseBody(RemoteHostSchema, { ...(req.body as object), id });
|
const host = parseBody(RemoteHostSchema, { ...(req.body as object), id });
|
||||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||||
@@ -296,7 +329,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
return { success: true, data: { host } };
|
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 { id } = req.params as { id: string };
|
||||||
const cases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
const cases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||||
if (cases.some((item) => item.hostId === id)) {
|
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 }>> => {
|
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 hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||||
const host = hosts.find((item) => item.id === remoteCase.hostId);
|
const host = hosts.find((item) => item.id === remoteCase.hostId);
|
||||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||||
@@ -321,7 +356,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
if (
|
if (
|
||||||
remoteCases.some((item) => item.name === remoteCase.name) ||
|
remoteCases.some((item) => item.name === remoteCase.name) ||
|
||||||
linkedCases[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');
|
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.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 host = parseBody(DockerHostSchema, req.body);
|
||||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||||
if (hosts.some((item) => item.id === host.id)) {
|
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 } };
|
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 { id } = req.params as { id: string };
|
||||||
const host = parseBody(DockerHostSchema, { ...(req.body as object), id });
|
const host = parseBody(DockerHostSchema, { ...(req.body as object), id });
|
||||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||||
@@ -365,7 +404,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
return { success: true, data: { host } };
|
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 { id } = req.params as { id: string };
|
||||||
const cases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
const cases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||||
if (cases.some((item) => item.hostId === id)) {
|
if (cases.some((item) => item.hostId === id)) {
|
||||||
@@ -386,7 +427,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
): Promise<
|
): Promise<
|
||||||
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }>
|
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 hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||||
const host = hosts.find((item) => item.id === dockerCase.hostId);
|
const host = hosts.find((item) => item.id === dockerCase.hostId);
|
||||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||||
@@ -396,7 +441,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
if (
|
if (
|
||||||
dockerCases.some((item) => item.name === dockerCase.name) ||
|
dockerCases.some((item) => item.name === dockerCase.name) ||
|
||||||
linkedCases[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');
|
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 body = parseBody(DockerQuickCreateSchema, req.body);
|
||||||
const { name, description } = 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');
|
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||||
|
|
||||||
// Collision across every case kind.
|
// Collision across every case kind.
|
||||||
@@ -525,7 +570,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
availability.error || 'docker daemon is not available'
|
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);
|
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), name);
|
||||||
if (!imageGate.ok) {
|
if (!imageGate.ok) {
|
||||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
|
||||||
@@ -644,7 +695,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
if (
|
if (
|
||||||
dockerCases.some((item) => item.name === newCaseName) ||
|
dockerCases.some((item) => item.name === newCaseName) ||
|
||||||
linkedCases[newCaseName] ||
|
linkedCases[newCaseName] ||
|
||||||
existsSync(join(CASES_DIR, newCaseName))
|
existsSync(join(resolveCasesDir(getAuthUser(req)), newCaseName))
|
||||||
) {
|
) {
|
||||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||||
}
|
}
|
||||||
@@ -681,6 +732,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
hostId,
|
hostId,
|
||||||
hostWorkspacePath: destWorkspacePath,
|
hostWorkspacePath: destWorkspacePath,
|
||||||
containerWorkdir: result.manifest.containerWorkdir,
|
containerWorkdir: result.manifest.containerWorkdir,
|
||||||
|
owner: ownerFor(req),
|
||||||
};
|
};
|
||||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, newCase]);
|
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, newCase]);
|
||||||
ctx.broadcast(SseEvent.DockerImportComplete, { name: newCaseName, path: destWorkspacePath, type: 'docker' });
|
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
|
// 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)) {
|
if (existsSync(casePath)) {
|
||||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
|
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 }>> => {
|
app.delete('/api/cases/:name', async (req): Promise<ApiResponse<{ name: string }>> => {
|
||||||
const { name } = req.params as { 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');
|
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
|
// Case in CASES_DIR — delete the entire directory
|
||||||
const casePath = join(CASES_DIR, name);
|
const casePath = join(resolveCasesDir(getAuthUser(req)), name);
|
||||||
if (!existsSync(casePath)) {
|
if (!existsSync(casePath)) {
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
|
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) => {
|
app.get('/api/cases/:name', async (req) => {
|
||||||
const { name } = req.params as { 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');
|
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)) {
|
if (!existsSync(casePath)) {
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const linked = casePath !== join(CASES_DIR, name);
|
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
path: casePath,
|
path: casePath,
|
||||||
@@ -894,12 +946,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
app.get('/api/cases/:name/fix-plan', async (req) => {
|
app.get('/api/cases/:name/fix-plan', async (req) => {
|
||||||
const { name } = req.params as { 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');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get case path (check linked cases first, then CASES_DIR)
|
// 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');
|
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) => {
|
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
|
||||||
const { caseName } = req.params as { caseName: string };
|
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');
|
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');
|
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
|
// Cache disabled to ensure fresh prompts when starting new plan generations
|
||||||
app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
|
app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
|
||||||
const { caseName, filePath } = req.params as { caseName: string; filePath: string };
|
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');
|
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('Pragma', 'no-cache');
|
||||||
reply.header('Expires', '0');
|
reply.header('Expires', '0');
|
||||||
|
|
||||||
const casePath = await resolveCasePath(caseName);
|
const casePath = await resolveCasePath(caseName, getAuthUser(req));
|
||||||
|
|
||||||
const wizardDir = join(casePath, 'ralph-wizard');
|
const wizardDir = join(casePath, 'ralph-wizard');
|
||||||
|
|
||||||
|
|||||||
@@ -9,33 +9,59 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||||
import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.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 { CronPort } from '../ports/index.js';
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
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 ────────────────────────────────────────────────────────────────
|
// ── Jobs ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/cron/jobs', async () => {
|
app.get('/api/cron/jobs', async (req) => {
|
||||||
return ctx.cron.listJobs();
|
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) => {
|
app.post('/api/cron/jobs', async (req) => {
|
||||||
// No custom errorMessage: surface the schema's field-specific messages
|
// No custom errorMessage: surface the schema's field-specific messages
|
||||||
// (e.g. "runAt is required for a one-time schedule").
|
// (e.g. "runAt is required for a one-time schedule").
|
||||||
const body = parseBody(CronJobSchema, req.body);
|
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) => {
|
app.get('/api/cron/jobs/:id', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const job = ctx.cron.getJob(id);
|
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;
|
return job;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/cron/jobs/:id', async (req) => {
|
app.put('/api/cron/jobs/:id', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
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);
|
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);
|
const job = ctx.cron.updateJob(id, body);
|
||||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||||
return { job };
|
return { job };
|
||||||
@@ -43,7 +69,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
|||||||
|
|
||||||
app.delete('/api/cron/jobs/:id', async (req) => {
|
app.delete('/api/cron/jobs/:id', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
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 createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
@@ -51,6 +77,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
|||||||
|
|
||||||
app.put('/api/cron/jobs/:id/enabled', async (req) => {
|
app.put('/api/cron/jobs/:id/enabled', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
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 { enabled } = parseBody(CronJobEnabledSchema, req.body, 'Invalid request body');
|
||||||
const job = ctx.cron.setEnabled(id, enabled);
|
const job = ctx.cron.setEnabled(id, enabled);
|
||||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
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) => {
|
app.post('/api/cron/jobs/:id/run', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const job = ctx.cron.getJob(id);
|
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);
|
const run = await ctx.cron.runNow(id);
|
||||||
return { run, activeAgents: ctx.cron.countActiveAgents(job.agentType, job.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) => {
|
app.get('/api/sessions/:id/files', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { depth, showHidden } = req.query as { depth?: string; showHidden?: 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 maxDepth = Math.min(parseInt(depth || '5', 10), 10);
|
||||||
const includeHidden = showHidden === 'true';
|
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) => {
|
app.get('/api/sessions/:id/file-content', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: 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) {
|
if (!filePath) {
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
|
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) => {
|
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { path: filePath, download } = req.query as { path?: string; download?: 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) {
|
if (!filePath) {
|
||||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
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.
|
// attachment-history list are layered on separately.
|
||||||
app.post('/api/sessions/:id/attachments', async (req, reply) => {
|
app.post('/api/sessions/:id/attachments', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
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 };
|
const body = (req.body || {}) as { path?: string };
|
||||||
|
|
||||||
if (!body.path || typeof body.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) => {
|
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
|
||||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||||
const { download } = req.query as { download?: 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);
|
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir);
|
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) => {
|
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { path: filePath, lines } = req.query as { path?: string; lines?: 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) {
|
if (!filePath) {
|
||||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
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).
|
// malformed error envelope instead of wrapping it).
|
||||||
app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
|
app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
|
||||||
const { id, streamId } = req.params as { id: string; streamId: string };
|
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);
|
const closed = fileStreamManager.closeStream(streamId);
|
||||||
return { closed };
|
return { closed };
|
||||||
});
|
});
|
||||||
@@ -1024,7 +1024,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = findSessionOrFail(ctx, sessionId);
|
const session = findSessionOrFail(ctx, sessionId, req);
|
||||||
const validated = validateSessionFilePath(session.workingDir, filePath);
|
const validated = validateSessionFilePath(session.workingDir, filePath);
|
||||||
if (!validated) {
|
if (!validated) {
|
||||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||||
|
|||||||
@@ -17,7 +17,15 @@ import {
|
|||||||
PlanTaskUpdateSchema,
|
PlanTaskUpdateSchema,
|
||||||
PlanTaskAddSchema,
|
PlanTaskAddSchema,
|
||||||
} from '../schemas.js';
|
} 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 { SseEvent } from '../sse-events.js';
|
||||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.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.`;
|
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({
|
const session = new Session({
|
||||||
workingDir: process.cwd(),
|
workingDir: process.cwd(),
|
||||||
mux: ctx.mux,
|
mux: ctx.mux,
|
||||||
useMux: false, // No mux needed for one-shot
|
useMux: false, // No mux needed for one-shot
|
||||||
mode: 'claude',
|
mode: 'claude',
|
||||||
|
claudeMode: planClaudeMode,
|
||||||
|
allowedTools: planClaudeModeConfig.allowedTools,
|
||||||
|
owner: planOwner,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use configured model for plan generation, falling back to opus
|
// 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
|
// Determine output directory for saving wizard results
|
||||||
let outputDir: string | undefined;
|
let outputDir: string | undefined;
|
||||||
if (caseName) {
|
if (caseName) {
|
||||||
const casePath = validatePathWithinBase(caseName, CASES_DIR);
|
const casePath = validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)));
|
||||||
if (casePath && existsSync(casePath)) {
|
if (casePath && existsSync(casePath)) {
|
||||||
outputDir = join(casePath, 'ralph-wizard');
|
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) => {
|
app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => {
|
||||||
const { id, taskId } = req.params as { id: string; taskId: string };
|
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;
|
const tracker = session.ralphTracker;
|
||||||
if (!tracker) {
|
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) => {
|
app.post('/api/sessions/:id/plan/checkpoint', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
const tracker = session.ralphTracker;
|
const tracker = session.ralphTracker;
|
||||||
if (!tracker) {
|
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) => {
|
app.get('/api/sessions/:id/plan/history', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
const tracker = session.ralphTracker;
|
const tracker = session.ralphTracker;
|
||||||
if (!tracker) {
|
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) => {
|
app.post('/api/sessions/:id/plan/rollback/:version', async (req) => {
|
||||||
const { id, version } = req.params as { id: string; version: string };
|
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;
|
const tracker = session.ralphTracker;
|
||||||
if (!tracker) {
|
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) => {
|
app.post('/api/sessions/:id/plan/task', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
const tracker = session.ralphTracker;
|
const tracker = session.ralphTracker;
|
||||||
if (!tracker) {
|
if (!tracker) {
|
||||||
|
|||||||
@@ -13,13 +13,22 @@ import { Session, isExternalCliMode } from '../../session.js';
|
|||||||
import { RespawnController } from '../../respawn-controller.js';
|
import { RespawnController } from '../../respawn-controller.js';
|
||||||
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
||||||
import { SseEvent } from '../sse-events.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 { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
import { buildRalphLoopPrompt } from '../../prompts/index.js';
|
import { buildRalphLoopPrompt } from '../../prompts/index.js';
|
||||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||||
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
|
||||||
|
|
||||||
export function registerRalphRoutes(
|
export function registerRalphRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
@@ -42,7 +51,7 @@ export function registerRalphRoutes(
|
|||||||
reset?: boolean | 'full';
|
reset?: boolean | 'full';
|
||||||
disableAutoEnable?: boolean;
|
disableAutoEnable?: boolean;
|
||||||
};
|
};
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
|
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
|
||||||
if (isExternalCliMode(session.mode)) {
|
if (isExternalCliMode(session.mode)) {
|
||||||
@@ -118,7 +127,7 @@ export function registerRalphRoutes(
|
|||||||
// Reset circuit breaker for Ralph tracker
|
// Reset circuit breaker for Ralph tracker
|
||||||
app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
|
app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
session.ralphTracker.resetCircuitBreaker();
|
session.ralphTracker.resetCircuitBreaker();
|
||||||
return {};
|
return {};
|
||||||
@@ -127,7 +136,7 @@ export function registerRalphRoutes(
|
|||||||
// Get Ralph status block and circuit breaker state
|
// Get Ralph status block and circuit breaker state
|
||||||
app.get('/api/sessions/:id/ralph-status', async (req) => {
|
app.get('/api/sessions/:id/ralph-status', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -147,7 +156,7 @@ export function registerRalphRoutes(
|
|||||||
// Generate @fix_plan.md content from todos
|
// Generate @fix_plan.md content from todos
|
||||||
app.get('/api/sessions/:id/fix-plan', async (req) => {
|
app.get('/api/sessions/:id/fix-plan', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
const content = session.ralphTracker.generateFixPlanMarkdown();
|
const content = session.ralphTracker.generateFixPlanMarkdown();
|
||||||
return {
|
return {
|
||||||
@@ -163,7 +172,7 @@ export function registerRalphRoutes(
|
|||||||
app.post('/api/sessions/:id/fix-plan/import', async (req) => {
|
app.post('/api/sessions/:id/fix-plan/import', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { content } = parseBody(FixPlanImportSchema, req.body, 'Invalid request body');
|
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);
|
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
|
||||||
ctx.persistSessionState(session);
|
ctx.persistSessionState(session);
|
||||||
@@ -180,7 +189,7 @@ export function registerRalphRoutes(
|
|||||||
// Write @fix_plan.md to session's working directory
|
// Write @fix_plan.md to session's working directory
|
||||||
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
const workingDir = session.workingDir;
|
const workingDir = session.workingDir;
|
||||||
if (!workingDir) {
|
if (!workingDir) {
|
||||||
@@ -207,7 +216,7 @@ export function registerRalphRoutes(
|
|||||||
// Read @fix_plan.md from session's working directory and import
|
// Read @fix_plan.md from session's working directory and import
|
||||||
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
|
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
const workingDir = session.workingDir;
|
const workingDir = session.workingDir;
|
||||||
if (!workingDir) {
|
if (!workingDir) {
|
||||||
@@ -246,7 +255,7 @@ export function registerRalphRoutes(
|
|||||||
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
|
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
const workingDir = session.workingDir;
|
const workingDir = session.workingDir;
|
||||||
if (!workingDir) {
|
if (!workingDir) {
|
||||||
@@ -271,13 +280,9 @@ export function registerRalphRoutes(
|
|||||||
|
|
||||||
// Start a Ralph Loop — creates a new session with autonomous cycling
|
// Start a Ralph Loop — creates a new session with autonomous cycling
|
||||||
app.post('/api/ralph-loop/start', async (req): Promise<ApiResponse> => {
|
app.post('/api/ralph-loop/start', async (req): Promise<ApiResponse> => {
|
||||||
// Prevent unbounded session creation
|
const rlOwner = ownerFor(req);
|
||||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
const capMsg = sessionCapacityMessage(ctx.sessions, rlOwner);
|
||||||
return createErrorResponse(
|
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||||
ApiErrorCode.SESSION_BUSY,
|
|
||||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
caseName,
|
caseName,
|
||||||
@@ -290,11 +295,13 @@ export function registerRalphRoutes(
|
|||||||
effort,
|
effort,
|
||||||
} = parseBody(RalphLoopStartSchema, req.body);
|
} = 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
|
// Security: Path traversal protection
|
||||||
const rlResolvedPath = resolve(casePath);
|
const rlResolvedPath = resolve(casePath);
|
||||||
const rlResolvedBase = resolve(CASES_DIR);
|
const rlResolvedBase = resolve(rlCasesBase);
|
||||||
const rlRelPath = relative(rlResolvedBase, rlResolvedPath);
|
const rlRelPath = relative(rlResolvedBase, rlResolvedPath);
|
||||||
if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) {
|
if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) {
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||||
@@ -324,6 +331,7 @@ export function registerRalphRoutes(
|
|||||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||||
const rlModelConfig = await ctx.getModelConfig();
|
const rlModelConfig = await ctx.getModelConfig();
|
||||||
const rlClaudeModeConfig = await ctx.getClaudeModeConfig();
|
const rlClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||||
|
const rlClaudeMode = await resolveClaudeModeForUsername(rlClaudeModeConfig.claudeMode, rlOwner);
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: casePath,
|
workingDir: casePath,
|
||||||
mux: ctx.mux,
|
mux: ctx.mux,
|
||||||
@@ -331,10 +339,11 @@ export function registerRalphRoutes(
|
|||||||
mode: 'claude',
|
mode: 'claude',
|
||||||
niceConfig,
|
niceConfig,
|
||||||
model: rlModelConfig?.defaultModel || undefined,
|
model: rlModelConfig?.defaultModel || undefined,
|
||||||
claudeMode: rlClaudeModeConfig.claudeMode,
|
claudeMode: rlClaudeMode,
|
||||||
allowedTools: rlClaudeModeConfig.allowedTools,
|
allowedTools: rlClaudeModeConfig.allowedTools,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
|
owner: rlOwner,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configure Ralph tracker
|
// Configure Ralph tracker
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function registerRespawnRoutes(
|
|||||||
if (req.body) {
|
if (req.body) {
|
||||||
body = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
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)
|
// Respawn is not supported for external-CLI sessions (opencode/codex)
|
||||||
if (isExternalCliMode(session.mode)) {
|
if (isExternalCliMode(session.mode)) {
|
||||||
@@ -160,7 +160,7 @@ export function registerRespawnRoutes(
|
|||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
// Validate respawn config to prevent arbitrary field injection
|
// Validate respawn config to prevent arbitrary field injection
|
||||||
const config = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
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);
|
const controller = ctx.respawnControllers.get(id);
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ export function registerRespawnRoutes(
|
|||||||
respawnConfig?: Partial<RespawnConfig>;
|
respawnConfig?: Partial<RespawnConfig>;
|
||||||
durationMinutes?: number;
|
durationMinutes?: number;
|
||||||
};
|
};
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
if (session.isBusy()) {
|
if (session.isBusy()) {
|
||||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||||
@@ -299,7 +299,7 @@ export function registerRespawnRoutes(
|
|||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||||
}
|
}
|
||||||
const body = reResult.data as { config?: Partial<RespawnConfig>; durationMinutes?: number };
|
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)
|
// Respawn is not supported for external-CLI sessions (opencode/codex)
|
||||||
if (isExternalCliMode(session.mode)) {
|
if (isExternalCliMode(session.mode)) {
|
||||||
|
|||||||
@@ -38,13 +38,21 @@ import {
|
|||||||
} from '../schemas.js';
|
} from '../schemas.js';
|
||||||
import {
|
import {
|
||||||
autoConfigureRalph,
|
autoConfigureRalph,
|
||||||
|
canAccessOwned,
|
||||||
CASES_DIR,
|
CASES_DIR,
|
||||||
findSessionOrFail,
|
findSessionOrFail,
|
||||||
|
getAuthUser,
|
||||||
|
isWorkingDirAllowed,
|
||||||
|
ownerFor,
|
||||||
parseBody,
|
parseBody,
|
||||||
persistAndBroadcastSession,
|
persistAndBroadcastSession,
|
||||||
|
resolveCasesDir,
|
||||||
|
sessionCapacityMessage,
|
||||||
SETTINGS_PATH,
|
SETTINGS_PATH,
|
||||||
validatePathWithinBase,
|
validatePathWithinBase,
|
||||||
} from '../route-helpers.js';
|
} 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 { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||||
import {
|
import {
|
||||||
writeHooksConfig,
|
writeHooksConfig,
|
||||||
@@ -67,7 +75,6 @@ import {
|
|||||||
type MuxStatInput,
|
type MuxStatInput,
|
||||||
} from '../../services/unified-session-service.js';
|
} from '../../services/unified-session-service.js';
|
||||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.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 { RunSummaryTracker } from '../../run-summary.js';
|
||||||
|
|
||||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||||
@@ -285,24 +292,38 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
// ========== Session Listing ==========
|
// ========== Session Listing ==========
|
||||||
|
|
||||||
app.get('/api/sessions', async () => {
|
app.get('/api/sessions', async (req) => {
|
||||||
return ctx.getLightSessionsState();
|
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 ==========
|
// ========== Session Creation ==========
|
||||||
|
|
||||||
app.post('/api/sessions', async (req) => {
|
app.post('/api/sessions', async (req) => {
|
||||||
// Prevent unbounded session creation
|
const owner = ownerFor(req);
|
||||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
// Global + per-user session cap.
|
||||||
return createErrorResponse(
|
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
||||||
ApiErrorCode.OPERATION_FAILED,
|
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
|
||||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = parseBody(CreateSessionSchema, req.body);
|
const body = parseBody(CreateSessionSchema, req.body);
|
||||||
const workingDir = body.workingDir || process.cwd();
|
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
|
// Validate workingDir exists and is a directory
|
||||||
if (body.workingDir) {
|
if (body.workingDir) {
|
||||||
try {
|
try {
|
||||||
@@ -320,16 +341,18 @@ export function registerSessionRoutes(
|
|||||||
// For keys the caller is actively setting, strip any stale disk entry a prior
|
// For keys the caller is actively setting, strip any stale disk entry a prior
|
||||||
// Codeman version may have written. Scope limited to:
|
// Codeman version may have written. Scope limited to:
|
||||||
// - Claude mode (OpenCode/Codex/Gemini don't read .claude/settings.local.json)
|
// - Claude mode (OpenCode/Codex/Gemini don't read .claude/settings.local.json)
|
||||||
// - workingDir inside CASES_DIR (Codeman's managed territory — we never mutate
|
// - workingDir inside CASES_DIR / the per-user case space (Codeman's managed
|
||||||
// .claude/settings.local.json in arbitrary user repos that POST /api/sessions
|
// territory — we never mutate .claude/settings.local.json in arbitrary user
|
||||||
// can target, because those may have hand-authored values).
|
// repos that POST /api/sessions can target, as those may have hand-authored
|
||||||
|
// values).
|
||||||
|
const managedCasesBase = resolveCasesDir(getAuthUser(req));
|
||||||
const canStripDisk =
|
const canStripDisk =
|
||||||
body.mode !== 'opencode' &&
|
body.mode !== 'opencode' &&
|
||||||
body.mode !== 'codex' &&
|
body.mode !== 'codex' &&
|
||||||
body.mode !== 'gemini' &&
|
body.mode !== 'gemini' &&
|
||||||
body.envOverrides &&
|
body.envOverrides &&
|
||||||
Object.keys(body.envOverrides).length > 0 &&
|
Object.keys(body.envOverrides).length > 0 &&
|
||||||
workingDir.startsWith(CASES_DIR + '/');
|
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
|
||||||
if (canStripDisk) {
|
if (canStripDisk) {
|
||||||
await stripCaseEnvKeys(workingDir, Object.keys(body.envOverrides!));
|
await stripCaseEnvKeys(workingDir, Object.keys(body.envOverrides!));
|
||||||
}
|
}
|
||||||
@@ -438,6 +461,8 @@ export function registerSessionRoutes(
|
|||||||
? modelConfig?.defaultModel || undefined
|
? modelConfig?.defaultModel || undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
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 terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir,
|
workingDir,
|
||||||
@@ -447,7 +472,7 @@ export function registerSessionRoutes(
|
|||||||
useMux: true,
|
useMux: true,
|
||||||
niceConfig: globalNice,
|
niceConfig: globalNice,
|
||||||
model,
|
model,
|
||||||
claudeMode: claudeModeConfig.claudeMode,
|
claudeMode: effectiveClaudeMode,
|
||||||
allowedTools: claudeModeConfig.allowedTools,
|
allowedTools: claudeModeConfig.allowedTools,
|
||||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||||
codexConfig: mode === 'codex' ? body.codexConfig : undefined,
|
codexConfig: mode === 'codex' ? body.codexConfig : undefined,
|
||||||
@@ -456,6 +481,7 @@ export function registerSessionRoutes(
|
|||||||
envOverrides: body.envOverrides,
|
envOverrides: body.envOverrides,
|
||||||
effort: body.effort,
|
effort: body.effort,
|
||||||
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
||||||
|
owner,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.addSession(session);
|
ctx.addSession(session);
|
||||||
@@ -476,7 +502,7 @@ export function registerSessionRoutes(
|
|||||||
app.put('/api/sessions/:id/name', async (req) => {
|
app.put('/api/sessions/:id/name', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const body = parseBody(SessionNameSchema, req.body, 'Invalid request body');
|
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);
|
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
||||||
session.name = name;
|
session.name = name;
|
||||||
@@ -491,7 +517,7 @@ export function registerSessionRoutes(
|
|||||||
app.put('/api/sessions/:id/color', async (req) => {
|
app.put('/api/sessions/:id/color', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const body = parseBody(SessionColorSchema, req.body, 'Invalid request body');
|
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'];
|
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
||||||
if (!validColors.includes(body.color)) {
|
if (!validColors.includes(body.color)) {
|
||||||
@@ -538,7 +564,7 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.get('/api/sessions/:id', async (req) => {
|
app.get('/api/sessions/:id', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
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.
|
// 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).
|
// 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) => {
|
app.get('/api/sessions/:id/output', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -569,7 +595,7 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.get('/api/sessions/:id/ralph-state', async (req) => {
|
app.get('/api/sessions/:id/ralph-state', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -585,7 +611,7 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.get('/api/sessions/:id/run-summary', async (req) => {
|
app.get('/api/sessions/:id/run-summary', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
const tracker = ctx.runSummaryTrackers.get(id);
|
const tracker = ctx.runSummaryTrackers.get(id);
|
||||||
if (!tracker) {
|
if (!tracker) {
|
||||||
@@ -605,7 +631,7 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.get('/api/sessions/:id/active-tools', async (req) => {
|
app.get('/api/sessions/:id/active-tools', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -624,7 +650,7 @@ export function registerSessionRoutes(
|
|||||||
app.post('/api/sessions/:id/run', async (req) => {
|
app.post('/api/sessions/:id/run', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { prompt } = parseBody(RunPromptSchema, req.body);
|
const { prompt } = parseBody(RunPromptSchema, req.body);
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
if (session.isBusy()) {
|
if (session.isBusy()) {
|
||||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||||
@@ -651,7 +677,7 @@ export function registerSessionRoutes(
|
|||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||||
}
|
}
|
||||||
const { clearBreaker } = bodyResult.data;
|
const { clearBreaker } = bodyResult.data;
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
if (session.isBusy()) {
|
if (session.isBusy()) {
|
||||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||||
@@ -707,7 +733,7 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.post('/api/sessions/:id/shell', async (req) => {
|
app.post('/api/sessions/:id/shell', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
if (session.isBusy()) {
|
if (session.isBusy()) {
|
||||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||||
@@ -740,7 +766,7 @@ export function registerSessionRoutes(
|
|||||||
app.post('/api/sessions/:id/input', async (req) => {
|
app.post('/api/sessions/:id/input', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { input, useMux, seq, clientId } = parseBody(SessionInputWithLimitSchema, req.body);
|
const { input, useMux, seq, clientId } = parseBody(SessionInputWithLimitSchema, req.body);
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
const inputStr = String(input);
|
const inputStr = String(input);
|
||||||
if (inputStr.length > MAX_INPUT_LENGTH) {
|
if (inputStr.length > MAX_INPUT_LENGTH) {
|
||||||
@@ -799,7 +825,7 @@ export function registerSessionRoutes(
|
|||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
const muxName = session.muxName;
|
const muxName = session.muxName;
|
||||||
if (!muxName) {
|
if (!muxName) {
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
|
||||||
@@ -831,7 +857,7 @@ export function registerSessionRoutes(
|
|||||||
app.post('/api/sessions/:id/resize', async (req) => {
|
app.post('/api/sessions/:id/resize', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body);
|
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 });
|
session.resize(cols, rows, { viewportType, force });
|
||||||
return {};
|
return {};
|
||||||
@@ -917,7 +943,7 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.get('/api/sessions/:id/last-response', async (req) => {
|
app.get('/api/sessions/:id/last-response', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
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
|
// Codex sessions don't write to ~/.claude/projects — their transcripts
|
||||||
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
|
// 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) => {
|
app.get('/api/sessions/:id/terminal', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const query = req.query as { tail?: string; full?: 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
|
// `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
|
// 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) => {
|
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
|
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);
|
session.setAutoClear(body.enabled, body.threshold);
|
||||||
persistAndBroadcastSession(ctx, session);
|
persistAndBroadcastSession(ctx, session);
|
||||||
@@ -1522,7 +1548,7 @@ export function registerSessionRoutes(
|
|||||||
app.post('/api/sessions/:id/auto-compact', async (req) => {
|
app.post('/api/sessions/:id/auto-compact', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
|
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);
|
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||||||
persistAndBroadcastSession(ctx, session);
|
persistAndBroadcastSession(ctx, session);
|
||||||
@@ -1544,7 +1570,7 @@ export function registerSessionRoutes(
|
|||||||
app.post('/api/sessions/:id/auto-resume', async (req) => {
|
app.post('/api/sessions/:id/auto-resume', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
|
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
session.setAutoResume(body.enabled);
|
session.setAutoResume(body.enabled);
|
||||||
persistAndBroadcastSession(ctx, session);
|
persistAndBroadcastSession(ctx, session);
|
||||||
@@ -1565,7 +1591,7 @@ export function registerSessionRoutes(
|
|||||||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
|
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
if (body.enabled) {
|
if (body.enabled) {
|
||||||
imageWatcher.watchSession(session.id, session.workingDir);
|
imageWatcher.watchSession(session.id, session.workingDir);
|
||||||
@@ -1590,7 +1616,7 @@ export function registerSessionRoutes(
|
|||||||
app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
|
const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
session.flickerFilterEnabled = body.enabled;
|
session.flickerFilterEnabled = body.enabled;
|
||||||
persistAndBroadcastSession(ctx, session);
|
persistAndBroadcastSession(ctx, session);
|
||||||
@@ -1610,13 +1636,9 @@ export function registerSessionRoutes(
|
|||||||
// ========== Quick Run ==========
|
// ========== Quick Run ==========
|
||||||
|
|
||||||
app.post('/api/run', async (req) => {
|
app.post('/api/run', async (req) => {
|
||||||
// Prevent unbounded session creation
|
const runOwner = ownerFor(req);
|
||||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
const capMsg = sessionCapacityMessage(ctx.sessions, runOwner);
|
||||||
return createErrorResponse(
|
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||||
ApiErrorCode.SESSION_BUSY,
|
|
||||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
prompt,
|
prompt,
|
||||||
@@ -1629,6 +1651,11 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
const dir = workingDir || process.cwd();
|
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
|
// Validate workingDir exists and is a directory
|
||||||
if (workingDir) {
|
if (workingDir) {
|
||||||
try {
|
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.addSession(session);
|
||||||
ctx.store.incrementSessionsCreated();
|
ctx.store.incrementSessionsCreated();
|
||||||
ctx.persistSessionState(session);
|
ctx.persistSessionState(session);
|
||||||
@@ -1671,13 +1708,9 @@ export function registerSessionRoutes(
|
|||||||
// ========== Quick Start ==========
|
// ========== Quick Start ==========
|
||||||
|
|
||||||
app.post('/api/quick-start', async (req) => {
|
app.post('/api/quick-start', async (req) => {
|
||||||
// Prevent unbounded session creation
|
const owner = ownerFor(req);
|
||||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
||||||
return createErrorResponse(
|
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||||
ApiErrorCode.SESSION_BUSY,
|
|
||||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
caseName = 'testcase',
|
caseName = 'testcase',
|
||||||
@@ -1690,6 +1723,11 @@ export function registerSessionRoutes(
|
|||||||
effort,
|
effort,
|
||||||
} = parseBody(QuickStartSchema, req.body);
|
} = 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,
|
// 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
|
// 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.
|
// would wrongly reject a machine that hasn't got the CLI installed locally.
|
||||||
@@ -1834,7 +1872,8 @@ export function registerSessionRoutes(
|
|||||||
} catch {
|
} catch {
|
||||||
// File missing or unparseable — treat as empty registry
|
// 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) {
|
if (!casePath) {
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||||
}
|
}
|
||||||
@@ -1922,6 +1961,7 @@ export function registerSessionRoutes(
|
|||||||
? qsModelConfig?.defaultModel || undefined
|
? qsModelConfig?.defaultModel || undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||||
|
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
|
||||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: resolvedCasePath,
|
workingDir: resolvedCasePath,
|
||||||
@@ -1931,8 +1971,9 @@ export function registerSessionRoutes(
|
|||||||
mode: mode,
|
mode: mode,
|
||||||
niceConfig: niceConfig,
|
niceConfig: niceConfig,
|
||||||
model: qsModel,
|
model: qsModel,
|
||||||
claudeMode: qsClaudeModeConfig.claudeMode,
|
claudeMode: qsEffectiveClaudeMode,
|
||||||
allowedTools: qsClaudeModeConfig.allowedTools,
|
allowedTools: qsClaudeModeConfig.allowedTools,
|
||||||
|
owner,
|
||||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||||
codexConfig: mode === 'codex' ? codexConfig : undefined,
|
codexConfig: mode === 'codex' ? codexConfig : undefined,
|
||||||
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
|
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
|
||||||
@@ -2414,7 +2455,37 @@ export function registerSessionRoutes(
|
|||||||
// Mux stats are optional.
|
// 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 offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
|
||||||
const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
|
const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
|
||||||
return filterAndPaginate(merged, {
|
return filterAndPaginate(merged, {
|
||||||
@@ -2473,7 +2544,7 @@ export function registerSessionRoutes(
|
|||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
|
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()) {
|
if (!req.isMultipart()) {
|
||||||
reply.code(400);
|
reply.code(400);
|
||||||
|
|||||||
@@ -758,7 +758,7 @@ export function registerSystemRoutes(
|
|||||||
|
|
||||||
app.get('/api/sessions/:id/cpu-limit', async (req) => {
|
app.get('/api/sessions/:id/cpu-limit', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
return {
|
return {
|
||||||
nice: session.niceConfig,
|
nice: session.niceConfig,
|
||||||
};
|
};
|
||||||
@@ -766,7 +766,7 @@ export function registerSystemRoutes(
|
|||||||
|
|
||||||
app.post('/api/sessions/:id/cpu-limit', async (req) => {
|
app.post('/api/sessions/:id/cpu-limit', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
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>;
|
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) => {
|
app.get('/api/sessions/:id/subagents', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
const subagents = subagentWatcher.getSubagentsForSession(session.workingDir);
|
const subagents = subagentWatcher.getSubagentsForSession(session.workingDir);
|
||||||
return { success: true, data: subagents };
|
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 type { ScheduledRun } from './ports/index.js';
|
||||||
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
|
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
|
||||||
import { isMultiUserMode } from '../config/multiuser.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 { installRouteErrorHandler } from './route-error-handler.js';
|
||||||
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
|
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
|
||||||
import {
|
import {
|
||||||
@@ -2238,7 +2238,16 @@ export class WebServer extends EventEmitter {
|
|||||||
const sessionName = savedState?.name || muxSession.name || muxSession.muxName;
|
const sessionName = savedState?.name || muxSession.name || muxSession.muxName;
|
||||||
|
|
||||||
// Create a session object for this mux session
|
// 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
|
// Recover envOverrides from the internal __envOverrides field written by
|
||||||
// session-manager (see updateSessionState). Cast to read the non-public field.
|
// session-manager (see updateSessionState). Cast to read the non-public field.
|
||||||
// Note: a legacy CLAUDE_CODE_EFFORT_LEVEL entry is auto-migrated to `effort`
|
// 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
|
// MuxSession.docker; state.json carries SessionState.docker), so recovery
|
||||||
// rebuilds the `docker exec` launch instead of a broken local command.
|
// rebuilds the `docker exec` launch instead of a broken local command.
|
||||||
docker: muxSession.docker ?? savedState?.docker,
|
docker: muxSession.docker ?? savedState?.docker,
|
||||||
|
owner: recoveredOwner,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
// 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