mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
The opt-in multi-user feature's only enforcement is web-layer scoping (all sessions share one OS account). An adversarial review found 8 critical + 7 high cross-user holes that defeated it, plus mediums; all fixed here. Single-user (flag-off) behavior stays byte-identical apart from documented consistency deltas. Ownership / confinement: - DELETE /api/sessions (bulk) + /:id now owner-scope / findSessionOrFail - quick-start, cron (create+fire), scheduled runs confine workingDir to the owner's space; case link/docker-link/docker-import confine the host path - resolveCasePath no longer resolves linked cases for non-admins; foreign remote/docker cases are skipped (fall through to the caller's own local case) - history, subagents/workflows, mux-sessions, orchestrator, cron run-history, away-digest, and remote/docker host reads are owner- or admin-scoped Permission policy (section 6.3): - non-granted users are downgraded at every spawn site incl. legacy /api/scheduled, PlanOrchestrator one-shots, remote launch, and the cron-fire gemini/codex bypass switches; resolveClaudeModeForUsername now fails closed Auth / store: - verify-first login throttle (a correct password is never locked out), /ws terminal subject to the change-password lockbox, cookie fast-path re-validates identity live, role/grant changes revoke sessions, admin delete runs the last-admin guard before any teardown - users.json: distinguish missing (ENOENT) from corrupt/unreadable so a bad read can't overwrite all accounts; unique per-process temp write path Event streams: - debounced session:updated + batched task:updated, clipboard, and push notifications route by owner (fail closed); getLightState hides machine-wide globalStats from non-admins Tests: two suites updated to assert the fixed (secure) behavior. tsc, eslint, and test:ci all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
266 lines
12 KiB
TypeScript
266 lines
12 KiB
TypeScript
/**
|
|
* @fileoverview Orchestrator loop routes — plan-based autonomous execution.
|
|
*
|
|
* Endpoints:
|
|
* - POST /api/orchestrator/start — Start orchestration with a goal
|
|
* - POST /api/orchestrator/approve — Approve generated plan
|
|
* - POST /api/orchestrator/reject — Reject plan with feedback
|
|
* - POST /api/orchestrator/pause — Pause execution
|
|
* - POST /api/orchestrator/resume — Resume from pause
|
|
* - POST /api/orchestrator/stop — Stop and clean up
|
|
* - GET /api/orchestrator/status — Get current status
|
|
* - GET /api/orchestrator/plan — Get current plan
|
|
* - POST /api/orchestrator/phase/:id/skip — Skip a phase
|
|
* - POST /api/orchestrator/phase/:id/retry — Retry a failed phase
|
|
*
|
|
* @module web/routes/orchestrator-routes
|
|
*/
|
|
|
|
import { FastifyInstance } from 'fastify';
|
|
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
|
import { OrchestratorStartSchema, OrchestratorRejectSchema } from '../schemas.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';
|
|
|
|
export function registerOrchestratorRoutes(app: FastifyInstance, ctx: OrchestratorPort & EventPort): void {
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Helpers
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
function getLoop() {
|
|
const loop = ctx.orchestratorLoop;
|
|
if (!loop) {
|
|
throw Object.assign(new Error('Orchestrator not initialized'), {
|
|
statusCode: 503,
|
|
body: createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'Orchestrator not initialized'),
|
|
});
|
|
}
|
|
return loop;
|
|
}
|
|
|
|
const EVENT_MAP: [string, (typeof SseEvent)[keyof typeof SseEvent], string[]][] = [
|
|
['stateChanged', SseEvent.OrchestratorStateChanged, ['state', 'prevState']],
|
|
['planProgress', SseEvent.OrchestratorPlanProgress, ['phase', 'detail']],
|
|
['planReady', SseEvent.OrchestratorPlanReady, ['plan']],
|
|
['phaseStarted', SseEvent.OrchestratorPhaseStarted, ['phase']],
|
|
['phaseCompleted', SseEvent.OrchestratorPhaseCompleted, ['phase']],
|
|
['phaseFailed', SseEvent.OrchestratorPhaseFailed, ['phase', 'reason']],
|
|
['taskAssigned', SseEvent.OrchestratorTaskAssigned, ['task', 'sessionId']],
|
|
['taskCompleted', SseEvent.OrchestratorTaskCompleted, ['task']],
|
|
['taskFailed', SseEvent.OrchestratorTaskFailed, ['task', 'error']],
|
|
['completed', SseEvent.OrchestratorCompleted, ['stats']],
|
|
];
|
|
|
|
let forwardingLoop: import('../../orchestrator-loop.js').OrchestratorLoop | null = null;
|
|
function setupEventForwarding(loop: import('../../orchestrator-loop.js').OrchestratorLoop) {
|
|
if (forwardingLoop === loop) return; // Already attached to this loop instance
|
|
forwardingLoop = loop;
|
|
for (const [event, sseEvent, argNames] of EVENT_MAP) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
loop.on(event, (...args: any[]) => {
|
|
const payload: Record<string, unknown> = {};
|
|
argNames.forEach((name, i) => {
|
|
payload[name] = args[i];
|
|
});
|
|
ctx.broadcast(sseEvent, payload);
|
|
});
|
|
}
|
|
// Special cases with non-trivial payload transforms
|
|
loop.on('verificationResult', (phase, result) => {
|
|
ctx.broadcast(SseEvent.OrchestratorVerification, { phaseId: phase.id, result });
|
|
});
|
|
loop.on('error', (error) => {
|
|
ctx.broadcast(SseEvent.OrchestratorError, { error: error.message });
|
|
});
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Start
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
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
|
|
let loop = ctx.orchestratorLoop;
|
|
if (!loop) {
|
|
loop = ctx.initOrchestratorLoop();
|
|
setupEventForwarding(loop);
|
|
}
|
|
|
|
// Check if already running
|
|
if (loop.isRunning()) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Orchestrator is already running');
|
|
}
|
|
|
|
try {
|
|
// Start is async — kicks off planning
|
|
loop.start(goal).catch((err) => {
|
|
console.error('[Orchestrator Route] Start failed:', getErrorMessage(err));
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
state: loop.state,
|
|
message: 'Orchestrator started — generating plan',
|
|
config: config ?? null,
|
|
};
|
|
} catch (err) {
|
|
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err));
|
|
}
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Approve / Reject Plan
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
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 {
|
|
loop.approve().catch((err) => {
|
|
console.error('[Orchestrator Route] Approve failed:', getErrorMessage(err));
|
|
});
|
|
return { ok: true, state: loop.state };
|
|
} catch (err) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
|
}
|
|
});
|
|
|
|
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');
|
|
|
|
try {
|
|
loop.reject(feedback).catch((err) => {
|
|
console.error('[Orchestrator Route] Reject failed:', getErrorMessage(err));
|
|
});
|
|
return { ok: true, state: loop.state };
|
|
} catch (err) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
|
}
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Pause / Resume / Stop
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
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 {
|
|
loop.pause();
|
|
return { ok: true, state: loop.state };
|
|
} catch (err) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
|
}
|
|
});
|
|
|
|
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 {
|
|
loop.resume().catch((err) => {
|
|
console.error('[Orchestrator Route] Resume failed:', getErrorMessage(err));
|
|
});
|
|
return { ok: true, state: loop.state };
|
|
} catch (err) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
|
}
|
|
});
|
|
|
|
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 {
|
|
await loop.stop();
|
|
return { ok: true, state: loop.state };
|
|
} catch (err) {
|
|
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err));
|
|
}
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Status / Plan
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
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 };
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
...loop.getStatus(),
|
|
};
|
|
});
|
|
|
|
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 };
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
plan: loop.getPlan(),
|
|
currentPhase: loop.getCurrentPhase(),
|
|
};
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Phase Operations
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
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 };
|
|
|
|
try {
|
|
await loop.skipPhase(id);
|
|
return { ok: true, state: loop.state };
|
|
} catch (err) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
|
}
|
|
});
|
|
|
|
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 };
|
|
|
|
try {
|
|
loop.retryPhase(id).catch((err) => {
|
|
console.error('[Orchestrator Route] Retry failed:', getErrorMessage(err));
|
|
});
|
|
return { ok: true, state: loop.state };
|
|
} catch (err) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
|
}
|
|
});
|
|
}
|