mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
Merge master into PR #156 (remote tmux durability)
Resolutions: - session.ts: keep the extracted _buildRespawnPaneOptions() helper (COD-108) and add master's docker/owner fields to it - tmux-manager.ts: docker branch first, then remote via buildRemoteSessionCommand (now an options object threading claudeMode/allowedTools into buildRemoteLaunchCommand, preserving the 6.3 multi-user permission downgrade) - case-routes.ts: keep master's adminOnly helper; gate the new COD-105 discovery endpoint admin-only in multi-user mode (hosts are machine-level infra) - settings-ui.js: union of remoteAutoReconnect + master's header-button defaults - session-routes.ts: union of imports; session gets remote + owner Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @fileoverview Admin user-management routes (multi-user mode only).
|
||||
*
|
||||
* All handlers: 404 unless multi-user mode is active, requireAdmin, and audit-logged
|
||||
* to ~/.codeman/admin-audit.jsonl. Endpoints (docs/multi-user-plan.md section 8):
|
||||
* GET /api/admin/users
|
||||
* POST /api/admin/users
|
||||
* PATCH /api/admin/users/:username
|
||||
* POST /api/admin/users/:username/reset-password
|
||||
* POST /api/admin/users/:username/logout
|
||||
* DELETE /api/admin/users/:username
|
||||
*
|
||||
* Self-service GET /api/me + POST /api/me/password live in me-routes.ts.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isMultiUserMode, userCasesDir } from '../../config/multiuser.js';
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
deleteUserSpace,
|
||||
findUser,
|
||||
generateOneTimePassword,
|
||||
readUsers,
|
||||
setPassword,
|
||||
toPublicUser,
|
||||
updateUser,
|
||||
UserStoreError,
|
||||
} from '../../user-store.js';
|
||||
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
|
||||
import { appendAdminAudit } from '../admin-audit.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { AuthPort } from '../ports/auth-port.js';
|
||||
import type { SessionPort } from '../ports/session-port.js';
|
||||
import type { EventPort } from '../ports/event-port.js';
|
||||
|
||||
const CreateUserSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
role: z.enum(['admin', 'user']).default('user'),
|
||||
password: z.string().min(8).max(1024).optional(),
|
||||
canBypassPermissions: z.boolean().optional(),
|
||||
});
|
||||
const UpdateUserSchema = z.object({
|
||||
role: z.enum(['admin', 'user']).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
canBypassPermissions: z.boolean().optional(),
|
||||
});
|
||||
const DeleteUserSchema = z.object({ deleteSpace: z.boolean().optional() });
|
||||
|
||||
/** Map a UserStoreError's code onto the API error code + status. */
|
||||
function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof createErrorResponse> {
|
||||
if (err instanceof UserStoreError) {
|
||||
const code = ApiErrorCode[err.code as keyof typeof ApiErrorCode] ?? ApiErrorCode.INVALID_INPUT;
|
||||
reply.code(
|
||||
err.code === 'USER_EXISTS' || err.code === 'LAST_ADMIN' ? 409 : err.code === 'USER_NOT_FOUND' ? 404 : 400
|
||||
);
|
||||
return createErrorResponse(code, err.message);
|
||||
}
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : 'error');
|
||||
}
|
||||
|
||||
export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & AuthPort & EventPort): void {
|
||||
// Gate: admin routes exist only in multi-user mode, and only for admins.
|
||||
const gate = (req: FastifyRequest, reply: FastifyReply): boolean => {
|
||||
if (!isMultiUserMode()) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Not found'));
|
||||
return false;
|
||||
}
|
||||
return requireAdmin(req, reply);
|
||||
};
|
||||
const audit = (req: FastifyRequest, action: string, target?: string, detail?: Record<string, unknown>) =>
|
||||
void appendAdminAudit({ admin: getAuthUser(req).username, action, target, ip: req.ip, detail });
|
||||
|
||||
// Count a user's live sessions + active cookie sessions + case folders.
|
||||
const statsFor = (username: string) => {
|
||||
let liveSessions = 0;
|
||||
for (const s of ctx.sessions.values()) if (s.owner === username) liveSessions++;
|
||||
let activeSessions = 0;
|
||||
if (ctx.authSessions) for (const [, rec] of ctx.authSessions) if (rec.username === username) activeSessions++;
|
||||
let caseCount = 0;
|
||||
try {
|
||||
caseCount = readdirSync(userCasesDir(username), { withFileTypes: true }).filter((e) => e.isDirectory()).length;
|
||||
} catch {
|
||||
/* no cases dir yet */
|
||||
}
|
||||
return { liveSessions, activeSessions, caseCount };
|
||||
};
|
||||
|
||||
app.get('/api/admin/users', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const users = await readUsers(true);
|
||||
return {
|
||||
success: true,
|
||||
data: users.map((u) => ({ ...toPublicUser(u), stats: statsFor(u.username) })),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/admin/users', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const parsed = CreateUserSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input');
|
||||
}
|
||||
// No password given: generate a one-time password, returned ONCE, force change.
|
||||
const oneTime = parsed.data.password ? undefined : generateOneTimePassword();
|
||||
try {
|
||||
const user = await createUser({
|
||||
username: parsed.data.username,
|
||||
role: parsed.data.role,
|
||||
password: parsed.data.password ?? oneTime!,
|
||||
canBypassPermissions: parsed.data.canBypassPermissions,
|
||||
mustChangePassword: !parsed.data.password,
|
||||
});
|
||||
audit(req, 'user.create', user.username, { role: user.role });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { user: toPublicUser(user), oneTimePassword: oneTime } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/admin/users/:username', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const parsed = UpdateUserSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input');
|
||||
}
|
||||
try {
|
||||
const user = await updateUser(username, parsed.data);
|
||||
// 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) } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/users/:username/reset-password', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
if (!(await findUser(username))) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user');
|
||||
}
|
||||
const oneTime = generateOneTimePassword();
|
||||
try {
|
||||
await setPassword(username, oneTime, { mustChangePassword: true });
|
||||
revokeUserSessions(ctx.authSessions, username);
|
||||
audit(req, 'user.reset-password', username);
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { oneTimePassword: oneTime } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/users/:username/logout', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const revoked = revokeUserSessions(ctx.authSessions, username);
|
||||
audit(req, 'user.logout', username, { revoked });
|
||||
return { success: true, data: { revoked } };
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:username', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const parsed = DeleteUserSchema.safeParse(req.body ?? {});
|
||||
const deleteSpace = parsed.success ? parsed.data.deleteSpace : false;
|
||||
try {
|
||||
// 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);
|
||||
if (deleteSpace) await deleteUserSpace(username);
|
||||
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { username, deletedSpace: !!deleteSpace } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
+701
-53
@@ -5,11 +5,13 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, createReadStream } from 'node:fs';
|
||||
import { exec } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { join, resolve, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, CaseInfo, RemoteSessionInfo } from '../../types.js';
|
||||
import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import {
|
||||
CreateCaseSchema,
|
||||
@@ -17,13 +19,49 @@ import {
|
||||
CaseOrderSchema,
|
||||
RemoteCaseLinkSchema,
|
||||
RemoteHostSchema,
|
||||
DockerCaseLinkSchema,
|
||||
DockerHostSchema,
|
||||
DockerExportSchema,
|
||||
DockerImportSchema,
|
||||
DockerQuickCreateSchema,
|
||||
} from '../schemas.js';
|
||||
import { exportDockerCase, importDockerBundle, listDockerExports, exportBundleName } from '../../docker-export.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||
import {
|
||||
canAccessOwned,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
isWorkingDirAllowed,
|
||||
ownerFor,
|
||||
resolveCasesDir,
|
||||
SETTINGS_PATH,
|
||||
validatePathWithinBase,
|
||||
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';
|
||||
import type { EventPort, ConfigPort, SessionPort } from '../ports/index.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
checkDockerAvailable,
|
||||
checkDockerImagePresent,
|
||||
checkDockerTmuxAvailable,
|
||||
ensureAgentBaseImage,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
dockerContainerName,
|
||||
dockerDisplayPath,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
removeDockerContainer,
|
||||
toSessionDocker,
|
||||
writeDockerCases,
|
||||
writeDockerHosts,
|
||||
} from '../../docker-hosts.js';
|
||||
import { buildDockerRemoveCommand } from '../../tmux-manager.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
listRemoteCodemanSessions,
|
||||
@@ -37,67 +75,134 @@ import {
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
const DOCKER_EXPORTS_DIR = dataPath('docker-exports');
|
||||
/** Auto-created host profile for the one-click "Run in Docker" case flow. */
|
||||
const DEFAULT_DOCKER_HOST_ID = 'default';
|
||||
|
||||
/** App version for export manifests (best-effort read of package.json). */
|
||||
const APP_VERSION = (() => {
|
||||
try {
|
||||
const pkgPath = fileURLToPath(new URL('../../../package.json', import.meta.url));
|
||||
return (JSON.parse(readFileSync(pkgPath, 'utf-8')).version as string) || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
})();
|
||||
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
|
||||
}
|
||||
|
||||
/** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */
|
||||
async function resolveCasePath(name: string): Promise<string> {
|
||||
/**
|
||||
* Resolve a case name to its directory path, checking linked cases first, then the
|
||||
* user's case space (per-user in multi-user mode, the shared CASES_DIR otherwise).
|
||||
*/
|
||||
async function resolveCasePath(name: string, user?: AuthUser): Promise<string> {
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name]) return linkedCases[name];
|
||||
return join(CASES_DIR, name);
|
||||
// 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);
|
||||
}
|
||||
|
||||
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
|
||||
/**
|
||||
* Gate a docker case on its base image, AUTO-BUILDING the default image on first
|
||||
* use so a missing image is never a blocker (the user's ask: "create it when it's
|
||||
* used for the first time"). Present image → verify tmux (hard prerequisite).
|
||||
* Default image missing → kick off a BACKGROUND build with SSE progress and return
|
||||
* `imageBuilding: true` (the case is created regardless; first launch awaits the
|
||||
* same dedup'd build). Custom image missing → a real error (we can't build a
|
||||
* foreign ref, and `--pull=never` forbids pulling).
|
||||
*/
|
||||
async function ensureCaseImage(
|
||||
broadcast: EventPort['broadcast'],
|
||||
sessionDocker: SessionDocker,
|
||||
name: string
|
||||
): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> {
|
||||
if (await checkDockerImagePresent(sessionDocker, sessionDocker.image)) {
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||||
if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
|
||||
return { ok: true, imageBuilding: false };
|
||||
}
|
||||
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `base image ${sessionDocker.image} not present; only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull it first.`,
|
||||
};
|
||||
}
|
||||
broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
|
||||
void ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
||||
onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
|
||||
})
|
||||
.then((r) =>
|
||||
broadcast(r.ok ? SseEvent.DockerImageBuildComplete : SseEvent.DockerImageBuildFailed, {
|
||||
name,
|
||||
image: sessionDocker.image,
|
||||
error: r.error,
|
||||
})
|
||||
)
|
||||
.catch((err) =>
|
||||
broadcast(SseEvent.DockerImageBuildFailed, { name, image: sessionDocker.image, error: getErrorMessage(err) })
|
||||
);
|
||||
return { ok: true, imageBuilding: true };
|
||||
}
|
||||
|
||||
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort & SessionPort): void {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case CRUD (list, create, link, detail, fix-plan)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== List Cases ==========
|
||||
|
||||
app.get('/api/cases', async (): Promise<CaseInfo[]> => {
|
||||
app.get('/api/cases', async (req): Promise<CaseInfo[]> => {
|
||||
const cases: CaseInfo[] = [];
|
||||
const user = getAuthUser(req);
|
||||
const admin = isAdmin(req);
|
||||
// Non-admins enumerate their OWN case space; admins see the shared CASES_DIR.
|
||||
const listBase = resolveCasesDir(user);
|
||||
|
||||
// Get cases from CASES_DIR
|
||||
// Get cases from the user's (or shared) cases dir
|
||||
try {
|
||||
const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
|
||||
const entries = await fs.readdir(listBase, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
|
||||
cases.push({
|
||||
name: e.name,
|
||||
path: join(CASES_DIR, e.name),
|
||||
hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')),
|
||||
path: join(listBase, e.name),
|
||||
hasClaudeMd: existsSync(join(listBase, e.name, 'CLAUDE.md')),
|
||||
location: 'local',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// CASES_DIR may not exist yet
|
||||
// dir may not exist yet
|
||||
}
|
||||
|
||||
// Get linked cases
|
||||
// Linked cases (v1 registry has no owner) are admin-only in multi-user mode.
|
||||
const linkedCases = await readLinkedCases();
|
||||
const existingNames = new Set(cases.map((c) => c.name));
|
||||
for (const [name, path] of Object.entries(linkedCases)) {
|
||||
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
||||
cases.push({
|
||||
name,
|
||||
path,
|
||||
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
||||
linked: true,
|
||||
location: 'linked-local',
|
||||
});
|
||||
if (admin) {
|
||||
for (const [name, path] of Object.entries(linkedCases)) {
|
||||
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
||||
cases.push({
|
||||
name,
|
||||
path,
|
||||
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
||||
linked: true,
|
||||
location: 'linked-local',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get remote cases
|
||||
// Get remote cases (owner-scoped; legacy no-owner = admin-only)
|
||||
const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
|
||||
for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
|
||||
const host = remoteHostMap.get(remoteCase.hostId);
|
||||
if (!host || !SAFE_CASE_NAME.test(remoteCase.name)) continue;
|
||||
if (!admin && !canAccessOwned(user, remoteCase.owner)) continue;
|
||||
existingNames.add(remoteCase.name);
|
||||
const remoteCaseInfo: CaseInfo = {
|
||||
name: remoteCase.name,
|
||||
@@ -119,6 +224,36 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
}
|
||||
|
||||
// Get docker cases
|
||||
const dockerHosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const dockerHostMap = new Map(dockerHosts.map((host) => [host.id, host]));
|
||||
for (const dockerCase of await readDockerCases(CODEMAN_CONFIG_DIR)) {
|
||||
const host = dockerHostMap.get(dockerCase.hostId);
|
||||
if (!host || !SAFE_CASE_NAME.test(dockerCase.name)) continue;
|
||||
if (!admin && !canAccessOwned(user, dockerCase.owner)) continue;
|
||||
existingNames.add(dockerCase.name);
|
||||
const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
|
||||
const dockerCaseInfo: CaseInfo = {
|
||||
name: dockerCase.name,
|
||||
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
|
||||
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||
location: 'docker',
|
||||
docker: {
|
||||
hostId: host.id,
|
||||
container,
|
||||
image: host.image,
|
||||
path: dockerCase.hostWorkspacePath,
|
||||
network: host.network ?? 'bridge',
|
||||
},
|
||||
};
|
||||
const existingIndex = cases.findIndex((item) => item.name === dockerCase.name);
|
||||
if (existingIndex === -1) {
|
||||
cases.push(dockerCaseInfo);
|
||||
} else {
|
||||
cases[existingIndex] = dockerCaseInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by persisted caseOrder from settings.json
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings', {});
|
||||
const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : [];
|
||||
@@ -137,7 +272,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
const { name, description } = parseBody(CreateCaseSchema, req.body);
|
||||
|
||||
const casePath = validatePathWithinBase(name, CASES_DIR);
|
||||
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
@@ -166,7 +301,17 @@ 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 =>
|
||||
isAdmin(req)
|
||||
? null
|
||||
: (reply.code(403), createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'));
|
||||
|
||||
// COD-105 — discover `codeman-*` tmux sessions already running on a remote
|
||||
// host (created by the remote's own Codeman, another instance, or this one)
|
||||
@@ -174,9 +319,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// trigger only (Decision A): the frontend calls this on a "Discover" click,
|
||||
// never automatically on host select. listRemoteCodemanSessions never throws
|
||||
// (returns [] on unreachable/no-tmux/no-sessions) and is ssh-guarded under test.
|
||||
// Hosts are admin-only infra in multi-user mode, so discovery is too.
|
||||
app.get(
|
||||
'/api/remote-hosts/:hostId/sessions',
|
||||
async (req): Promise<ApiResponse<{ sessions: RemoteSessionInfo[] }>> => {
|
||||
async (req, reply): Promise<ApiResponse<{ sessions: RemoteSessionInfo[] }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { hostId } = req.params as { hostId: string };
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
@@ -185,7 +333,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
);
|
||||
|
||||
app.post('/api/remote-hosts', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
app.post('/api/remote-hosts', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const host = parseBody(RemoteHostSchema, req.body);
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
if (hosts.some((item) => item.id === host.id)) {
|
||||
@@ -195,7 +345,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.put('/api/remote-hosts/:id', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
app.put('/api/remote-hosts/:id', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
const host = parseBody(RemoteHostSchema, { ...(req.body as object), id });
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
@@ -207,7 +359,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.delete('/api/remote-hosts/:id', async (req): Promise<ApiResponse<{ id: string }>> => {
|
||||
app.delete('/api/remote-hosts/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
const cases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (cases.some((item) => item.hostId === id)) {
|
||||
@@ -222,7 +376,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
});
|
||||
|
||||
app.post('/api/cases/remote-link', async (req): Promise<ApiResponse<{ case: unknown }>> => {
|
||||
const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const };
|
||||
const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const, owner: ownerFor(req) };
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
const host = hosts.find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
@@ -232,7 +386,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
if (
|
||||
remoteCases.some((item) => item.name === remoteCase.name) ||
|
||||
linkedCases[remoteCase.name] ||
|
||||
existsSync(join(CASES_DIR, remoteCase.name))
|
||||
existsSync(join(resolveCasesDir(getAuthUser(req)), remoteCase.name))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
@@ -250,8 +404,444 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return { success: true, data: { case: remoteCase } };
|
||||
});
|
||||
|
||||
// ========== Docker hosts + docker cases (COD-Docker) ==========
|
||||
|
||||
// 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);
|
||||
if (denied) return denied;
|
||||
const host = parseBody(DockerHostSchema, req.body);
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
if (hosts.some((item) => item.id === host.id)) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Docker host already exists');
|
||||
}
|
||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.put('/api/docker-hosts/:id', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
const host = parseBody(DockerHostSchema, { ...(req.body as object), id });
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const index = hosts.findIndex((item) => item.id === id);
|
||||
if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const next = [...hosts];
|
||||
next[index] = host;
|
||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, next);
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.delete('/api/docker-hosts/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
const cases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (cases.some((item) => item.hostId === id)) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Docker host is still used by docker cases');
|
||||
}
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
await writeDockerHosts(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
hosts.filter((item) => item.id !== id)
|
||||
);
|
||||
return { success: true, data: { id } };
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/api/cases/docker-link',
|
||||
async (
|
||||
req
|
||||
): Promise<
|
||||
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }>
|
||||
> => {
|
||||
const dockerCase = {
|
||||
...parseBody(DockerCaseLinkSchema, req.body),
|
||||
type: 'docker' as const,
|
||||
owner: ownerFor(req),
|
||||
};
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const host = hosts.find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
dockerCases.some((item) => item.name === dockerCase.name) ||
|
||||
linkedCases[dockerCase.name] ||
|
||||
existsSync(join(resolveCasesDir(getAuthUser(req)), dockerCase.name))
|
||||
) {
|
||||
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.
|
||||
if (!existsSync(dockerCase.hostWorkspacePath)) {
|
||||
try {
|
||||
mkdirSync(dockerCase.hostWorkspacePath, { recursive: true });
|
||||
} catch (err) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`Could not create workspace: ${getErrorMessage(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Courtesy validation: docker daemon must be reachable. The base image is
|
||||
// auto-built on first use (default image) rather than being a link-time
|
||||
// blocker, so a missing image kicks off a background build instead of erroring.
|
||||
const availability = await checkDockerAvailable(host.engine);
|
||||
if (!availability.ok) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
availability.error || 'docker daemon is not available'
|
||||
);
|
||||
}
|
||||
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), dockerCase.name);
|
||||
if (!imageGate.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
|
||||
}
|
||||
|
||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
|
||||
ctx.broadcast(SseEvent.CaseLinked, {
|
||||
name: dockerCase.name,
|
||||
path: dockerCase.hostWorkspacePath,
|
||||
type: 'docker',
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
case: dockerCase,
|
||||
capsEnforced: availability.capsEnforced,
|
||||
isDesktop: availability.isDesktop,
|
||||
imageBuilding: imageGate.imageBuilding,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// One-click "Run in Docker": create a NORMAL case (folder in CASES_DIR, scaffolded)
|
||||
// AND link it to a hardened container with default settings, auto-provisioning a
|
||||
// shared `default` docker host so the user never touches host/image/network fields.
|
||||
app.post(
|
||||
'/api/cases/docker-quickcreate',
|
||||
async (
|
||||
req
|
||||
): Promise<
|
||||
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }>
|
||||
> => {
|
||||
const body = parseBody(DockerQuickCreateSchema, req.body);
|
||||
const { name, description } = body;
|
||||
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
|
||||
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
|
||||
// Collision across every case kind.
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
existsSync(casePath) ||
|
||||
dockerCases.some((item) => item.name === name) ||
|
||||
remoteCases.some((item) => item.name === name) ||
|
||||
linkedCases[name]
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// The checkbox alone (no overrides) uses the shared `default` host; any tweaked
|
||||
// setting gets a dedicated per-case host so it never mutates the shared default.
|
||||
const hasOverrides = !!(
|
||||
body.image ||
|
||||
body.network ||
|
||||
body.networkName ||
|
||||
body.memory ||
|
||||
body.cpus ||
|
||||
body.gpus ||
|
||||
body.mountCredentials !== undefined
|
||||
);
|
||||
const resources: { memory?: string; cpus?: string } = {};
|
||||
if (body.memory) resources.memory = body.memory;
|
||||
if (body.cpus) resources.cpus = body.cpus;
|
||||
const desiredHost: DockerHost = {
|
||||
id: hasOverrides ? `q-${name}` : DEFAULT_DOCKER_HOST_ID,
|
||||
label: hasOverrides ? `Case: ${name}` : 'Default',
|
||||
image: body.image || DEFAULT_AGENT_IMAGE,
|
||||
network: body.network || 'bridge',
|
||||
...(body.networkName ? { networkName: body.networkName } : {}),
|
||||
...(Object.keys(resources).length ? { resources } : {}),
|
||||
...(body.gpus ? { gpus: body.gpus } : {}),
|
||||
mountCredentials: body.mountCredentials ?? true,
|
||||
resumeOnStart: true,
|
||||
hooksEnabled: true,
|
||||
};
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const existing = hosts.find((item) => item.id === desiredHost.id);
|
||||
// Reuse the shared default if present; create/refresh a per-case host for overrides.
|
||||
const host = existing && !hasOverrides ? existing : desiredHost;
|
||||
if (!existing) {
|
||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, desiredHost]);
|
||||
} else if (hasOverrides) {
|
||||
await writeDockerHosts(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
hosts.map((h) => (h.id === desiredHost.id ? desiredHost : h))
|
||||
);
|
||||
}
|
||||
|
||||
// Probe the daemon BEFORE scaffolding so a missing docker surfaces a clear
|
||||
// error instead of leaving an orphaned case folder. The base image is NOT a
|
||||
// blocker: a missing default image auto-builds in the background on first use.
|
||||
const availability = await checkDockerAvailable(host.engine);
|
||||
if (!availability.ok) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
availability.error || 'docker daemon is not available'
|
||||
);
|
||||
}
|
||||
const dockerCase = {
|
||||
name,
|
||||
type: 'docker' as const,
|
||||
hostId: host.id,
|
||||
hostWorkspacePath: casePath,
|
||||
owner: ownerFor(req),
|
||||
};
|
||||
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), name);
|
||||
if (!imageGate.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
|
||||
}
|
||||
|
||||
// Scaffold the case folder exactly like a normal case.
|
||||
try {
|
||||
mkdirSync(casePath, { recursive: true });
|
||||
mkdirSync(join(casePath, 'src'), { recursive: true });
|
||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||
writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, description || '', templatePath));
|
||||
await writeHooksConfig(casePath);
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
|
||||
ctx.broadcast(SseEvent.CaseLinked, { name, path: casePath, type: 'docker' });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
case: dockerCase,
|
||||
capsEnforced: availability.capsEnforced,
|
||||
isDesktop: availability.isDesktop,
|
||||
imageBuilding: imageGate.imageBuilding,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ========== Docker export / import ==========
|
||||
|
||||
// Export a docker case to a portable bundle. Runs in the BACKGROUND (a full image
|
||||
// save can take minutes) and broadcasts docker:exportComplete / docker:exportFailed.
|
||||
app.post('/api/docker-cases/:name/export', async (req): Promise<ApiResponse<{ started: true; bundle: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const { mode = 'full' } = parseBody(DockerExportSchema, req.body ?? {});
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
|
||||
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
if (mode === 'full' && !sessionDocker.mountCredentials) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'full-image export is refused for a sealed container (its in-container login would ride the committed layer). Use a workspace-only export.'
|
||||
);
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const bundle = exportBundleName(name, timestamp, mode);
|
||||
// Fire-and-forget: the client watches for the SSE completion event.
|
||||
void exportDockerCase({
|
||||
docker: sessionDocker,
|
||||
caseName: name,
|
||||
timestamp,
|
||||
exportsDir: DOCKER_EXPORTS_DIR,
|
||||
mode,
|
||||
codemanVersion: APP_VERSION,
|
||||
})
|
||||
.then((result) => {
|
||||
ctx.broadcast(SseEvent.DockerExportComplete, {
|
||||
name,
|
||||
bundle: basename(result.bundlePath),
|
||||
sizeBytes: result.sizeBytes,
|
||||
mode,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
ctx.broadcast(SseEvent.DockerExportFailed, { name, mode, error: getErrorMessage(err) });
|
||||
});
|
||||
|
||||
return { success: true, data: { started: true, bundle } };
|
||||
});
|
||||
|
||||
app.get('/api/docker-exports', async (): Promise<ApiResponse<{ exports: unknown[] }>> => {
|
||||
return { success: true, data: { exports: await listDockerExports(DOCKER_EXPORTS_DIR) } };
|
||||
});
|
||||
|
||||
// Download an export bundle (filename resolved WITHIN the exports dir — no traversal).
|
||||
app.get('/api/docker-exports/:filename', async (req, reply) => {
|
||||
const { filename } = req.params as { filename: string };
|
||||
if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid bundle filename');
|
||||
}
|
||||
const full = join(DOCKER_EXPORTS_DIR, filename);
|
||||
if (!existsSync(full)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Export not found');
|
||||
reply.header('Content-Type', 'application/gzip');
|
||||
reply.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
return reply.send(createReadStream(full));
|
||||
});
|
||||
|
||||
app.delete('/api/docker-exports/:filename', async (req): Promise<ApiResponse<{ filename: string }>> => {
|
||||
const { filename } = req.params as { filename: string };
|
||||
if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid bundle filename');
|
||||
}
|
||||
const full = join(DOCKER_EXPORTS_DIR, filename);
|
||||
if (!existsSync(full)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Export not found');
|
||||
await fs.rm(full, { force: true });
|
||||
return { success: true, data: { filename } };
|
||||
});
|
||||
|
||||
// Import a bundle (already present in the exports dir) into a NEW docker case.
|
||||
app.post('/api/docker-cases/import', async (req): Promise<ApiResponse<{ case: unknown }>> => {
|
||||
const { bundle, newCaseName, destWorkspacePath } = parseBody(DockerImportSchema, req.body);
|
||||
const bundlePath = join(DOCKER_EXPORTS_DIR, bundle);
|
||||
if (!existsSync(bundlePath)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Bundle not found in exports dir');
|
||||
|
||||
// Name-collision guard across ALL case kinds.
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
dockerCases.some((item) => item.name === newCaseName) ||
|
||||
linkedCases[newCaseName] ||
|
||||
existsSync(join(resolveCasesDir(getAuthUser(req)), newCaseName))
|
||||
) {
|
||||
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 {
|
||||
result = await importDockerBundle({
|
||||
bundlePath,
|
||||
destWorkspace: destWorkspacePath,
|
||||
engine: 'docker',
|
||||
timestamp,
|
||||
newCaseName,
|
||||
});
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
// Create (or REFRESH) the dedicated docker host pointing at the quarantined
|
||||
// imported image (full mode) or the manifest's base image (workspace-only).
|
||||
// Refresh matters: after a case-delete + re-import of the same name, a stale
|
||||
// `imported-<name>` host would silently pin the PREVIOUS import's image tag.
|
||||
const hostId = `imported-${newCaseName}`;
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const importedHost = {
|
||||
id: hostId,
|
||||
label: `Imported: ${newCaseName}`,
|
||||
engine: result.manifest.engine,
|
||||
image: result.importedImage ?? result.manifest.image,
|
||||
network: (['bridge', 'none', 'custom'].includes(result.manifest.network) ? result.manifest.network : 'bridge') as
|
||||
| 'bridge'
|
||||
| 'none'
|
||||
| 'custom',
|
||||
};
|
||||
await writeDockerHosts(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
hosts.some((h) => h.id === hostId)
|
||||
? hosts.map((h) => (h.id === hostId ? { ...h, ...importedHost } : h))
|
||||
: [...hosts, importedHost]
|
||||
);
|
||||
const newCase = {
|
||||
name: newCaseName,
|
||||
type: 'docker' as const,
|
||||
hostId,
|
||||
hostWorkspacePath: destWorkspacePath,
|
||||
containerWorkdir: result.manifest.containerWorkdir,
|
||||
owner: ownerFor(req),
|
||||
};
|
||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, newCase]);
|
||||
ctx.broadcast(SseEvent.DockerImportComplete, { name: newCaseName, path: destWorkspacePath, type: 'docker' });
|
||||
return { success: true, data: { case: newCase } };
|
||||
});
|
||||
|
||||
// Recreate-on-drift confirm (docs/docker-cases-plan.md §4): remove the case
|
||||
// container so the next launch recreates it with the CURRENT host config. The
|
||||
// workspace + transcripts ride bind mounts and survive; the conversation resumes
|
||||
// via the case's lastClaudeSessionId. Refused while sessions of the case are live
|
||||
// (removal would yank the container out from under their panes).
|
||||
app.post(
|
||||
'/api/docker-cases/:name/recreate',
|
||||
async (req): Promise<ApiResponse<{ name: string; container: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
|
||||
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.docker?.containerName === sessionDocker.containerName && session.pid) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.CONFLICT,
|
||||
`Sessions of case "${name}" are still running — stop them first, then recreate the container.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await removeDockerContainer(sessionDocker);
|
||||
} catch (err) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`Failed to remove container: ${getErrorMessage(err)}`
|
||||
);
|
||||
}
|
||||
// Drop the per-container claude-config seed; it is regenerated at next launch.
|
||||
await fs
|
||||
.rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
|
||||
.catch(() => {});
|
||||
ctx.broadcast(SseEvent.DockerContainerRecreated, { name, container: sessionDocker.containerName });
|
||||
return { success: true, data: { name, container: sessionDocker.containerName } };
|
||||
}
|
||||
);
|
||||
|
||||
// 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
|
||||
@@ -263,7 +853,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
|
||||
// Check if case name already exists in CASES_DIR
|
||||
const casePath = join(CASES_DIR, name);
|
||||
const casePath = join(resolveCasesDir(getAuthUser(req)), name);
|
||||
if (existsSync(casePath)) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
|
||||
}
|
||||
@@ -298,24 +888,56 @@ 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, CASES_DIR)) {
|
||||
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 } };
|
||||
}
|
||||
|
||||
// Check linked cases first — unlink only, don't delete the actual directory
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
const dockerCase = dockerCases.find((item) => item.name === name && canAccessOwned(user, item.owner));
|
||||
if (dockerCase) {
|
||||
await writeDockerCases(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
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).
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (host) {
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
try {
|
||||
exec(buildDockerRemoveCommand(sessionDocker), { timeout: 15_000 }, () => {});
|
||||
} catch {
|
||||
/* best-effort — never blocks the unlink */
|
||||
}
|
||||
// Remove the per-container claude-config seed file (account metadata copy).
|
||||
await fs
|
||||
.rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' });
|
||||
return { success: true, data: { name } };
|
||||
}
|
||||
|
||||
// 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));
|
||||
@@ -327,7 +949,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
|
||||
// Case in CASES_DIR — delete the entire directory
|
||||
const casePath = join(CASES_DIR, name);
|
||||
const casePath = join(resolveCasesDir(getAuthUser(req)), name);
|
||||
if (!existsSync(casePath)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
|
||||
}
|
||||
@@ -369,12 +991,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
app.get('/api/cases/:name', async (req) => {
|
||||
const { name } = req.params as { name: string };
|
||||
|
||||
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||
if (!validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// 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');
|
||||
@@ -392,13 +1018,35 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
};
|
||||
}
|
||||
|
||||
const casePath = await resolveCasePath(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');
|
||||
const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
|
||||
return {
|
||||
name,
|
||||
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
|
||||
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||
location: 'docker',
|
||||
docker: {
|
||||
hostId: host.id,
|
||||
container,
|
||||
image: host.image,
|
||||
path: dockerCase.hostWorkspacePath,
|
||||
network: host.network ?? 'bridge',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const casePath = await resolveCasePath(name, getAuthUser(req));
|
||||
|
||||
if (!existsSync(casePath)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
|
||||
}
|
||||
|
||||
const linked = casePath !== join(CASES_DIR, name);
|
||||
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
|
||||
return {
|
||||
name,
|
||||
path: casePath,
|
||||
@@ -411,12 +1059,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
app.get('/api/cases/:name/fix-plan', async (req) => {
|
||||
const { name } = req.params as { name: string };
|
||||
|
||||
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||
if (!validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Get case path (check linked cases first, then CASES_DIR)
|
||||
const casePath = await resolveCasePath(name);
|
||||
const casePath = await resolveCasePath(name, getAuthUser(req));
|
||||
|
||||
const fixPlanPath = join(casePath, '@fix_plan.md');
|
||||
|
||||
@@ -516,11 +1164,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
|
||||
const { caseName } = req.params as { caseName: string };
|
||||
if (!validatePathWithinBase(caseName, CASES_DIR)) {
|
||||
if (!validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
const casePath = await resolveCasePath(caseName);
|
||||
const casePath = await resolveCasePath(caseName, getAuthUser(req));
|
||||
|
||||
const wizardDir = join(casePath, 'ralph-wizard');
|
||||
|
||||
@@ -559,7 +1207,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// Cache disabled to ensure fresh prompts when starting new plan generations
|
||||
app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
|
||||
const { caseName, filePath } = req.params as { caseName: string; filePath: string };
|
||||
if (!validatePathWithinBase(caseName, CASES_DIR)) {
|
||||
if (!validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
@@ -568,7 +1216,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
reply.header('Pragma', 'no-cache');
|
||||
reply.header('Expires', '0');
|
||||
|
||||
const casePath = await resolveCasePath(caseName);
|
||||
const casePath = await resolveCasePath(caseName, getAuthUser(req));
|
||||
|
||||
const wizardDir = join(casePath, 'ralph-wizard');
|
||||
|
||||
|
||||
@@ -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,33 +9,77 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import { canAccessOwned, getAuthUser, 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';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
// A job the caller may see/act on (own, or admin/single-user).
|
||||
const canTouch = (req: FastifyRequest, job: CronJob | null | undefined): job is CronJob =>
|
||||
!!job && canAccessOwned(getAuthUser(req), job.owner);
|
||||
|
||||
// ── Jobs ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/cron/jobs', async () => {
|
||||
return ctx.cron.listJobs();
|
||||
app.get('/api/cron/jobs', async (req) => {
|
||||
const jobs = ctx.cron.listJobs();
|
||||
if (!isMultiUserMode()) return jobs;
|
||||
const user = getAuthUser(req);
|
||||
if (user.role === 'admin') return jobs;
|
||||
return (jobs as CronJob[]).filter((j) => canAccessOwned(user, j.owner));
|
||||
});
|
||||
|
||||
app.post('/api/cron/jobs', async (req) => {
|
||||
// No custom errorMessage: surface the schema's field-specific messages
|
||||
// (e.g. "runAt is required for a one-time schedule").
|
||||
const body = parseBody(CronJobSchema, req.body);
|
||||
return { job: ctx.cron.createJob(body) };
|
||||
// Section 6.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.
|
||||
// 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'
|
||||
);
|
||||
}
|
||||
return { job: ctx.cron.createJob(body, ownerFor(req)) };
|
||||
});
|
||||
|
||||
app.get('/api/cron/jobs/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const job = ctx.cron.getJob(id);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
if (!canTouch(req, job)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
return job;
|
||||
});
|
||||
|
||||
app.put('/api/cron/jobs/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
const body = parseBody(CronJobUpdateSchema, req.body);
|
||||
// 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'
|
||||
);
|
||||
}
|
||||
const job = ctx.cron.updateJob(id, body);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
return { job };
|
||||
@@ -43,7 +87,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
|
||||
app.delete('/api/cron/jobs/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!ctx.cron.deleteJob(id)) {
|
||||
if (!canTouch(req, ctx.cron.getJob(id)) || !ctx.cron.deleteJob(id)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
}
|
||||
return {};
|
||||
@@ -51,6 +95,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
|
||||
app.put('/api/cron/jobs/:id/enabled', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!canTouch(req, ctx.cron.getJob(id))) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
const { enabled } = parseBody(CronJobEnabledSchema, req.body, 'Invalid request body');
|
||||
const job = ctx.cron.setEnabled(id, enabled);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
@@ -62,7 +107,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
|
||||
app.post('/api/cron/jobs/:id/run', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const job = ctx.cron.getJob(id);
|
||||
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
if (!canTouch(req, job)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
|
||||
const run = await ctx.cron.runNow(id);
|
||||
return { run, activeAgents: ctx.cron.countActiveAgents(job.agentType, job.id) };
|
||||
});
|
||||
@@ -71,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)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
||||
import { canAccessOwned, findSessionOrFail, getAuthUser, validateSessionFilePath } from '../route-helpers.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -227,13 +228,17 @@ async function serveThumbnail(reply: FastifyReply, resolvedPath: string, extensi
|
||||
function getKnownSessionWorkingDir(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId: string,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
req: FastifyRequest
|
||||
): string | undefined {
|
||||
// Multi-user: a non-admin may only reach their OWN session's files. A foreign
|
||||
// (or missing) session is reported identically as 404 so existence isn't leaked.
|
||||
const user = getAuthUser(req);
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession) return liveSession.workingDir;
|
||||
if (liveSession && canAccessOwned(user, liveSession.owner)) return liveSession.workingDir;
|
||||
|
||||
const stored = ctx.store.getSession(sessionId);
|
||||
if (stored) return stored.workingDir;
|
||||
if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) return stored.workingDir;
|
||||
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
|
||||
return undefined;
|
||||
@@ -261,10 +266,13 @@ function appendDownloadFlag(url: string): string {
|
||||
|
||||
function getSessionAttachmentHistory(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId: string
|
||||
sessionId: string,
|
||||
req: FastifyRequest
|
||||
): { workingDir: string; history: SessionAttachmentHistoryItem[] } | undefined {
|
||||
const user = getAuthUser(req);
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession) {
|
||||
if (!canAccessOwned(user, liveSession.owner)) return undefined;
|
||||
return {
|
||||
workingDir: liveSession.workingDir,
|
||||
history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [],
|
||||
@@ -272,7 +280,7 @@ function getSessionAttachmentHistory(
|
||||
}
|
||||
|
||||
const stored = ctx.store.getSession(sessionId) as StoredSessionWithPrivateAttachmentHistory | undefined;
|
||||
if (!stored) return undefined;
|
||||
if (!stored || !canAccessOwned(user, (stored as { owner?: string }).owner)) return undefined;
|
||||
|
||||
return {
|
||||
workingDir: stored.workingDir,
|
||||
@@ -371,7 +379,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { depth, showHidden } = req.query as { depth?: string; showHidden?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
|
||||
const includeHidden = showHidden === 'true';
|
||||
@@ -495,7 +503,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-content', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!filePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
|
||||
@@ -648,7 +656,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath, download } = req.query as { path?: string; download?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!filePath) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||
@@ -737,7 +745,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// attachment-history list are layered on separately.
|
||||
app.post('/api/sessions/:id/attachments', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const body = (req.body || {}) as { path?: string };
|
||||
|
||||
if (!body.path || typeof body.path !== 'string') {
|
||||
@@ -766,7 +774,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// each entry to current metadata + routes. External entries are re-registered.
|
||||
app.get('/api/sessions/:id/attachments', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const sessionHistory = getSessionAttachmentHistory(ctx, id);
|
||||
const sessionHistory = getSessionAttachmentHistory(ctx, id, req);
|
||||
if (!sessionHistory) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`));
|
||||
return;
|
||||
@@ -794,7 +802,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// size/mtime as the underlying file is rewritten).
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
@@ -831,7 +839,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const { download } = req.query as { download?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir);
|
||||
@@ -850,7 +858,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// convert server-side; PDF/PNG/text redirect to the raw route.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
@@ -870,7 +878,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// Serve a first-page thumbnail of a registered attachment by id.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
@@ -884,7 +892,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
|
||||
if (!filePath) {
|
||||
@@ -912,7 +920,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
|
||||
if (!filePath) {
|
||||
@@ -941,7 +949,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath, lines } = req.query as { path?: string; lines?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!filePath) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||
@@ -1003,7 +1011,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// malformed error envelope instead of wrapping it).
|
||||
app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
|
||||
const { id, streamId } = req.params as { id: string; streamId: string };
|
||||
findSessionOrFail(ctx, id); // Validates session exists
|
||||
findSessionOrFail(ctx, id, req); // Validates session exists
|
||||
const closed = fileStreamManager.closeStream(streamId);
|
||||
return { closed };
|
||||
});
|
||||
@@ -1024,7 +1032,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
return;
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, sessionId);
|
||||
const session = findSessionOrFail(ctx, sessionId, req);
|
||||
const validated = validateSessionFilePath(session.workingDir, filePath);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
|
||||
@@ -8,6 +8,8 @@ import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
|
||||
import { sanitizeHookData, parseBody } from '../route-helpers.js';
|
||||
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
export function registerHookEventRoutes(
|
||||
@@ -48,7 +50,18 @@ export function registerHookEventRoutes(
|
||||
// the user ran `/clear` (which spins up a new conversation jsonl).
|
||||
if (data && typeof data.session_id === 'string' && data.session_id) {
|
||||
const session = ctx.sessions.get(sessionId);
|
||||
const prevClaudeSessionId = session?.claudeSessionId;
|
||||
session?.adoptClaudeSessionId(data.session_id);
|
||||
// Docker sessions: keep the case's resume seed following the LIVE
|
||||
// conversation (post-/clear id switches), so a container stop/reboot
|
||||
// relaunch resumes the right transcript.
|
||||
if (session?.docker && session.claudeSessionId && session.claudeSessionId !== prevClaudeSessionId) {
|
||||
void persistDockerCaseClaudeSessionId(
|
||||
getDataDir(),
|
||||
session.docker.containerName,
|
||||
session.claudeSessionId
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize forwarded data: only include known safe fields, limit size
|
||||
|
||||
@@ -19,4 +19,6 @@ export { registerPlanRoutes } from './plan-routes.js';
|
||||
export { registerOrchestratorRoutes } from './orchestrator-routes.js';
|
||||
export { registerClipboardRoutes } from './clipboard-routes.js';
|
||||
export { registerSearchRoutes } from './search-routes.js';
|
||||
export { registerMeRoutes } from './me-routes.js';
|
||||
export { registerAdminRoutes } from './admin-routes.js';
|
||||
export { registerWsRoutes } from './ws-routes.js';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @fileoverview Self-service identity routes (multi-user + single-user).
|
||||
*
|
||||
* - GET /api/me : who am I ({ username, role, mustChangePassword }).
|
||||
* Works in single-user mode too, returning the synthetic
|
||||
* admin so the frontend has one "am I admin" code path.
|
||||
* - POST /api/me/password : change my own password (verifies the current one,
|
||||
* clears mustChangePassword, revokes my OTHER sessions).
|
||||
*
|
||||
* These are the two endpoints a `mustChangePassword` user may still reach (the auth
|
||||
* middleware's lockbox exempts them). See docs/multi-user-plan.md sections 5, 8.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, setPassword, verifyPassword } from '../../user-store.js';
|
||||
import { getAuthUser, revokeUserSessions } from '../route-helpers.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import type { AuthPort } from '../ports/auth-port.js';
|
||||
|
||||
const PasswordChangeSchema = z.object({
|
||||
currentPassword: z.string().min(1).max(1024),
|
||||
newPassword: z.string().min(8).max(1024),
|
||||
});
|
||||
|
||||
export function registerMeRoutes(app: FastifyInstance, ctx: AuthPort): void {
|
||||
// GET /api/me — identity probe. Synthetic admin in single-user mode. The
|
||||
// `multiUser` flag lets the frontend distinguish a single-user admin (no admin
|
||||
// UI) from a real multi-user admin.
|
||||
app.get('/api/me', async (req) => {
|
||||
if (!isMultiUserMode()) {
|
||||
return { success: true, data: { username: 'admin', role: 'admin', mustChangePassword: false, multiUser: false } };
|
||||
}
|
||||
const user = getAuthUser(req);
|
||||
const record = await findUser(user.username);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
mustChangePassword: !!record?.mustChangePassword,
|
||||
multiUser: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/me/password — self-service password change.
|
||||
app.post('/api/me/password', async (req, reply) => {
|
||||
if (!isMultiUserMode()) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Multi-user mode is not enabled');
|
||||
}
|
||||
const parsed = PasswordChangeSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
parsed.error.issues[0]?.message ?? 'New password must be at least 8 characters'
|
||||
);
|
||||
}
|
||||
const { username } = getAuthUser(req);
|
||||
const verified = await verifyPassword(username, parsed.data.currentPassword);
|
||||
if (!verified) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Current password is incorrect');
|
||||
}
|
||||
await setPassword(username, parsed.data.newPassword, { mustChangePassword: false });
|
||||
|
||||
// Revoke this user's OTHER cookie sessions; keep the caller's own session alive
|
||||
// and clear its mustChangePassword snapshot so they aren't re-locked immediately.
|
||||
const currentToken = req.cookies[AUTH_COOKIE_NAME];
|
||||
revokeUserSessions(ctx.authSessions, username, currentToken);
|
||||
if (currentToken) {
|
||||
const rec = ctx.authSessions?.get(currentToken);
|
||||
if (rec) rec.mustChangePassword = false;
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -17,7 +17,15 @@ import {
|
||||
PlanTaskUpdateSchema,
|
||||
PlanTaskAddSchema,
|
||||
} from '../schemas.js';
|
||||
import { findSessionOrFail, parseBody, CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
|
||||
import {
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
ownerFor,
|
||||
parseBody,
|
||||
resolveCasesDir,
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
@@ -124,12 +132,19 @@ Return ONLY a JSON array. Each item MUST have:
|
||||
|
||||
NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
// Create temporary session for the AI call using Opus 4.5 for deep reasoning
|
||||
// Create temporary session for the AI call using Opus 4.5 for deep reasoning.
|
||||
// Section 6.3: downgrade a non-granted user's one-shot to a classifier-guarded mode.
|
||||
const planOwner = ownerFor(req);
|
||||
const planClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const planClaudeMode = await resolveClaudeModeForUsername(planClaudeModeConfig.claudeMode, planOwner);
|
||||
const session = new Session({
|
||||
workingDir: process.cwd(),
|
||||
mux: ctx.mux,
|
||||
useMux: false, // No mux needed for one-shot
|
||||
mode: 'claude',
|
||||
claudeMode: planClaudeMode,
|
||||
allowedTools: planClaudeModeConfig.allowedTools,
|
||||
owner: planOwner,
|
||||
});
|
||||
|
||||
// Use configured model for plan generation, falling back to opus
|
||||
@@ -228,7 +243,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Determine output directory for saving wizard results
|
||||
let outputDir: string | undefined;
|
||||
if (caseName) {
|
||||
const casePath = validatePathWithinBase(caseName, CASES_DIR);
|
||||
const casePath = validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)));
|
||||
if (casePath && existsSync(casePath)) {
|
||||
outputDir = join(casePath, 'ralph-wizard');
|
||||
|
||||
@@ -246,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
|
||||
@@ -359,7 +385,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => {
|
||||
const { id, taskId } = req.params as { id: string; taskId: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
@@ -385,7 +411,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.post('/api/sessions/:id/plan/checkpoint', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
@@ -401,7 +427,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.get('/api/sessions/:id/plan/history', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
@@ -415,7 +441,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.post('/api/sessions/:id/plan/rollback/:version', async (req) => {
|
||||
const { id, version } = req.params as { id: string; version: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
@@ -435,7 +461,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
app.post('/api/sessions/:id/plan/task', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = session.ralphTracker;
|
||||
if (!tracker) {
|
||||
|
||||
@@ -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 } };
|
||||
});
|
||||
|
||||
@@ -13,13 +13,22 @@ import { Session, isExternalCliMode } from '../../session.js';
|
||||
import { RespawnController } from '../../respawn-controller.js';
|
||||
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
getAuthUser,
|
||||
ownerFor,
|
||||
resolveCasesDir,
|
||||
sessionCapacityMessage,
|
||||
SETTINGS_PATH,
|
||||
findSessionOrFail,
|
||||
parseBody,
|
||||
} from '../route-helpers.js';
|
||||
import { resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { buildRalphLoopPrompt } from '../../prompts/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
||||
|
||||
export function registerRalphRoutes(
|
||||
app: FastifyInstance,
|
||||
@@ -42,7 +51,7 @@ export function registerRalphRoutes(
|
||||
reset?: boolean | 'full';
|
||||
disableAutoEnable?: boolean;
|
||||
};
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
@@ -118,7 +127,7 @@ export function registerRalphRoutes(
|
||||
// Reset circuit breaker for Ralph tracker
|
||||
app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.ralphTracker.resetCircuitBreaker();
|
||||
return {};
|
||||
@@ -127,7 +136,7 @@ export function registerRalphRoutes(
|
||||
// Get Ralph status block and circuit breaker state
|
||||
app.get('/api/sessions/:id/ralph-status', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -147,7 +156,7 @@ export function registerRalphRoutes(
|
||||
// Generate @fix_plan.md content from todos
|
||||
app.get('/api/sessions/:id/fix-plan', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const content = session.ralphTracker.generateFixPlanMarkdown();
|
||||
return {
|
||||
@@ -163,7 +172,7 @@ export function registerRalphRoutes(
|
||||
app.post('/api/sessions/:id/fix-plan/import', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { content } = parseBody(FixPlanImportSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
|
||||
ctx.persistSessionState(session);
|
||||
@@ -180,7 +189,7 @@ export function registerRalphRoutes(
|
||||
// Write @fix_plan.md to session's working directory
|
||||
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -207,7 +216,7 @@ export function registerRalphRoutes(
|
||||
// Read @fix_plan.md from session's working directory and import
|
||||
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -246,7 +255,7 @@ export function registerRalphRoutes(
|
||||
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -271,13 +280,9 @@ export function registerRalphRoutes(
|
||||
|
||||
// Start a Ralph Loop — creates a new session with autonomous cycling
|
||||
app.post('/api/ralph-loop/start', async (req): Promise<ApiResponse> => {
|
||||
// Prevent unbounded session creation
|
||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.SESSION_BUSY,
|
||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`
|
||||
);
|
||||
}
|
||||
const rlOwner = ownerFor(req);
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, rlOwner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||
|
||||
const {
|
||||
caseName,
|
||||
@@ -290,11 +295,13 @@ export function registerRalphRoutes(
|
||||
effort,
|
||||
} = parseBody(RalphLoopStartSchema, req.body);
|
||||
|
||||
const casePath = join(CASES_DIR, caseName);
|
||||
// Multi-user: cases live in the requesting user's space.
|
||||
const rlCasesBase = resolveCasesDir(getAuthUser(req));
|
||||
const casePath = join(rlCasesBase, caseName);
|
||||
|
||||
// Security: Path traversal protection
|
||||
const rlResolvedPath = resolve(casePath);
|
||||
const rlResolvedBase = resolve(CASES_DIR);
|
||||
const rlResolvedBase = resolve(rlCasesBase);
|
||||
const rlRelPath = relative(rlResolvedBase, rlResolvedPath);
|
||||
if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
@@ -324,6 +331,7 @@ export function registerRalphRoutes(
|
||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||
const rlModelConfig = await ctx.getModelConfig();
|
||||
const rlClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const rlClaudeMode = await resolveClaudeModeForUsername(rlClaudeModeConfig.claudeMode, rlOwner);
|
||||
const session = new Session({
|
||||
workingDir: casePath,
|
||||
mux: ctx.mux,
|
||||
@@ -331,10 +339,11 @@ export function registerRalphRoutes(
|
||||
mode: 'claude',
|
||||
niceConfig,
|
||||
model: rlModelConfig?.defaultModel || undefined,
|
||||
claudeMode: rlClaudeModeConfig.claudeMode,
|
||||
claudeMode: rlClaudeMode,
|
||||
allowedTools: rlClaudeModeConfig.allowedTools,
|
||||
envOverrides,
|
||||
effort,
|
||||
owner: rlOwner,
|
||||
});
|
||||
|
||||
// Configure Ralph tracker
|
||||
|
||||
@@ -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 };
|
||||
@@ -87,7 +96,7 @@ export function registerRespawnRoutes(
|
||||
if (req.body) {
|
||||
body = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Respawn is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
@@ -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 });
|
||||
|
||||
@@ -160,7 +169,7 @@ export function registerRespawnRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
// Validate respawn config to prevent arbitrary field injection
|
||||
const config = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
@@ -226,7 +235,7 @@ export function registerRespawnRoutes(
|
||||
respawnConfig?: Partial<RespawnConfig>;
|
||||
durationMinutes?: number;
|
||||
};
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -299,7 +308,7 @@ export function registerRespawnRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = reResult.data as { config?: Partial<RespawnConfig>; durationMinutes?: number };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Respawn is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import { canAccessOwned, getAuthUser, parseBody } from '../route-helpers.js';
|
||||
import { SearchQuerySchema } from '../schemas.js';
|
||||
import {
|
||||
searchSources,
|
||||
@@ -62,13 +62,14 @@ interface SessionLike {
|
||||
* Harvest the three source arrays from the live in-memory stores. Reads only
|
||||
* bounded, already-loaded data — no disk I/O, no terminal buffers.
|
||||
*/
|
||||
function harvestSources(ctx: SessionPort & InfraPort): SearchSources {
|
||||
function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) => boolean): SearchSources {
|
||||
const sessions: SessionSearchInput[] = [];
|
||||
const events: EventSearchInput[] = [];
|
||||
const files: FileSearchInput[] = [];
|
||||
|
||||
for (const raw of ctx.sessions.values()) {
|
||||
const s = raw as unknown as SessionLike;
|
||||
const s = raw as unknown as SessionLike & { owner?: string };
|
||||
if (canSee && !canSee(s.owner)) continue; // multi-user ownership scope
|
||||
const sessionName = s.name ?? '';
|
||||
const timestamp = s.lastActivityAt ?? s.createdAt ?? 0;
|
||||
|
||||
@@ -96,7 +97,8 @@ function harvestSources(ctx: SessionPort & InfraPort): SearchSources {
|
||||
|
||||
// Events: from the live run-summary trackers, keyed by session id.
|
||||
for (const [sessionId, tracker] of ctx.runSummaryTrackers) {
|
||||
const session = ctx.sessions.get(sessionId) as unknown as SessionLike | undefined;
|
||||
const session = ctx.sessions.get(sessionId) as unknown as (SessionLike & { owner?: string }) | undefined;
|
||||
if (canSee && !canSee(session?.owner)) continue; // multi-user ownership scope
|
||||
const sessionName = session?.name ?? '';
|
||||
const summary = tracker.getSummary();
|
||||
// Newest events are most relevant; cap the per-session harvest.
|
||||
@@ -120,6 +122,8 @@ export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & In
|
||||
app.get('/api/search', async (req) => {
|
||||
// Zod-validate the query. parseBody throws a structured 400 on failure.
|
||||
const { q, types, limit } = parseBody(SearchQuerySchema, req.query);
|
||||
const user = getAuthUser(req);
|
||||
const canSee = (owner?: string) => canAccessOwned(user, owner);
|
||||
|
||||
const allowed: Set<SearchSourceType> | null = types
|
||||
? new Set(
|
||||
@@ -130,7 +134,7 @@ export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & In
|
||||
)
|
||||
: null;
|
||||
|
||||
const sources = harvestSources(ctx);
|
||||
const sources = harvestSources(ctx, canSee);
|
||||
|
||||
// Apply the optional source-type filter before searching so excluded
|
||||
// sources never contribute to (or consume budget in) the result set.
|
||||
|
||||
@@ -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';
|
||||
@@ -38,13 +40,22 @@ import {
|
||||
} from '../schemas.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
canAccessOwned,
|
||||
CASES_DIR,
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
isWorkingDirAllowed,
|
||||
ownerFor,
|
||||
parseBody,
|
||||
persistAndBroadcastSession,
|
||||
resolveCasesDir,
|
||||
sessionCapacityMessage,
|
||||
SETTINGS_PATH,
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import {
|
||||
writeHooksConfig,
|
||||
@@ -67,7 +78,6 @@ import {
|
||||
type MuxStatInput,
|
||||
} from '../../services/unified-session-service.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
||||
import { RunSummaryTracker } from '../../run-summary.js';
|
||||
|
||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||
@@ -80,6 +90,17 @@ import {
|
||||
toAttachedSessionRemote,
|
||||
toSessionRemote,
|
||||
} from '../../remote-hosts.js';
|
||||
import {
|
||||
checkDockerAvailable,
|
||||
checkDockerConfigDrift,
|
||||
checkDockerTmuxAvailable,
|
||||
ensureAgentBaseImage,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
persistDockerCaseClaudeSessionId,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
toSessionDocker,
|
||||
} from '../../docker-hosts.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
@@ -256,6 +277,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
|
||||
@@ -282,20 +326,21 @@ export function registerSessionRoutes(
|
||||
|
||||
// ========== Session Listing ==========
|
||||
|
||||
app.get('/api/sessions', async () => {
|
||||
return ctx.getLightSessionsState();
|
||||
app.get('/api/sessions', async (req) => {
|
||||
const list = ctx.getLightSessionsState();
|
||||
if (!isMultiUserMode()) return list;
|
||||
const user = getAuthUser(req);
|
||||
if (user.role === 'admin') return list;
|
||||
return (list as Array<{ owner?: string }>).filter((s) => canAccessOwned(user, s.owner));
|
||||
});
|
||||
|
||||
// ========== Session Creation ==========
|
||||
|
||||
app.post('/api/sessions', async (req) => {
|
||||
// Prevent unbounded session creation
|
||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`
|
||||
);
|
||||
}
|
||||
const owner = ownerFor(req);
|
||||
// Global + per-user session cap.
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
|
||||
|
||||
const body = parseBody(CreateSessionSchema, req.body);
|
||||
let workingDir = body.workingDir || process.cwd();
|
||||
@@ -314,6 +359,20 @@ export function registerSessionRoutes(
|
||||
remote = toAttachedSessionRemote(host, remoteSessionName, workingDir);
|
||||
}
|
||||
|
||||
// Multi-user: shell mode is arbitrary command execution as the host account,
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Multi-user linchpin (section 6.2): a non-admin's workingDir must resolve
|
||||
// inside their own case space. Enforced BEFORE any disk-mutating call below so
|
||||
// a foreign path can never be written into.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), workingDir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||||
}
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
if (body.workingDir) {
|
||||
try {
|
||||
@@ -331,16 +390,18 @@ export function registerSessionRoutes(
|
||||
// For keys the caller is actively setting, strip any stale disk entry a prior
|
||||
// Codeman version may have written. Scope limited to:
|
||||
// - Claude mode (OpenCode/Codex/Gemini don't read .claude/settings.local.json)
|
||||
// - workingDir inside CASES_DIR (Codeman's managed territory — we never mutate
|
||||
// .claude/settings.local.json in arbitrary user repos that POST /api/sessions
|
||||
// can target, because those may have hand-authored values).
|
||||
// - workingDir inside CASES_DIR / the per-user case space (Codeman's managed
|
||||
// territory — we never mutate .claude/settings.local.json in arbitrary user
|
||||
// repos that POST /api/sessions can target, as those may have hand-authored
|
||||
// values).
|
||||
const managedCasesBase = resolveCasesDir(getAuthUser(req));
|
||||
const canStripDisk =
|
||||
body.mode !== 'opencode' &&
|
||||
body.mode !== 'codex' &&
|
||||
body.mode !== 'gemini' &&
|
||||
body.envOverrides &&
|
||||
Object.keys(body.envOverrides).length > 0 &&
|
||||
workingDir.startsWith(CASES_DIR + '/');
|
||||
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
|
||||
if (canStripDisk) {
|
||||
await stripCaseEnvKeys(workingDir, Object.keys(body.envOverrides!));
|
||||
}
|
||||
@@ -449,6 +510,14 @@ export function registerSessionRoutes(
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
// Section 6.3: force non-granted users to a classifier-guarded mode.
|
||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
|
||||
// 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,
|
||||
@@ -458,16 +527,17 @@ export function registerSessionRoutes(
|
||||
useMux: true,
|
||||
niceConfig: globalNice,
|
||||
model,
|
||||
claudeMode: claudeModeConfig.claudeMode,
|
||||
claudeMode: effectiveClaudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? body.codexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? body.geminiConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: body.envOverrides,
|
||||
effort: body.effort,
|
||||
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
||||
remote,
|
||||
owner,
|
||||
});
|
||||
|
||||
ctx.addSession(session);
|
||||
@@ -488,7 +558,7 @@ export function registerSessionRoutes(
|
||||
app.put('/api/sessions/:id/name', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(SessionNameSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
||||
session.name = name;
|
||||
@@ -503,7 +573,7 @@ export function registerSessionRoutes(
|
||||
app.put('/api/sessions/:id/color', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(SessionColorSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
||||
if (!validColors.includes(body.color)) {
|
||||
@@ -522,18 +592,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) {
|
||||
@@ -550,7 +624,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
|
||||
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
|
||||
@@ -565,7 +639,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/output', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -581,7 +655,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/ralph-state', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -597,7 +671,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/run-summary', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const tracker = ctx.runSummaryTrackers.get(id);
|
||||
if (!tracker) {
|
||||
@@ -617,7 +691,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/active-tools', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -636,7 +710,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/run', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { prompt } = parseBody(RunPromptSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -663,7 +737,7 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { clearBreaker } = bodyResult.data;
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -719,7 +793,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/shell', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -752,7 +826,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/input', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { input, useMux, seq, clientId } = parseBody(SessionInputWithLimitSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const inputStr = String(input);
|
||||
if (inputStr.length > MAX_INPUT_LENGTH) {
|
||||
@@ -811,7 +885,7 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const muxName = session.muxName;
|
||||
if (!muxName) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
|
||||
@@ -843,7 +917,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/resize', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.resize(cols, rows, { viewportType, force });
|
||||
return {};
|
||||
@@ -929,7 +1003,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/last-response', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Codex sessions don't write to ~/.claude/projects — their transcripts
|
||||
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
|
||||
@@ -948,6 +1022,12 @@ export function registerSessionRoutes(
|
||||
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
|
||||
if (activeId && activeId !== session.claudeSessionId) {
|
||||
session.adoptClaudeSessionId(activeId);
|
||||
// Docker sessions: keep the case's resume seed following the live conversation.
|
||||
if (session.docker) {
|
||||
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The Claude conversation ID (used as JSONL filename)
|
||||
@@ -1380,7 +1460,7 @@ export function registerSessionRoutes(
|
||||
app.get('/api/sessions/:id/terminal', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const query = req.query as { tail?: string; full?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
|
||||
// the page and wants the whole scroll history back, so we capture the ENTIRE
|
||||
@@ -1513,7 +1593,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.setAutoClear(body.enabled, body.threshold);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -1534,7 +1614,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/auto-compact', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -1556,7 +1636,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/auto-resume', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.setAutoResume(body.enabled);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -1577,7 +1657,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (body.enabled) {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
@@ -1602,7 +1682,7 @@ export function registerSessionRoutes(
|
||||
app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.flickerFilterEnabled = body.enabled;
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -1622,13 +1702,9 @@ export function registerSessionRoutes(
|
||||
// ========== Quick Run ==========
|
||||
|
||||
app.post('/api/run', async (req) => {
|
||||
// Prevent unbounded session creation
|
||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.SESSION_BUSY,
|
||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`
|
||||
);
|
||||
}
|
||||
const runOwner = ownerFor(req);
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, runOwner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||
|
||||
const {
|
||||
prompt,
|
||||
@@ -1641,6 +1717,11 @@ export function registerSessionRoutes(
|
||||
}
|
||||
const dir = workingDir || process.cwd();
|
||||
|
||||
// Multi-user: confine a non-admin's one-shot working dir to their space.
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), dir)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||||
}
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
if (workingDir) {
|
||||
try {
|
||||
@@ -1653,7 +1734,17 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const session = new Session({ workingDir: dir, envOverrides: runEnvOverrides });
|
||||
// Section 6.3: the one-shot spawn path (runPrompt/buildPromptArgs) respects the
|
||||
// session's claudeMode, so resolve it for the owner (bypass -> auto for non-granted).
|
||||
const runClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const runClaudeMode = await resolveClaudeModeForUsername(runClaudeModeConfig.claudeMode, runOwner);
|
||||
const session = new Session({
|
||||
workingDir: dir,
|
||||
envOverrides: runEnvOverrides,
|
||||
claudeMode: runClaudeMode,
|
||||
allowedTools: runClaudeModeConfig.allowedTools,
|
||||
owner: runOwner,
|
||||
});
|
||||
ctx.addSession(session);
|
||||
ctx.store.incrementSessionsCreated();
|
||||
ctx.persistSessionState(session);
|
||||
@@ -1683,17 +1774,15 @@ export function registerSessionRoutes(
|
||||
// ========== Quick Start ==========
|
||||
|
||||
app.post('/api/quick-start', async (req) => {
|
||||
// Prevent unbounded session creation
|
||||
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.SESSION_BUSY,
|
||||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`
|
||||
);
|
||||
}
|
||||
const owner = ownerFor(req);
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||
|
||||
const {
|
||||
caseName = 'testcase',
|
||||
sessionName,
|
||||
mode = 'claude',
|
||||
modelOverride,
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
@@ -1701,13 +1790,33 @@ export function registerSessionRoutes(
|
||||
effort,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
|
||||
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Resolve the remote case FIRST — the CLI executes on the REMOTE host over ssh,
|
||||
// so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and
|
||||
// would wrongly reject a machine that hasn't got the CLI installed locally.
|
||||
let remote = undefined;
|
||||
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 && 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');
|
||||
@@ -1719,13 +1828,14 @@ export function registerSessionRoutes(
|
||||
if (
|
||||
(envOverrides && Object.keys(envOverrides).length > 0) ||
|
||||
effort ||
|
||||
modelOverride !== undefined ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
||||
'envOverrides, effort, modelOverride, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1739,6 +1849,76 @@ export function registerSessionRoutes(
|
||||
|
||||
casePath = remoteCase.remotePath;
|
||||
remote = toSessionRemote(host, remoteCase);
|
||||
} else if (dockerCase) {
|
||||
// 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). (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 (
|
||||
(envOverrides && Object.keys(envOverrides).length > 0) ||
|
||||
effort ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'envOverrides, effort, and per-CLI config are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
|
||||
);
|
||||
}
|
||||
|
||||
const availability = await checkDockerAvailable(host.engine);
|
||||
if (!availability.ok) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
availability.error || 'docker daemon is not available'
|
||||
);
|
||||
}
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
// Ensure the base image exists, auto-building the default image on first use so
|
||||
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
|
||||
// this awaits the SAME in-flight build rather than starting a second one.
|
||||
const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
||||
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available');
|
||||
}
|
||||
if (ensured.built) {
|
||||
ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image });
|
||||
}
|
||||
// tmux is a hard prerequisite (the in-container tmux makes reconnect durable).
|
||||
// Skip the extra container-run probe for our OWN default image (the baked
|
||||
// Dockerfile always contains tmux); still verify a custom image.
|
||||
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
|
||||
}
|
||||
}
|
||||
|
||||
// Config drift (docs/docker-cases-plan.md §4): the desired create-config no
|
||||
// longer matches the existing container's codeman.confighash label. Refuse to
|
||||
// silently launch into the stale container — the frontend confirms a recreate
|
||||
// (POST /api/docker-cases/:name/recreate; workspace + transcripts ride bind
|
||||
// mounts and the conversation resumes), or the user reverts the host edit.
|
||||
const drift = await checkDockerConfigDrift(sessionDocker);
|
||||
if (drift.exists && drift.drifted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.CONFLICT,
|
||||
`Container config for case "${dockerCase.name}" changed since the container was created. Recreate the container to apply it (workspace and conversation survive), or revert the docker host edit.`
|
||||
);
|
||||
}
|
||||
|
||||
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
|
||||
docker = sessionDocker;
|
||||
// Seed resume so a relaunch resumes the case's last conversation from the
|
||||
// bind-mounted transcript (decision: resume-on-start default ON).
|
||||
if (sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) {
|
||||
dockerResumeId = dockerCase.lastClaudeSessionId;
|
||||
}
|
||||
} else {
|
||||
// Check OpenCode availability if requested
|
||||
if (mode === 'opencode') {
|
||||
@@ -1783,7 +1963,11 @@ export function registerSessionRoutes(
|
||||
} catch {
|
||||
// File missing or unparseable — treat as empty registry
|
||||
}
|
||||
casePath = linkedCases[caseName] || validatePathWithinBase(caseName, CASES_DIR);
|
||||
// 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');
|
||||
}
|
||||
@@ -1793,8 +1977,18 @@ 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;
|
||||
|
||||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote cases)
|
||||
if (!remote && !existsSync(resolvedCasePath)) {
|
||||
// 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)) {
|
||||
try {
|
||||
mkdirSync(resolvedCasePath, { recursive: true });
|
||||
mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });
|
||||
@@ -1814,7 +2008,7 @@ export function registerSessionRoutes(
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
} else if (!remote && mode !== 'opencode') {
|
||||
} else if (!remote && !docker && mode !== 'opencode') {
|
||||
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
|
||||
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
|
||||
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
|
||||
@@ -1822,6 +2016,33 @@ export function registerSessionRoutes(
|
||||
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
|
||||
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
||||
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
|
||||
// hook-idle detection fire (decision: wire hooks now). Never clobbers an existing
|
||||
// configured project. Skipped for external CLIs (they use their own systems).
|
||||
if (docker && docker.hooksEnabled && mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
|
||||
try {
|
||||
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
|
||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), generateClaudeMd(caseName, '', templatePath));
|
||||
}
|
||||
if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) {
|
||||
await writeHooksConfig(resolvedCasePath);
|
||||
} else {
|
||||
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal — the session still runs, hooks may be degraded */
|
||||
}
|
||||
}
|
||||
|
||||
// Model override → <case>/.claude/settings.local.json (claude-mode; local AND
|
||||
// docker — the docker workspace is a real host dir, so the settings file crosses
|
||||
// the bind mount and the in-container claude reads it). Remote was rejected above.
|
||||
if (mode === 'claude' && modelOverride !== undefined) {
|
||||
await updateCaseModel(resolvedCasePath, modelOverride || null);
|
||||
}
|
||||
|
||||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||
// see POST /api/sessions for full rationale).
|
||||
if (
|
||||
@@ -1850,28 +2071,39 @@ export function registerSessionRoutes(
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: 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,
|
||||
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
|
||||
mux: ctx.mux,
|
||||
useMux: true,
|
||||
mode: mode,
|
||||
niceConfig: niceConfig,
|
||||
model: qsModel,
|
||||
claudeMode: qsClaudeModeConfig.claudeMode,
|
||||
claudeMode: qsEffectiveClaudeMode,
|
||||
allowedTools: qsClaudeModeConfig.allowedTools,
|
||||
owner,
|
||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? codexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
|
||||
envOverrides,
|
||||
effort,
|
||||
remote,
|
||||
docker,
|
||||
resumeSessionId: dockerResumeId,
|
||||
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
||||
});
|
||||
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||
// so the initial state already has the phrase configured (only if globally enabled)
|
||||
if (mode === 'claude' && !remote && ctx.store.getConfig().ralphEnabled) {
|
||||
if (mode === 'claude' && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
|
||||
autoConfigureRalph(session, resolvedCasePath, ctx);
|
||||
if (!session.ralphTracker.enabled) {
|
||||
session.ralphTracker.enable();
|
||||
@@ -1915,6 +2147,19 @@ export function registerSessionRoutes(
|
||||
}
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
// Docker + claude: the pane command pins the conversation id (--session-id /
|
||||
// --resume, claudeDockerPaneCommand), so persist it as the case's resume seed
|
||||
// NOW — a later container stop/reboot relaunch resumes this conversation even
|
||||
// if no in-container hook ever reaches the host (loopback bind, no bridge
|
||||
// listener). Hook/last-response adoption updates it again after /clear.
|
||||
if (docker && mode === 'claude') {
|
||||
void persistDockerCaseClaudeSessionId(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
docker.containerName,
|
||||
session.claudeSessionId || session.id
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
// Save lastUsedCase to settings for TUI/web sync
|
||||
try {
|
||||
const settingsFilePath = SETTINGS_PATH;
|
||||
@@ -2203,6 +2448,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.
|
||||
@@ -2214,13 +2465,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) {
|
||||
@@ -2232,6 +2485,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) };
|
||||
});
|
||||
@@ -2339,7 +2594,37 @@ export function registerSessionRoutes(
|
||||
// Mux stats are optional.
|
||||
}
|
||||
|
||||
const merged = mergeUnifiedSessions({ live, persisted, lifecycle, history, mux });
|
||||
// Multi-user: a non-admin only sees their own sessions; host-wide transcript
|
||||
// history (not tied to an owned session) is admin-only.
|
||||
let sLive = live;
|
||||
let sPersisted = persisted;
|
||||
let sLifecycle = lifecycle;
|
||||
let sHistory = history;
|
||||
const uUser = getAuthUser(req);
|
||||
if (isMultiUserMode() && uUser.role !== 'admin') {
|
||||
const ownedLive = new Set(
|
||||
[...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id)
|
||||
);
|
||||
const stored = ctx.store.getState().sessions as Record<string, { id: string; owner?: string }>;
|
||||
const ownedPersisted = new Set(
|
||||
Object.values(stored)
|
||||
.filter((p) => canAccessOwned(uUser, p.owner))
|
||||
.map((p) => p.id)
|
||||
);
|
||||
const isOwned = (id: string) => ownedLive.has(id) || ownedPersisted.has(id);
|
||||
sLive = live.filter((l) => isOwned(l.id));
|
||||
sPersisted = persisted.filter((p) => isOwned(p.id));
|
||||
sLifecycle = lifecycle.filter((e) => isOwned(e.sessionId));
|
||||
sHistory = [];
|
||||
}
|
||||
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: sLive,
|
||||
persisted: sPersisted,
|
||||
lifecycle: sLifecycle,
|
||||
history: sHistory,
|
||||
mux,
|
||||
});
|
||||
const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
|
||||
const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
|
||||
return filterAndPaginate(merged, {
|
||||
@@ -2398,7 +2683,7 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!req.isMultipart()) {
|
||||
reply.code(400);
|
||||
|
||||
+112
-30
@@ -15,6 +15,9 @@ import { randomBytes } from 'node:crypto';
|
||||
import { dataPath } from '../../config/instance.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
||||
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser } from '../../user-store.js';
|
||||
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
|
||||
import {
|
||||
ConfigUpdateSchema,
|
||||
SettingsUpdateSchema,
|
||||
@@ -136,7 +139,7 @@ export function registerSystemRoutes(
|
||||
|
||||
// ========== Status ==========
|
||||
|
||||
app.get('/api/status', async () => ctx.getLightState());
|
||||
app.get('/api/status', async (req) => ctx.getLightState(req.authUser));
|
||||
|
||||
// ========== Tunnel ==========
|
||||
|
||||
@@ -159,12 +162,19 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/tunnel/qr', async (_req, reply) => {
|
||||
app.get('/api/tunnel/qr', async (req, reply) => {
|
||||
const url = ctx.tunnelManager.getUrl();
|
||||
if (!url) {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running'));
|
||||
}
|
||||
try {
|
||||
if (isMultiUserMode()) {
|
||||
// A rotating global token cannot carry identity — mint a single-use token
|
||||
// bound to the requesting user so the scanned code logs THEM in.
|
||||
const shortCode = ctx.tunnelManager.mintUserToken(getAuthUser(req).username);
|
||||
const svg = await ctx.tunnelManager.getQrSvgForCode(url, shortCode);
|
||||
return { svg, authEnabled: true };
|
||||
}
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
if (authPassword) {
|
||||
// Auth enabled — use cached SVG with embedded short code
|
||||
@@ -188,10 +198,11 @@ export function registerSystemRoutes(
|
||||
|
||||
app.get('/q/:code', async (req, reply) => {
|
||||
const shortCode = (req.params as { code: string }).code;
|
||||
const multiUser = isMultiUserMode();
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
|
||||
// No point if auth isn't enabled — just redirect
|
||||
if (!authPassword) {
|
||||
// No point if auth isn't enabled — just redirect. Multi-user is always "enabled".
|
||||
if (!multiUser && !authPassword) {
|
||||
return reply.redirect('/');
|
||||
}
|
||||
|
||||
@@ -203,12 +214,28 @@ export function registerSystemRoutes(
|
||||
return reply.code(429).send('Too Many Requests');
|
||||
}
|
||||
|
||||
// Validate and atomically consume the token
|
||||
if (!shortCode || !ctx.tunnelManager.consumeToken(shortCode)) {
|
||||
// Validate and atomically consume the token (with any bound identity).
|
||||
const consumed = shortCode ? ctx.tunnelManager.consumeTokenWithIdentity(shortCode) : { ok: false };
|
||||
// In multi-user mode a token MUST carry an identity (an identity-less rotating
|
||||
// token can't create a scoped session), so reject those too.
|
||||
if (!consumed.ok || (multiUser && !consumed.username)) {
|
||||
ctx.qrAuthFailures?.set(clientIp, qrFailures + 1);
|
||||
return reply.code(401).send('Invalid or expired QR code');
|
||||
}
|
||||
|
||||
// Resolve the role for the bound user (disabled/deleted users fail closed).
|
||||
// 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, mustChangePassword: !!user.mustChangePassword };
|
||||
}
|
||||
|
||||
// Issue session cookie (same pattern as Basic Auth success path)
|
||||
const sessionToken = randomBytes(32).toString('hex');
|
||||
const clientUA = req.headers['user-agent'] ?? '';
|
||||
@@ -217,6 +244,9 @@ export function registerSystemRoutes(
|
||||
ua: clientUA,
|
||||
createdAt: Date.now(),
|
||||
method: 'qr',
|
||||
username: identity?.username,
|
||||
role: identity?.role,
|
||||
mustChangePassword: !!identity?.mustChangePassword,
|
||||
});
|
||||
ctx.qrAuthFailures?.delete(clientIp);
|
||||
|
||||
@@ -465,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,
|
||||
});
|
||||
|
||||
@@ -585,7 +644,10 @@ export function registerSystemRoutes(
|
||||
// letting an operator opt in from the browser without setting the env var.
|
||||
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
|
||||
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) {
|
||||
const acknowledged = isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
|
||||
// Multi-user mode makes the tunnel authenticated (every person has their own
|
||||
// credential), so it satisfies the same requirement as CODEMAN_PASSWORD.
|
||||
const acknowledged =
|
||||
isMultiUserMode() || isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
|
||||
if (!acknowledged) {
|
||||
const msg =
|
||||
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
|
||||
@@ -727,7 +789,7 @@ export function registerSystemRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/cpu-limit', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
return {
|
||||
nice: session.niceConfig,
|
||||
};
|
||||
@@ -735,7 +797,7 @@ export function registerSystemRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/cpu-limit', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const body = parseBody(CpuLimitSchema, req.body, 'Invalid request body') as Partial<NiceConfig>;
|
||||
|
||||
@@ -796,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))
|
||||
@@ -805,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) {
|
||||
@@ -816,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))
|
||||
@@ -826,12 +896,14 @@ export function registerSystemRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/subagents', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const subagents = subagentWatcher.getSubagentsForSession(session.workingDir);
|
||||
return { success: true, data: subagents };
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -840,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;
|
||||
@@ -854,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) {
|
||||
@@ -868,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 } };
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { SessionPort } from '../ports/session-port.js';
|
||||
import { MAX_INPUT_LENGTH } from '../../config/terminal-limits.js';
|
||||
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
|
||||
import { WsConnectionRegistry } from '../ws-connection-registry.js';
|
||||
import { canAccessOwned, getAuthUser } from '../route-helpers.js';
|
||||
|
||||
/** Micro-batch interval for terminal output (ms). Short enough for low latency,
|
||||
* long enough to group Ink's rapid cursor-up redraw sequences into single frames. */
|
||||
@@ -93,6 +94,17 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-user owner gate: writing to this socket injects keystrokes into the
|
||||
// agent, so a non-admin may only attach to their OWN session. The global auth
|
||||
// hook already ran on the upgrade request and decorated req.authUser (an
|
||||
// unauthenticated upgrade never reaches here — the hook 401s the handshake).
|
||||
// findSessionOrFail throws an HTTP-shaped error, so the check is inlined here
|
||||
// as a 4003 close. No-op in single-user mode (canAccessOwned returns true).
|
||||
if (!canAccessOwned(getAuthUser(req), session.owner)) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Structured transport logging — surfaces WS open/close/timeout churn so the
|
||||
// tunnel-flap behavior (COD-134) is observable in the server logs. Fastify is
|
||||
// configured logger:false, so we log via console (→ journald under systemd).
|
||||
|
||||
Reference in New Issue
Block a user