mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
fix(multiuser): close cross-user web-layer scoping holes found in review
The opt-in multi-user feature's only enforcement is web-layer scoping (all sessions share one OS account). An adversarial review found 8 critical + 7 high cross-user holes that defeated it, plus mediums; all fixed here. Single-user (flag-off) behavior stays byte-identical apart from documented consistency deltas. Ownership / confinement: - DELETE /api/sessions (bulk) + /:id now owner-scope / findSessionOrFail - quick-start, cron (create+fire), scheduled runs confine workingDir to the owner's space; case link/docker-link/docker-import confine the host path - resolveCasePath no longer resolves linked cases for non-admins; foreign remote/docker cases are skipped (fall through to the caller's own local case) - history, subagents/workflows, mux-sessions, orchestrator, cron run-history, away-digest, and remote/docker host reads are owner- or admin-scoped Permission policy (section 6.3): - non-granted users are downgraded at every spawn site incl. legacy /api/scheduled, PlanOrchestrator one-shots, remote launch, and the cron-fire gemini/codex bypass switches; resolveClaudeModeForUsername now fails closed Auth / store: - verify-first login throttle (a correct password is never locked out), /ws terminal subject to the change-password lockbox, cookie fast-path re-validates identity live, role/grant changes revoke sessions, admin delete runs the last-admin guard before any teardown - users.json: distinguish missing (ENOENT) from corrupt/unreadable so a bad read can't overwrite all accounts; unique per-process temp write path Event streams: - debounced session:updated + batched task:updated, clipboard, and push notifications route by owner (fail closed); getLightState hides machine-wide globalStats from non-admins Tests: two suites updated to assert the fixed (secure) behavior. tsc, eslint, and test:ci all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ import {
|
||||
} from '../../config/auth-config.js';
|
||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
|
||||
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
|
||||
|
||||
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
|
||||
@@ -114,6 +114,9 @@ function checkHookSecretBypass(
|
||||
function isPasswordChangeExempt(req: FastifyRequest): boolean {
|
||||
const url = (req.url ?? '').split('?')[0];
|
||||
if (url === '/api/me' || url === '/api/me/password') return true;
|
||||
// Security: the WebSocket terminal (/ws/...) is a functional channel, not a static
|
||||
// asset, so it must NOT be exempt, or a locked user keeps a working terminal.
|
||||
if (url.startsWith('/ws/')) return false;
|
||||
return !url.startsWith('/api/');
|
||||
}
|
||||
|
||||
@@ -344,13 +347,40 @@ function registerMultiUserAuthHook(
|
||||
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
||||
const record = sessionToken ? authSessions.get(sessionToken) : undefined;
|
||||
if (record && record.username) {
|
||||
req.authUser = { username: record.username, role: record.role ?? 'user' };
|
||||
// Security: re-validate the cookie identity against the store on every request so
|
||||
// an out-of-band mutation the in-memory map can't see (the `codeman users` CLI,
|
||||
// a separate process, deleting/disabling/demoting a user) takes effect promptly
|
||||
// instead of riding the 24h cookie. findUser is cached ~1s, so this is cheap.
|
||||
let live: Awaited<ReturnType<typeof findUser>>;
|
||||
try {
|
||||
live = await findUser(record.username);
|
||||
} catch {
|
||||
// The store is transiently unreadable/corrupt (readUsers throws on a non-ENOENT
|
||||
// read, #23). Fall back to the cookie's snapshot for THIS request rather than
|
||||
// 500-ing an already-authenticated client (pre-#24 behaviour); a persistently
|
||||
// corrupt store still fails all WRITES loudly at the mutator/bootstrap layer.
|
||||
req.authUser = { username: record.username, role: record.role ?? 'user' };
|
||||
setSessionCookie(reply, sessionToken!);
|
||||
enforcePasswordChange(req, reply, !!record.mustChangePassword);
|
||||
return;
|
||||
}
|
||||
if (!live || live.disabled) {
|
||||
authSessions.delete(sessionToken!);
|
||||
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
||||
reply.code(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
// Trust the LIVE role/mustChangePassword, not the (possibly stale) cookie snapshot
|
||||
// (also defends #9/#13: a CLI demotion is reflected without a revoke).
|
||||
req.authUser = { username: live.username, role: live.role };
|
||||
setSessionCookie(reply, sessionToken!); // sliding re-issue
|
||||
enforcePasswordChange(req, reply, !!record.mustChangePassword);
|
||||
enforcePasswordChange(req, reply, !!live.mustChangePassword);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Basic Auth against the user store (scrypt verify).
|
||||
// Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the
|
||||
// per-username bucket here; see below).
|
||||
const ipFail = authFailures.get(clientIp) ?? 0;
|
||||
if (ipFail >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, authFailures, clientIp);
|
||||
@@ -359,11 +389,10 @@ function registerMultiUserAuthHook(
|
||||
const creds = parseBasicAuth(req.headers.authorization);
|
||||
if (creds) {
|
||||
const normUser = creds.username.trim().toLowerCase();
|
||||
const uFail = userFailures.get(normUser) ?? 0;
|
||||
if (uFail >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, userFailures, normUser);
|
||||
return;
|
||||
}
|
||||
// Security: VERIFY FIRST, then throttle only FAILED attempts. Consulting the
|
||||
// per-username bucket before verifying let throwaway IPs lock out a known account
|
||||
// (incl. admin) even with the correct password. A correct password must always
|
||||
// win and self-heal both buckets, regardless of the username-failure count.
|
||||
const result = await verifyPassword(creds.username, creds.password);
|
||||
if (result) {
|
||||
const { user, needsRehash: rehash } = result;
|
||||
@@ -388,9 +417,23 @@ function registerMultiUserAuthHook(
|
||||
enforcePasswordChange(req, reply, !!user.mustChangePassword);
|
||||
return;
|
||||
}
|
||||
userFailures.set(normUser, uFail + 1);
|
||||
// Failed guess: count it against BOTH buckets. Once the per-username bucket
|
||||
// reaches the cap, further FAILED attempts get 429 (throttles distributed
|
||||
// brute-force), but this path is only reached on a wrong password, so it can
|
||||
// never deny a correct one.
|
||||
const uFail = (userFailures.get(normUser) ?? 0) + 1;
|
||||
userFailures.set(normUser, uFail);
|
||||
authFailures.set(clientIp, ipFail + 1);
|
||||
if (uFail >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, userFailures, normUser);
|
||||
return;
|
||||
}
|
||||
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
|
||||
reply.code(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
// No credentials presented: count against the per-IP bucket and challenge.
|
||||
authFailures.set(clientIp, ipFail + 1);
|
||||
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
|
||||
reply.code(401).send('Unauthorized');
|
||||
|
||||
@@ -13,9 +13,12 @@ export interface AuthSessionRecord {
|
||||
method: 'qr' | 'basic';
|
||||
/**
|
||||
* Multi-user identity carried by the cookie (single-user leaves these unset).
|
||||
* Snapshotted at mint time; state transitions that would change them (password
|
||||
* reset, disable, delete) revoke the user's sessions so a stale snapshot can't
|
||||
* outlive the change. See docs/multi-user-plan.md section 5.
|
||||
* Snapshotted at mint time. Authorization-relevant admin changes (password reset,
|
||||
* disable, delete, role change, bypass-grant change) revoke the user's sessions so
|
||||
* a stale snapshot can't outlive the change; additionally the cookie fast-path
|
||||
* re-reads role/disabled/mustChangePassword live from the store each request, so an
|
||||
* out-of-band CLI mutation also takes effect promptly. See docs/multi-user-plan.md
|
||||
* section 5.
|
||||
*/
|
||||
username?: string;
|
||||
role?: 'admin' | 'user';
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface ScheduledRun {
|
||||
completedTasks: number;
|
||||
totalCost: number;
|
||||
logs: string[];
|
||||
/** Multi-user owner (username) — undefined in single-user mode. Used to scope
|
||||
* list/delete and to downgrade the spawned Session's permission mode. */
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
export interface InfraPort {
|
||||
@@ -33,6 +36,6 @@ export interface InfraPort {
|
||||
readonly teamWatcher: TeamWatcher;
|
||||
readonly tunnelManager: TunnelManager;
|
||||
readonly pushStore: PushSubscriptionStore;
|
||||
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun>;
|
||||
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number, owner?: string): Promise<ScheduledRun>;
|
||||
stopScheduledRun(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import type { AuthSessionRecord } from './ports/auth-port.js';
|
||||
import type { StaleExpirationMap } from '../utils/index.js';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js';
|
||||
import { SYNTHETIC_ADMIN } from '../user-store.js';
|
||||
import { SYNTHETIC_ADMIN, findUser } from '../user-store.js';
|
||||
|
||||
// Shared path constants used across route modules. CASES_DIR (project folders)
|
||||
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
|
||||
@@ -166,6 +166,22 @@ export function isWorkingDirAllowed(user: AuthUser, workingDir: string): boolean
|
||||
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Username-keyed variant of `isWorkingDirAllowed` for spawn sites that only carry
|
||||
* an owner username (cron fire-time, scheduled-run loop) rather than a live request.
|
||||
* Resolves the owner's role from the store; a missing/deleted user is treated as a
|
||||
* non-privileged regular user (fails closed to their deterministic case space).
|
||||
* No-op (true) in single-user mode or for an unset owner.
|
||||
*/
|
||||
export async function isWorkingDirAllowedForUsername(
|
||||
username: string | undefined,
|
||||
workingDir: string
|
||||
): Promise<boolean> {
|
||||
if (!isMultiUserMode() || !username) return true;
|
||||
const user = await findUser(username);
|
||||
return isWorkingDirAllowed({ username, role: user?.role ?? 'user' }, workingDir);
|
||||
}
|
||||
|
||||
/** 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';
|
||||
|
||||
@@ -134,8 +134,12 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
}
|
||||
try {
|
||||
const user = await updateUser(username, parsed.data);
|
||||
// Disabling revokes the user's cookie sessions.
|
||||
if (parsed.data.disabled) revokeUserSessions(ctx.authSessions, username);
|
||||
// Security: revoke the target's cookie sessions on ANY successful update. role,
|
||||
// disabled, and canBypassPermissions are all authorization-relevant, and the
|
||||
// cookie snapshots role, so a stale cookie could otherwise retain old privileges
|
||||
// (a demoted admin staying admin). Idempotent, affects only the target, and
|
||||
// forces a re-auth that re-snapshots the new record.
|
||||
revokeUserSessions(ctx.authSessions, user.username);
|
||||
audit(req, 'user.update', user.username, parsed.data);
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { user: toPublicUser(user) } };
|
||||
@@ -177,14 +181,18 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
const parsed = DeleteUserSchema.safeParse(req.body ?? {});
|
||||
const deleteSpace = parsed.success ? parsed.data.deleteSpace : false;
|
||||
try {
|
||||
// Kill the user's live sessions first (normal kill flow, incl. docker/remote
|
||||
// teardown), before removing the record.
|
||||
// Security: validate BEFORE any teardown. deleteUser runs the authoritative
|
||||
// existence + last-admin guard under lock with no side effects, so a refusal
|
||||
// (409 LAST_ADMIN / 404 USER_NOT_FOUND) leaves the user's live sessions and
|
||||
// cookies untouched. Only after it succeeds do we irreversibly kill sessions and
|
||||
// revoke cookies. (owned is captured from the in-memory map, independent of the
|
||||
// record, so it is safe to read before the delete.)
|
||||
const owned = [...ctx.sessions.values()].filter((s) => s.owner === username).map((s) => s.id);
|
||||
await deleteUser(username); // throws LAST_ADMIN / USER_NOT_FOUND (no side effects)
|
||||
for (const id of owned) {
|
||||
await ctx.cleanupSession(id, true, 'admin_delete_user').catch(() => {});
|
||||
}
|
||||
revokeUserSessions(ctx.authSessions, username);
|
||||
await deleteUser(username); // throws LAST_ADMIN / USER_NOT_FOUND
|
||||
if (deleteSpace) await deleteUserSpace(username);
|
||||
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
canAccessOwned,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
isWorkingDirAllowed,
|
||||
ownerFor,
|
||||
resolveCasesDir,
|
||||
SETTINGS_PATH,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
parseBody,
|
||||
readJsonConfig,
|
||||
} from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { AuthUser } from '../../types.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
@@ -96,7 +98,9 @@ async function readLinkedCases(): Promise<Record<string, string>> {
|
||||
*/
|
||||
async function resolveCasePath(name: string, user?: AuthUser): Promise<string> {
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name]) return linkedCases[name];
|
||||
// Linked cases carry no owner (legacy/admin-only registry): a non-admin must not
|
||||
// resolve arbitrary linked paths by name in multi-user mode (path-escape guard).
|
||||
if (linkedCases[name] && (!isMultiUserMode() || user?.role === 'admin')) return linkedCases[name];
|
||||
return join(resolveCasesDir(user), name);
|
||||
}
|
||||
|
||||
@@ -295,7 +299,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/remote-hosts', async () => readRemoteHosts(CODEMAN_CONFIG_DIR));
|
||||
// Hosts are machine-level infra config (ssh users/identity paths): non-admins get an
|
||||
// empty list in multi-user mode, matching the admin-only write side. No-op otherwise.
|
||||
app.get('/api/remote-hosts', async (req) =>
|
||||
isMultiUserMode() && !isAdmin(req) ? [] : readRemoteHosts(CODEMAN_CONFIG_DIR)
|
||||
);
|
||||
|
||||
// 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 =>
|
||||
@@ -376,7 +384,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
// ========== Docker hosts + docker cases (COD-Docker) ==========
|
||||
|
||||
app.get('/api/docker-hosts', async () => readDockerHosts(CODEMAN_CONFIG_DIR));
|
||||
// Hosts are machine-level infra config (images/mounts/env): non-admins get an empty
|
||||
// list in multi-user mode, matching the admin-only write side. No-op otherwise.
|
||||
app.get('/api/docker-hosts', async (req) =>
|
||||
isMultiUserMode() && !isAdmin(req) ? [] : readDockerHosts(CODEMAN_CONFIG_DIR)
|
||||
);
|
||||
|
||||
app.post('/api/docker-hosts', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
@@ -446,6 +458,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// Confine the bind-mounted workspace to the caller's own space BEFORE creating it
|
||||
// (also removes the arbitrary-dir-creation primitive). No-op for admins/single-user.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), dockerCase.hostWorkspacePath)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'hostWorkspacePath is outside your workspace');
|
||||
}
|
||||
|
||||
// The workspace is a REAL host directory (bind-mounted into the container), so
|
||||
// create it now if missing. Scaffolding (.claude/settings.local.json + CLAUDE.md)
|
||||
// is written by quick-start on first launch, matching local-case behaviour.
|
||||
@@ -700,6 +718,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// Import extracts a tar into destWorkspacePath (later becomes Session.workingDir):
|
||||
// confine it to the caller's own space. No-op for admins/single-user.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), destWorkspacePath)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'destWorkspacePath is outside your workspace');
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
let result;
|
||||
try {
|
||||
@@ -740,7 +764,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
});
|
||||
|
||||
// Link an existing folder as a case
|
||||
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
app.post('/api/cases/link', async (req, reply): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
// Linking writes an arbitrary absolute path into the shared ownerless registry:
|
||||
// admin-only in multi-user mode (mirrors host CRUD + the admin-only GET listing).
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Expand ~ to home directory
|
||||
@@ -787,27 +815,31 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
app.delete('/api/cases/:name', async (req): Promise<ApiResponse<{ name: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const user = getAuthUser(req);
|
||||
|
||||
if (!validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)))) {
|
||||
if (!validatePathWithinBase(name, resolveCasesDir(user))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Fold ownership INTO the match (don't early-return): a non-owned same-named remote/
|
||||
// docker case is skipped so control falls through to the caller's own local delete.
|
||||
// canAccessOwned is all-true for admins/single-user, so flag-OFF stays byte-identical.
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (remoteCases.some((item) => item.name === name)) {
|
||||
if (remoteCases.some((item) => item.name === name && canAccessOwned(user, item.owner))) {
|
||||
await writeRemoteCases(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
remoteCases.filter((item) => item.name !== name)
|
||||
remoteCases.filter((item) => !(item.name === name && canAccessOwned(user, item.owner)))
|
||||
);
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
|
||||
return { success: true, data: { name } };
|
||||
}
|
||||
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
const dockerCase = dockerCases.find((item) => item.name === name);
|
||||
const dockerCase = dockerCases.find((item) => item.name === name && canAccessOwned(user, item.owner));
|
||||
if (dockerCase) {
|
||||
await writeDockerCases(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
dockerCases.filter((item) => item.name !== name)
|
||||
dockerCases.filter((item) => item !== dockerCase)
|
||||
);
|
||||
// Best-effort `docker rm -f` the per-case container (case-delete is the
|
||||
// explicit teardown that removes it; the bind-mounted workspace survives).
|
||||
@@ -828,9 +860,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return { success: true, data: { name } };
|
||||
}
|
||||
|
||||
// Check linked cases first — unlink only, don't delete the actual directory
|
||||
// Check linked cases first — unlink only, don't delete the actual directory.
|
||||
// Linked cases carry no owner (admin-only WRITE in multi-user mode), so a non-admin
|
||||
// must not unlink one either; skip so control falls through to their local delete.
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name]) {
|
||||
if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req))) {
|
||||
delete linkedCases[name];
|
||||
try {
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
|
||||
@@ -888,8 +922,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Fold ownership INTO the match (don't early-return): a non-owned same-named remote/
|
||||
// docker case is skipped so control falls through to the caller's own LOCAL case
|
||||
// (remote/docker names are globally unique, local names per-user). No metadata is
|
||||
// disclosed for a foreign case. canAccessOwned is allow-all for admins/single-user.
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCase = remoteCases.find((item) => item.name === name);
|
||||
const remoteCase = remoteCases.find((item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner));
|
||||
if (remoteCase) {
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
@@ -907,7 +945,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
};
|
||||
}
|
||||
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
|
||||
(item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner)
|
||||
);
|
||||
if (dockerCase) {
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
|
||||
@@ -5,19 +5,29 @@
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort } from '../ports/index.js';
|
||||
import type { EventPort, SessionPort } from '../ports/index.js';
|
||||
import { getAuthUser, canAccessOwned } from '../route-helpers.js';
|
||||
import { createErrorResponse, ApiErrorCode } from '../../types.js';
|
||||
|
||||
export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort): void {
|
||||
export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort & SessionPort): void {
|
||||
app.post('/api/clipboard', async (req) => {
|
||||
const body = req.body as { text?: string; sessionId?: string };
|
||||
const text = body?.text;
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing or empty "text" field');
|
||||
}
|
||||
// Multi-user: a supplied sessionId must belong to the caller — never let a
|
||||
// client target another user's session (no-op in single-user).
|
||||
if (body.sessionId && !canAccessOwned(getAuthUser(req), ctx.sessions.get(body.sessionId)?.owner)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Cannot target another user session');
|
||||
}
|
||||
ctx.broadcast(SseEvent.ClipboardWrite, {
|
||||
text,
|
||||
sessionId: body.sessionId ?? null,
|
||||
// Stamp the trusted caller identity so deriveSseHint routes this write to the
|
||||
// caller's own tabs only (multi-user). Undefined in single-user → JSON drops
|
||||
// the field and delivery stays global to that one user's browsers.
|
||||
callerUsername: req.authUser?.username,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return {};
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js';
|
||||
import { canAccessOwned, getAuthUser, ownerFor, parseBody } from '../route-helpers.js';
|
||||
import { canRunPrivilegedCommands } from '../../user-store.js';
|
||||
import { canAccessOwned, getAuthUser, isWorkingDirAllowed, ownerFor, parseBody } from '../route-helpers.js';
|
||||
import { canUsernameRunPrivilegedCommands } from '../../user-store.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { CronJob } from '../../types/cron.js';
|
||||
import type { CronPort } from '../ports/index.js';
|
||||
@@ -35,8 +35,19 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
// No custom errorMessage: surface the schema's field-specific messages
|
||||
// (e.g. "runAt is required for a one-time schedule").
|
||||
const body = parseBody(CronJobSchema, req.body);
|
||||
// Section 6.2: confine the job's workingDir to the owner's case space (mirrors
|
||||
// POST /api/sessions). No-op allow-all for admins/single-user. workingDir is
|
||||
// required by CronJobSchema so it is always present here.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), body.workingDir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||||
}
|
||||
// Section 6.3: shell mode / a launchCommand is arbitrary host-account execution.
|
||||
if ((body.agentType === 'shell' || body.launchCommand) && !canRunPrivilegedCommands(getAuthUser(req))) {
|
||||
// Resolve the owner's grant from the store (AuthUser.role alone can't tell a GRANTED
|
||||
// regular user from a plain one); mirrors session-routes + the cron fire-time re-check.
|
||||
if (
|
||||
(body.agentType === 'shell' || body.launchCommand) &&
|
||||
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.FORBIDDEN,
|
||||
'Shell/launchCommand cron jobs require the can-bypass-permissions grant'
|
||||
@@ -56,7 +67,14 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
const body = parseBody(CronJobUpdateSchema, req.body);
|
||||
if ((body.agentType === 'shell' || body.launchCommand) && !canRunPrivilegedCommands(getAuthUser(req))) {
|
||||
// Section 6.2: the update body is partial, so only confine when workingDir is set.
|
||||
if (body.workingDir !== undefined && !isWorkingDirAllowed(getAuthUser(req), body.workingDir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||||
}
|
||||
if (
|
||||
(body.agentType === 'shell' || body.launchCommand) &&
|
||||
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.FORBIDDEN,
|
||||
'Shell/launchCommand cron jobs require the can-bypass-permissions grant'
|
||||
@@ -98,10 +116,22 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
|
||||
app.get('/api/cron/jobs/:id/runs', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Owner-gate like every other :id handler so a foreign job's run history (session
|
||||
// ids, names, deep links) isn't leaked; NOT_FOUND avoids disclosing existence.
|
||||
if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
return ctx.cron.listRuns(id);
|
||||
});
|
||||
|
||||
app.get('/api/cron/runs', async () => {
|
||||
return ctx.cron.listRuns();
|
||||
app.get('/api/cron/runs', async (req) => {
|
||||
const runs = ctx.cron.listRuns();
|
||||
if (!isMultiUserMode()) return runs;
|
||||
const user = getAuthUser(req);
|
||||
if (user.role === 'admin') return runs;
|
||||
// Non-admin: keep only runs whose owning job the caller can access (drops runs
|
||||
// whose job is absent from the map — defensive; deleteJob already cascades).
|
||||
const ownerByJobId = new Map<string, string | undefined>(
|
||||
ctx.cron.listJobs().map((j): [string, string | undefined] => [j.id, j.owner])
|
||||
);
|
||||
return runs.filter((run) => canAccessOwned(user, ownerByJobId.get(run.cronJobId)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,9 +6,14 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import type { InfraPort } from '../ports/index.js';
|
||||
import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js';
|
||||
import { requireAdmin } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
|
||||
export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
app.get('/api/mux-sessions', async () => {
|
||||
app.get('/api/mux-sessions', async (req, reply) => {
|
||||
// Multi-user: this recovery/debug surface exposes every user's tmux + workdirs → admin-only
|
||||
// (requireAdmin is a no-op allow-all in single-user mode, so flag-off is unchanged).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const sessions = await ctx.mux.getSessionsWithStats();
|
||||
return {
|
||||
sessions,
|
||||
@@ -16,23 +21,31 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/mux-sessions/:sessionId', async (req) => {
|
||||
app.delete('/api/mux-sessions/:sessionId', async (req, reply) => {
|
||||
// Multi-user: killing any tmux session by name is a cross-user destructive action → admin-only.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { sessionId } = req.params as { sessionId: string };
|
||||
const success = await ctx.mux.killSession(sessionId);
|
||||
return { killed: success };
|
||||
});
|
||||
|
||||
app.post('/api/mux-sessions/reconcile', async () => {
|
||||
app.post('/api/mux-sessions/reconcile', async (req, reply) => {
|
||||
// Multi-user: process-wide reconcile → admin-only.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const result = await ctx.mux.reconcileSessions();
|
||||
return result;
|
||||
});
|
||||
|
||||
app.post('/api/mux-sessions/stats/start', async () => {
|
||||
app.post('/api/mux-sessions/stats/start', async (req, reply) => {
|
||||
// Multi-user: process-wide stats collection toggle → admin-only.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
ctx.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||||
return {};
|
||||
});
|
||||
|
||||
app.post('/api/mux-sessions/stats/stop', async () => {
|
||||
app.post('/api/mux-sessions/stats/stop', async (req, reply) => {
|
||||
// Multi-user: process-wide stats collection toggle → admin-only.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
ctx.mux.stopStatsCollection();
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { OrchestratorStartSchema, OrchestratorRejectSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import { parseBody, requireAdmin } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, OrchestratorPort } from '../ports/index.js';
|
||||
|
||||
@@ -79,7 +80,10 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Start
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/start', async (req) => {
|
||||
app.post('/api/orchestrator/start', async (req, reply) => {
|
||||
// Multi-user: the orchestrator is a process-wide singleton with no per-user
|
||||
// isolation → admin-only (requireAdmin is a no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { goal, config } = parseBody(OrchestratorStartSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Initialize loop if needed
|
||||
@@ -115,7 +119,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Approve / Reject Plan
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/approve', async () => {
|
||||
app.post('/api/orchestrator/approve', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
@@ -128,7 +134,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/reject', async (req) => {
|
||||
app.post('/api/orchestrator/reject', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
const { feedback } = parseBody(OrchestratorRejectSchema, req.body, 'Feedback is required');
|
||||
@@ -147,7 +155,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Pause / Resume / Stop
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/pause', async () => {
|
||||
app.post('/api/orchestrator/pause', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
@@ -158,7 +168,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/resume', async () => {
|
||||
app.post('/api/orchestrator/resume', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
@@ -171,7 +183,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/stop', async () => {
|
||||
app.post('/api/orchestrator/stop', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
@@ -186,7 +200,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Status / Plan
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.get('/api/orchestrator/status', async () => {
|
||||
app.get('/api/orchestrator/status', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = ctx.orchestratorLoop;
|
||||
if (!loop) {
|
||||
return { ok: true, state: 'idle', plan: null, stats: null };
|
||||
@@ -198,7 +214,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/orchestrator/plan', async () => {
|
||||
app.get('/api/orchestrator/plan', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = ctx.orchestratorLoop;
|
||||
if (!loop) {
|
||||
return { ok: true, plan: null };
|
||||
@@ -215,7 +233,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
// Phase Operations
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/phase/:id/skip', async (req) => {
|
||||
app.post('/api/orchestrator/phase/:id/skip', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
@@ -227,7 +247,9 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/phase/:id/retry', async (req) => {
|
||||
app.post('/api/orchestrator/phase/:id/retry', async (req, reply) => {
|
||||
// Multi-user: shared-singleton orchestrator → admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const loop = getLoop();
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
|
||||
@@ -261,7 +261,18 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
|
||||
const detailedModelConfig = await ctx.getModelConfig();
|
||||
const orchestrator = new PlanOrchestrator(ctx.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined);
|
||||
// Section 6.3: resolve the owner's permission mode (mirrors /api/generate-plan above) and
|
||||
// thread it + owner + allowedTools into the orchestrator's internal research/planner one-shots
|
||||
// so a non-granted multi-user user cannot run them under --dangerously-skip-permissions.
|
||||
// In single-user, resolveClaudeModeForUsername returns the global mode = byte-identical.
|
||||
const detailedOwner = ownerFor(req);
|
||||
const detailedClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const detailedClaudeMode = await resolveClaudeModeForUsername(detailedClaudeModeConfig.claudeMode, detailedOwner);
|
||||
const orchestrator = new PlanOrchestrator(ctx.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined, {
|
||||
claudeMode: detailedClaudeMode,
|
||||
owner: detailedOwner,
|
||||
allowedTools: detailedClaudeModeConfig.allowedTools,
|
||||
});
|
||||
|
||||
// Store orchestrator for potential cancellation via API (not on disconnect)
|
||||
// Plan generation continues even if browser disconnects - only explicit cancel stops it
|
||||
|
||||
@@ -24,6 +24,10 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
userAgent: userAgent ?? req.headers['user-agent'] ?? '',
|
||||
createdAt: Date.now(),
|
||||
pushPreferences: pushPreferences ?? {},
|
||||
// Multi-user: stamp the trusted caller identity so sendPushNotifications can
|
||||
// scope session notifications to the owner (+ admins). Undefined in single-user.
|
||||
username: req.authUser?.username,
|
||||
role: req.authUser?.role,
|
||||
});
|
||||
return { success: true, data: { id: record.id } };
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type PersistedRespa
|
||||
import { RespawnController, type RespawnConfig } from '../../respawn-controller.js';
|
||||
import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { findSessionOrFail, autoConfigureRalph, parseBody } from '../route-helpers.js';
|
||||
import { findSessionOrFail, autoConfigureRalph, parseBody, canAccessOwned, getAuthUser } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { isExternalCliMode } from '../../session.js';
|
||||
@@ -46,7 +46,11 @@ export function registerRespawnRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
if (!controller) {
|
||||
// Multi-user: gate on the owner from the same source the data comes from, and
|
||||
// return the existing neutral shape (not 404) when foreign so existence isn't
|
||||
// leaked. canAccessOwned is allow-all in single-user mode → byte-identical.
|
||||
const owner = ctx.sessions.get(id)?.owner ?? ctx.mux.getSession(id)?.owner;
|
||||
if (!controller || !canAccessOwned(getAuthUser(req), owner)) {
|
||||
return { enabled: false, status: null };
|
||||
}
|
||||
|
||||
@@ -60,16 +64,21 @@ export function registerRespawnRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/respawn/config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Multi-user: owner-gate each branch against the source of the data, preserving
|
||||
// the neutral {config:null,active:false} shape when foreign (no existence leak).
|
||||
// canAccessOwned is allow-all in single-user mode → byte-identical, and this keeps
|
||||
// the mux-only pre-config path working (findSessionOrFail would break it).
|
||||
const user = getAuthUser(req);
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
if (controller) {
|
||||
if (controller && canAccessOwned(user, ctx.sessions.get(id)?.owner)) {
|
||||
return { config: controller.getConfig(), active: true };
|
||||
}
|
||||
|
||||
// Return pre-saved config from mux-sessions.json
|
||||
const preConfig = ctx.mux.getSession(id)?.respawnConfig;
|
||||
if (preConfig) {
|
||||
return { config: preConfig, active: false };
|
||||
const mux = ctx.mux.getSession(id);
|
||||
if (mux?.respawnConfig && canAccessOwned(user, mux.owner)) {
|
||||
return { config: mux.respawnConfig, active: false };
|
||||
}
|
||||
|
||||
return { config: null, active: false };
|
||||
@@ -122,6 +131,9 @@ export function registerRespawnRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/respawn/stop', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Owner-gate before any side effects (matches start/config/enable): a non-owner
|
||||
// gets NOT_FOUND and never reaches stop/delete/clearRespawnConfig/persist.
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
if (!controller) {
|
||||
@@ -144,10 +156,7 @@ export function registerRespawnRoutes(
|
||||
ctx.mux.clearRespawnConfig(id);
|
||||
|
||||
// Update state.json (respawnConfig removed)
|
||||
const session = ctx.sessions.get(id);
|
||||
if (session) {
|
||||
ctx.persistSessionState(session);
|
||||
}
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
ctx.broadcast(SseEvent.RespawnStopped, { sessionId: id });
|
||||
|
||||
|
||||
@@ -7,17 +7,35 @@ import { FastifyInstance } from 'fastify';
|
||||
import { statSync } from 'node:fs';
|
||||
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
|
||||
import { ScheduledRunSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import {
|
||||
parseBody,
|
||||
getAuthUser,
|
||||
ownerFor,
|
||||
isWorkingDirAllowed,
|
||||
canAccessOwned,
|
||||
resolveCasesDir,
|
||||
} from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { SessionPort, EventPort, InfraPort, ScheduledRun } from '../ports/index.js';
|
||||
|
||||
export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & InfraPort): void {
|
||||
app.get('/api/scheduled', async () => {
|
||||
return Array.from(ctx.scheduledRuns.values());
|
||||
app.get('/api/scheduled', async (req) => {
|
||||
// Multi-user: non-admins see only their own runs (no-op in single-user).
|
||||
const user = getAuthUser(req);
|
||||
return Array.from(ctx.scheduledRuns.values()).filter((r) => canAccessOwned(user, r.owner));
|
||||
});
|
||||
|
||||
app.post('/api/scheduled', async (req): Promise<{ run: ScheduledRun } | ApiResponse<never>> => {
|
||||
const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Multi-user: confine the run's workingDir to the caller's own case space.
|
||||
// The spawned Session (--dangerously-skip-permissions by default) trusts this
|
||||
// dir; without confinement a non-admin could point it at another user's files.
|
||||
// No-op for admins / single-user (isWorkingDirAllowed returns true).
|
||||
if (workingDir && !isWorkingDirAllowed(getAuthUser(req), workingDir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is not within your allowed workspace');
|
||||
}
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
if (workingDir) {
|
||||
try {
|
||||
@@ -30,7 +48,11 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
|
||||
}
|
||||
}
|
||||
|
||||
const run = await ctx.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60);
|
||||
// Multi-user: default a missing workingDir to the user's own cases dir rather
|
||||
// than the server's cwd. Single-user keeps process.cwd() (byte-identical).
|
||||
const effectiveWorkingDir = workingDir || (isMultiUserMode() ? resolveCasesDir(getAuthUser(req)) : process.cwd());
|
||||
|
||||
const run = await ctx.startScheduledRun(prompt, effectiveWorkingDir, durationMinutes ?? 60, ownerFor(req));
|
||||
return { run };
|
||||
});
|
||||
|
||||
@@ -38,7 +60,9 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
|
||||
const { id } = req.params as { id: string };
|
||||
const run = ctx.scheduledRuns.get(id);
|
||||
|
||||
if (!run) {
|
||||
// NOT_FOUND (not FORBIDDEN) for a foreign run so existence isn't leaked; no-op
|
||||
// for admins / single-user (canAccessOwned returns true).
|
||||
if (!run || !canAccessOwned(getAuthUser(req), run.owner)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
|
||||
}
|
||||
|
||||
@@ -50,7 +74,8 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
|
||||
const { id } = req.params as { id: string };
|
||||
const run = ctx.scheduledRuns.get(id);
|
||||
|
||||
if (!run) {
|
||||
// Owner-scoped read: a foreign run reads as NOT_FOUND (no-op in single-user).
|
||||
if (!run || !canAccessOwned(getAuthUser(req), run.owner)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
getErrorMessage,
|
||||
type ApiResponse,
|
||||
type SessionColor,
|
||||
type CodexConfig,
|
||||
type GeminiConfig,
|
||||
} from '../../types.js';
|
||||
import { Session, isAltScreenStripMode } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
CASES_DIR,
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
isWorkingDirAllowed,
|
||||
ownerFor,
|
||||
parseBody,
|
||||
@@ -51,7 +54,7 @@ import {
|
||||
SETTINGS_PATH,
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { canRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import {
|
||||
@@ -266,6 +269,29 @@ export function _resetPasteRateBuckets(): void {
|
||||
pasteRateBuckets.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
|
||||
* cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`
|
||||
* and Gemini `--approval-mode yolo` disable the safety classifier the non-granted-user
|
||||
* downgrade is meant to keep on, so clamp them for a non-granted owner. buildGeminiCommand
|
||||
* defaults an ABSENT approvalMode to yolo, so the gemini config must be MATERIALIZED
|
||||
* (auto_edit) even when the request sent none. No-op in single-user mode / for a granted
|
||||
* owner (canUsernameRunPrivilegedCommands returns true when !isMultiUserMode()).
|
||||
*/
|
||||
async function clampExternalCliBypassForOwner(
|
||||
owner: string | undefined,
|
||||
codexConfig: CodexConfig | undefined,
|
||||
geminiConfig: GeminiConfig | undefined
|
||||
): Promise<{ codexConfig: CodexConfig | undefined; geminiConfig: GeminiConfig | undefined }> {
|
||||
const granted = await canUsernameRunPrivilegedCommands(owner);
|
||||
if (granted) return { codexConfig, geminiConfig };
|
||||
// Non-granted: force codex bypass off (only meaningful when a config was sent) and
|
||||
// materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
|
||||
const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
|
||||
const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
|
||||
return { codexConfig: clampedCodex, geminiConfig: clampedGemini };
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||
@@ -312,8 +338,9 @@ export function registerSessionRoutes(
|
||||
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))) {
|
||||
// gated behind the same grant as bypass (section 6.3). Resolve the owner's grant
|
||||
// from the store so a GRANTED regular user is not wrongly denied (AuthUser role alone can't tell).
|
||||
if (body.mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
||||
}
|
||||
|
||||
@@ -463,6 +490,12 @@ export function registerSessionRoutes(
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
// Section 6.3: force non-granted users to a classifier-guarded mode.
|
||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
|
||||
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
const { codexConfig: gatedCodexConfig, geminiConfig: gatedGeminiConfig } = await clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
body.codexConfig,
|
||||
body.geminiConfig
|
||||
);
|
||||
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
@@ -475,8 +508,8 @@ export function registerSessionRoutes(
|
||||
claudeMode: effectiveClaudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? body.codexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? body.geminiConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: body.envOverrides,
|
||||
effort: body.effort,
|
||||
@@ -536,18 +569,22 @@ export function registerSessionRoutes(
|
||||
const query = req.query as { killMux?: string };
|
||||
const killMux = query.killMux !== 'false'; // Default to true
|
||||
|
||||
if (!ctx.sessions.has(id)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
// Security: owner-scoped lookup 404s foreign/missing sessions uniformly (no existence leak, no cross-user kill).
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
await ctx.cleanupSession(id, killMux, 'user_delete');
|
||||
await ctx.cleanupSession(session.id, killMux, 'user_delete');
|
||||
return {};
|
||||
});
|
||||
|
||||
// ========== Delete All Sessions ==========
|
||||
|
||||
app.delete('/api/sessions', async (): Promise<ApiResponse<{ killed: number }>> => {
|
||||
const sessionIds = Array.from(ctx.sessions.keys());
|
||||
app.delete('/api/sessions', async (req): Promise<ApiResponse<{ killed: number }>> => {
|
||||
// Security: scope the bulk sweep to sessions the caller can access — a non-admin
|
||||
// must not wipe other users' sessions (canAccessOwned is allow-all for admin/single-user).
|
||||
const user = getAuthUser(req);
|
||||
const sessionIds = Array.from(ctx.sessions.values())
|
||||
.filter((s) => canAccessOwned(user, s.owner))
|
||||
.map((s) => s.id);
|
||||
let killed = 0;
|
||||
|
||||
for (const id of sessionIds) {
|
||||
@@ -1724,7 +1761,8 @@ export function registerSessionRoutes(
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
|
||||
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
|
||||
if (mode === 'shell' && !canRunPrivilegedCommands(getAuthUser(req))) {
|
||||
// Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
|
||||
if (mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
||||
}
|
||||
|
||||
@@ -1735,11 +1773,20 @@ export function registerSessionRoutes(
|
||||
let docker = undefined;
|
||||
let dockerResumeId: string | undefined;
|
||||
let casePath: string | null = null;
|
||||
// Security: fold ownership INTO the match (don't early-return) so a NON-OWNED
|
||||
// same-named remote/docker case is skipped and control falls through to the caller's
|
||||
// own LOCAL case — remote/docker names are globally unique but local names are
|
||||
// per-user, so a name collision must not shadow the caller's own case. canAccessOwned
|
||||
// is allow-all for admins/single-user, so flag-OFF stays byte-identical.
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCase = remoteCases.find((item) => item.name === caseName);
|
||||
const remoteCase = remoteCases.find(
|
||||
(item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner)
|
||||
);
|
||||
const dockerCase = remoteCase
|
||||
? undefined
|
||||
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName);
|
||||
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
|
||||
(item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner)
|
||||
);
|
||||
if (remoteCase) {
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
@@ -1775,7 +1822,7 @@ export function registerSessionRoutes(
|
||||
// Docker case: the CLI executes INSIDE a container via local tmux + `docker
|
||||
// exec`, so the LOCAL availability gates below don't apply. Mirror the remote
|
||||
// branch's rejection of per-session config that would not cross into the
|
||||
// container (it would silently no-op).
|
||||
// container (it would silently no-op). (Ownership is enforced in the .find above.)
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
if (
|
||||
@@ -1872,8 +1919,11 @@ export function registerSessionRoutes(
|
||||
} catch {
|
||||
// File missing or unparseable — treat as empty registry
|
||||
}
|
||||
// Multi-user: resolve local cases inside the requesting user's case space.
|
||||
casePath = linkedCases[caseName] || validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)));
|
||||
// Multi-user: the linked-cases registry is ownerless/global, so only admins may
|
||||
// resolve a name to an arbitrary linked path. A non-admin resolves inside their
|
||||
// OWN case space only (single-user: isAdmin true, so linked cases still honoured).
|
||||
const linked = isAdmin(req) ? linkedCases[caseName] : undefined;
|
||||
casePath = linked || validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)));
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
@@ -1883,6 +1933,15 @@ export function registerSessionRoutes(
|
||||
// for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
|
||||
const resolvedCasePath = casePath as string;
|
||||
|
||||
// Multi-user linchpin (section 6.2): confine the resolved workingDir to the caller's
|
||||
// own case space BEFORE any mkdir/scaffold below creates or mutates it. Applies to
|
||||
// LOCAL and DOCKER cases (docker.hostWorkspacePath is a real host dir the file routes
|
||||
// trust); skipped for REMOTE, whose path is an ssh path that would spuriously fail
|
||||
// realpath confinement. No-op for admins / single-user mode.
|
||||
if (!remote && !isWorkingDirAllowed(getAuthUser(req), resolvedCasePath)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'case path is outside your workspace');
|
||||
}
|
||||
|
||||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
|
||||
// non-docker cases — docker workspaces are scaffolded in their own block below)
|
||||
if (!remote && !docker && !existsSync(resolvedCasePath)) {
|
||||
@@ -1962,6 +2021,12 @@ export function registerSessionRoutes(
|
||||
: undefined;
|
||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
|
||||
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig } = await clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
codexConfig,
|
||||
geminiConfig
|
||||
);
|
||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
workingDir: resolvedCasePath,
|
||||
@@ -1975,8 +2040,8 @@ export function registerSessionRoutes(
|
||||
allowedTools: qsClaudeModeConfig.allowedTools,
|
||||
owner,
|
||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? codexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
|
||||
envOverrides,
|
||||
effort,
|
||||
remote,
|
||||
@@ -2319,6 +2384,12 @@ export function registerSessionRoutes(
|
||||
const query = req.query as { projectKey?: string; offset?: string; limit?: string };
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const headBuf = Buffer.alloc(16384);
|
||||
// Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin
|
||||
// must only see history whose decoded workingDir is inside their own case space.
|
||||
// Do NOT trust the caller-supplied projectKey — confine on the decoded path.
|
||||
// No-op for admins / single-user mode.
|
||||
const user = getAuthUser(req);
|
||||
const scopeHistory = isMultiUserMode() && user.role !== 'admin';
|
||||
|
||||
// Single-folder drill-down: when projectKey is provided, scan only that
|
||||
// directory, bypass the 50-cap, and honor offset/limit pagination.
|
||||
@@ -2330,13 +2401,15 @@ export function registerSessionRoutes(
|
||||
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
|
||||
const projPath = join(projectsDir, query.projectKey);
|
||||
const all = await scanProjectDir(projPath, query.projectKey, headBuf);
|
||||
let all = await scanProjectDir(projPath, query.projectKey, headBuf);
|
||||
// Confine to the caller's workspace (a projectKey maps to a single foreign cwd).
|
||||
if (scopeHistory) all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
||||
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
return { sessions: all.slice(offset, offset + limit), total: all.length };
|
||||
}
|
||||
|
||||
// Global overview: scan all projects, return up to 50 most-recent sessions.
|
||||
const results: HistorySession[] = [];
|
||||
let results: HistorySession[] = [];
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
@@ -2348,6 +2421,8 @@ export function registerSessionRoutes(
|
||||
// Projects dir may not exist
|
||||
}
|
||||
|
||||
// Multi-user: drop rows outside the non-admin caller's own case space.
|
||||
if (scopeHistory) results = results.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
||||
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
return { sessions: results.slice(0, 50) };
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } f
|
||||
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser } from '../../user-store.js';
|
||||
import { getAuthUser } from '../route-helpers.js';
|
||||
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
|
||||
import {
|
||||
ConfigUpdateSchema,
|
||||
SettingsUpdateSchema,
|
||||
@@ -224,14 +224,16 @@ export function registerSystemRoutes(
|
||||
}
|
||||
|
||||
// Resolve the role for the bound user (disabled/deleted users fail closed).
|
||||
let identity: { username: string; role: 'admin' | 'user' } | undefined;
|
||||
// Carry the bound user's real mustChangePassword flag out of this block so the
|
||||
// minted cookie enforces the lockbox instead of hardcoding false.
|
||||
let identity: { username: string; role: 'admin' | 'user'; mustChangePassword: boolean } | undefined;
|
||||
if (multiUser && consumed.username) {
|
||||
const user = await findUser(consumed.username);
|
||||
if (!user || user.disabled) {
|
||||
ctx.qrAuthFailures?.set(clientIp, qrFailures + 1);
|
||||
return reply.code(401).send('Invalid or expired QR code');
|
||||
}
|
||||
identity = { username: user.username, role: user.role };
|
||||
identity = { username: user.username, role: user.role, mustChangePassword: !!user.mustChangePassword };
|
||||
}
|
||||
|
||||
// Issue session cookie (same pattern as Basic Auth success path)
|
||||
@@ -244,7 +246,7 @@ export function registerSystemRoutes(
|
||||
method: 'qr',
|
||||
username: identity?.username,
|
||||
role: identity?.role,
|
||||
mustChangePassword: false,
|
||||
mustChangePassword: !!identity?.mustChangePassword,
|
||||
});
|
||||
ctx.qrAuthFailures?.delete(clientIp);
|
||||
|
||||
@@ -493,23 +495,52 @@ export function registerSystemRoutes(
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values()).map((session) => ({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
status: session.status,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
}));
|
||||
// Multi-user: scope the digest's aggregated activity to sessions the caller
|
||||
// owns (canAccessOwned is a no-op allow-all for admins/single-user).
|
||||
const user = getAuthUser(req);
|
||||
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values())
|
||||
.filter((session) => canAccessOwned(user, session.owner))
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
status: session.status,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
}));
|
||||
|
||||
const runSummaries = Array.from(ctx.runSummaryTrackers.values()).map((tracker) => tracker.getSummary());
|
||||
// Run-summary trackers are keyed by Codeman session id → filter by that session's owner.
|
||||
const runSummaries = Array.from(ctx.runSummaryTrackers.entries())
|
||||
.filter(([id]) => canAccessOwned(user, ctx.sessions.get(id)?.owner))
|
||||
.map(([, tracker]) => tracker.getSummary());
|
||||
|
||||
// Map each subagent's Claude conversation id back to its owning session so the
|
||||
// recent-subagent lookback is owner-scoped too (fails closed when unattributable).
|
||||
const ownerByClaudeSessionId = new Map<string, string | undefined>();
|
||||
for (const s of ctx.sessions.values()) {
|
||||
if (s.claudeSessionId) ownerByClaudeSessionId.set(s.claudeSessionId, s.owner);
|
||||
}
|
||||
const subagents = subagentWatcher
|
||||
.getRecentSubagents(60)
|
||||
.filter((sa) => canAccessOwned(user, ownerByClaudeSessionId.get(sa.sessionId))) as AwayDigestSubagent[];
|
||||
|
||||
// Multi-user: the lifecycle log and daily token stats carry no owner, so scope them
|
||||
// for a non-admin: keep only lifecycle entries attributable to an owned LIVE session
|
||||
// (fail closed — an ended session's owner can't be resolved, so it is dropped rather
|
||||
// than leaked), and withhold the machine-wide daily token totals entirely (they can't
|
||||
// be per-user attributed, same as globalStats in #29). Admins/single-user keep all
|
||||
// (canAccessOwned allow-all, role check false → byte-identical).
|
||||
const scopedLifecycle = lifecycleEntries.filter((e) =>
|
||||
canAccessOwned(user, ctx.sessions.get(e.sessionId ?? '')?.owner)
|
||||
);
|
||||
const nonAdminScoped = isMultiUserMode() && user.role !== 'admin';
|
||||
const digest = buildAwayDigest({
|
||||
range,
|
||||
lifecycleEntries,
|
||||
lifecycleEntries: scopedLifecycle,
|
||||
runSummaries,
|
||||
sessions,
|
||||
dailyTokenStats: ctx.store.getDailyStats(30),
|
||||
subagents: subagentWatcher.getRecentSubagents(60) as AwayDigestSubagent[],
|
||||
dailyTokenStats: nonAdminScoped ? [] : ctx.store.getDailyStats(30),
|
||||
subagents,
|
||||
now: range.until,
|
||||
});
|
||||
|
||||
@@ -827,7 +858,10 @@ export function registerSystemRoutes(
|
||||
// ========== Workflow Run Monitoring (ultracode) ==========
|
||||
|
||||
// LEFT-pane list: lightweight run summaries (no agents[]).
|
||||
app.get('/api/workflows', async (req) => {
|
||||
app.get('/api/workflows', async (req, reply) => {
|
||||
// Multi-user stopgap: these aggregates are process-wide (no owner concept), so
|
||||
// restrict cross-user reads to admins (no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { minutes } = req.query as { minutes?: string };
|
||||
const runs = minutes
|
||||
? workflowRunWatcher.getRecentRunSummaries(parseInt(minutes, 10))
|
||||
@@ -836,7 +870,9 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
// RIGHT-pane detail: full run incl. agents[] (tokens/toolCalls/state per agent).
|
||||
app.get('/api/workflows/:runId', async (req) => {
|
||||
app.get('/api/workflows/:runId', async (req, reply) => {
|
||||
// Multi-user stopgap: cross-user run detail is admin-only (no-op in single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { runId } = req.params as { runId: string };
|
||||
const run = workflowRunWatcher.getRun(runId);
|
||||
if (!run) {
|
||||
@@ -847,7 +883,10 @@ export function registerSystemRoutes(
|
||||
|
||||
// ========== Subagent Monitoring ==========
|
||||
|
||||
app.get('/api/subagents', async (req) => {
|
||||
app.get('/api/subagents', async (req, reply) => {
|
||||
// Multi-user stopgap: the global subagent list spans all users → admin-only
|
||||
// (no-op allow-all in single-user mode). Per-session variant below stays scoped.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { minutes } = req.query as { minutes?: string };
|
||||
const subagents = minutes
|
||||
? subagentWatcher.getRecentSubagents(parseInt(minutes, 10))
|
||||
@@ -862,7 +901,9 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: subagents };
|
||||
});
|
||||
|
||||
app.get('/api/subagents/:agentId', async (req) => {
|
||||
app.get('/api/subagents/:agentId', async (req, reply) => {
|
||||
// Multi-user stopgap: cross-user subagent metadata is admin-only (no-op single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const info = subagentWatcher.getSubagent(agentId);
|
||||
if (!info) {
|
||||
@@ -871,7 +912,10 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: info };
|
||||
});
|
||||
|
||||
app.get('/api/subagents/:agentId/transcript', async (req) => {
|
||||
app.get('/api/subagents/:agentId/transcript', async (req, reply) => {
|
||||
// Multi-user stopgap: transcript CONTENT of any user's subagent is admin-only
|
||||
// (no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const { limit, format } = req.query as { limit?: string; format?: 'raw' | 'formatted' };
|
||||
const limitNum = limit ? parseInt(limit, 10) : undefined;
|
||||
@@ -885,7 +929,10 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: transcript };
|
||||
});
|
||||
|
||||
app.delete('/api/subagents/:agentId', async (req) => {
|
||||
app.delete('/api/subagents/:agentId', async (req, reply) => {
|
||||
// Multi-user stopgap: killing any user's subagent is a cross-user write → admin-only
|
||||
// (no-op allow-all in single-user mode).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const info = subagentWatcher.getSubagent(agentId);
|
||||
if (!info) {
|
||||
@@ -899,12 +946,16 @@ export function registerSystemRoutes(
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Subagent not found or already completed');
|
||||
});
|
||||
|
||||
app.post('/api/subagents/cleanup', async () => {
|
||||
app.post('/api/subagents/cleanup', async (req, reply) => {
|
||||
// Multi-user stopgap: process-wide cleanup affects every user → admin-only (no-op single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const removed = subagentWatcher.cleanupNow();
|
||||
return { success: true, data: { removed, remaining: subagentWatcher.getSubagents().length } };
|
||||
});
|
||||
|
||||
app.delete('/api/subagents', async () => {
|
||||
app.delete('/api/subagents', async (req, reply) => {
|
||||
// Multi-user stopgap: clearing ALL users' subagents is a cross-user write → admin-only (no-op single-user).
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const cleared = subagentWatcher.clearAll();
|
||||
return { success: true, data: { cleared } };
|
||||
});
|
||||
|
||||
+57
-4
@@ -1554,7 +1554,12 @@ export class WebServer extends EventEmitter {
|
||||
);
|
||||
}
|
||||
|
||||
private async startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun> {
|
||||
private async startScheduledRun(
|
||||
prompt: string,
|
||||
workingDir: string,
|
||||
durationMinutes: number,
|
||||
owner?: string
|
||||
): Promise<ScheduledRun> {
|
||||
const id = uuidv4();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -1570,6 +1575,9 @@ export class WebServer extends EventEmitter {
|
||||
completedTasks: 0,
|
||||
totalCost: 0,
|
||||
logs: [`[${new Date().toISOString()}] Scheduled run started`],
|
||||
// Multi-user: stamp the requesting user so the spawned Session is owned +
|
||||
// permission-downgraded, and list/delete stay owner-scoped.
|
||||
owner,
|
||||
};
|
||||
|
||||
this.scheduledRuns.set(id, run);
|
||||
@@ -1608,8 +1616,23 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
let session: Session | null = null;
|
||||
try {
|
||||
// Create a session for this iteration
|
||||
session = new Session({ workingDir: run.workingDir });
|
||||
// Create a session for this iteration.
|
||||
if (isMultiUserMode()) {
|
||||
// §6.3: resolve the permission mode with the RUN OWNER (a non-granted user
|
||||
// must not regain --dangerously-skip-permissions here) and stamp the owner so
|
||||
// list/delete stay scoped. owner + mode + allowedTools mirror quick-start.
|
||||
const scheduledClaudeCfg = await this.getClaudeModeConfig();
|
||||
session = new Session({
|
||||
workingDir: run.workingDir,
|
||||
owner: run.owner,
|
||||
claudeMode: await resolveClaudeModeForUsername(scheduledClaudeCfg.claudeMode, run.owner),
|
||||
allowedTools: scheduledClaudeCfg.allowedTools,
|
||||
});
|
||||
} else {
|
||||
// Single-user: build EXACTLY as master (bare workingDir → Session's default
|
||||
// mode) so the flag-off path stays byte-identical.
|
||||
session = new Session({ workingDir: run.workingDir });
|
||||
}
|
||||
this.sessions.set(session.id, session);
|
||||
this.store.incrementSessionsCreated();
|
||||
this.persistSessionState(session);
|
||||
@@ -1785,7 +1808,7 @@ export class WebServer extends EventEmitter {
|
||||
Array.isArray(arr)
|
||||
? (arr as Array<Record<string, unknown>>).filter((x) => ownedClaudeIds.has(String(x[key])))
|
||||
: arr;
|
||||
return {
|
||||
const filtered: Record<string, unknown> = {
|
||||
...base,
|
||||
sessions,
|
||||
respawnStatus,
|
||||
@@ -1794,6 +1817,11 @@ export class WebServer extends EventEmitter {
|
||||
workflowRuns: bySession(base.workflowRuns, 'sessionUuid'),
|
||||
planUsage: null, // host-plan telemetry is admin-only
|
||||
};
|
||||
// #29: globalStats is a machine-wide aggregate (all users' tokens/cost + active
|
||||
// count) with no per-user attribution — never expose it to a non-admin. The
|
||||
// header falls back to per-active-session totals when it is absent.
|
||||
delete filtered.globalStats;
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private computeLightState() {
|
||||
@@ -1890,6 +1918,14 @@ export class WebServer extends EventEmitter {
|
||||
const owner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
|
||||
return { owner, sessionScoped: true };
|
||||
}
|
||||
// #20/#38: clipboard:write writes into the receiver's OS clipboard — route it to
|
||||
// the POSTING user's own tabs only (never other users). The route stamps the
|
||||
// trusted caller identity as `callerUsername`. sessionScoped:true fails closed
|
||||
// (withhold from non-admins) if the caller identity is somehow unresolved, rather
|
||||
// than falling through to global delivery.
|
||||
if (event.startsWith('clipboard:')) {
|
||||
return { username: (data as { callerUsername?: string }).callerUsername, sessionScoped: true };
|
||||
}
|
||||
// Unrecognized / genuinely global events (connection status, needsRefresh): all.
|
||||
return undefined;
|
||||
}
|
||||
@@ -1948,6 +1984,13 @@ export class WebServer extends EventEmitter {
|
||||
const sessionName = (data.sessionName as string) || '';
|
||||
const sessionId = (data.sessionId as string) || '';
|
||||
|
||||
// Multi-user: a session-scoped push (all PUSH_EVENT_MAP events carry a sessionId)
|
||||
// must reach only the owner's devices (+ admins) — the body embeds the session
|
||||
// name + activity, so cross-user delivery would leak it. Resolved once here; the
|
||||
// per-subscription gate below is a no-op in single-user (send to all).
|
||||
const multiUserPush = isMultiUserMode();
|
||||
const pushSessionOwner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
|
||||
|
||||
// Build body text from event data
|
||||
let body = sessionName ? `[${sessionName}]` : '';
|
||||
if (event === SseEvent.SessionError && data.error) {
|
||||
@@ -1984,6 +2027,16 @@ export class WebServer extends EventEmitter {
|
||||
// Check per-subscription preferences
|
||||
if (sub.pushPreferences[event] === false) continue;
|
||||
|
||||
// Multi-user recipient scoping: admins receive all; a session-scoped event
|
||||
// reaches only subscriptions owned by the session owner (fail closed if the
|
||||
// owner is unresolved — legacy subs with no stamped username are excluded);
|
||||
// a genuinely session-less event reaches everyone.
|
||||
if (multiUserPush && sub.role !== 'admin') {
|
||||
if (sessionId) {
|
||||
if (sub.username === undefined || sub.username !== pushSessionOwner) continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validate the stored endpoint before fetching it server-side (SSRF, M7).
|
||||
// Defense-in-depth: subscribe-time validation already rejects unsafe URLs.
|
||||
if (!isSafePushEndpoint(sub.endpoint)) {
|
||||
|
||||
@@ -413,7 +413,11 @@ export class SseStreamManager {
|
||||
return;
|
||||
}
|
||||
for (const [, { sessionId, task }] of this.taskUpdateBatches) {
|
||||
this.broadcast(SseEvent.TaskUpdated, { sessionId, task });
|
||||
// Multi-user: batched task updates carry session state — route to the owner
|
||||
// only (fail closed if unknown), matching flushSessionTerminalBatch. No-op for
|
||||
// identity-less single-user clients (canDeliver short-circuits on no identity).
|
||||
const owner = this.deps.resolveSessionOwner?.(sessionId);
|
||||
this.broadcast(SseEvent.TaskUpdated, { sessionId, task }, { owner, sessionScoped: true });
|
||||
}
|
||||
this.taskUpdateBatches.clear();
|
||||
}
|
||||
@@ -453,7 +457,11 @@ export class SseStreamManager {
|
||||
// Single expensive serialization per batch interval
|
||||
const state = this.deps.getSessionStateWithRespawn(sessionId);
|
||||
if (state) {
|
||||
this.broadcast(SseEvent.SessionUpdated, state);
|
||||
// Multi-user: the debounced session:updated blob carries name/workingDir/
|
||||
// tokens/cost — route to the session owner only (fail closed if unknown),
|
||||
// matching flushSessionTerminalBatch. No-op for single-user clients.
|
||||
const owner = this.deps.resolveSessionOwner?.(sessionId);
|
||||
this.broadcast(SseEvent.SessionUpdated, state, { owner, sessionScoped: true });
|
||||
}
|
||||
}
|
||||
this.stateUpdatePending.clear();
|
||||
|
||||
Reference in New Issue
Block a user