From a6f1438f40b9615addd33dd817940840a3d957de Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 8 Mar 2026 17:00:27 +0000 Subject: [PATCH] fix(web): route Mission Control API calls through orchestrator proxy (#747) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- .../app/api/orchestrator/[...path]/route.ts | 93 +++++++++++++++++++ .../mission-control/AuditLogDrawer.tsx | 4 +- .../mission-control/BargeInInput.test.tsx | 30 ++++-- .../mission-control/BargeInInput.tsx | 2 +- .../GlobalAgentRoster.test.tsx | 9 +- .../mission-control/GlobalAgentRoster.tsx | 11 ++- .../mission-control/KillAllDialog.test.tsx | 36 ++++--- .../mission-control/KillAllDialog.tsx | 9 +- .../mission-control/PanelControls.test.tsx | 20 ++-- .../mission-control/PanelControls.tsx | 8 +- 10 files changed, 178 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/app/api/orchestrator/[...path]/route.ts diff --git a/apps/web/src/app/api/orchestrator/[...path]/route.ts b/apps/web/src/app/api/orchestrator/[...path]/route.ts new file mode 100644 index 0000000..430b023 --- /dev/null +++ b/apps/web/src/app/api/orchestrator/[...path]/route.ts @@ -0,0 +1,93 @@ +import { type NextRequest, NextResponse } from "next/server"; + +const DEFAULT_ORCHESTRATOR_URL = "http://localhost:3001"; + +function getOrchestratorUrl(): string { + return ( + process.env.ORCHESTRATOR_URL ?? + process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ?? + process.env.NEXT_PUBLIC_API_URL ?? + DEFAULT_ORCHESTRATOR_URL + ); +} + +/** + * Generic catch-all proxy for orchestrator API routes. + * + * Forwards any request to /api/orchestrator/ → ORCHESTRATOR_URL/ + * with the ORCHESTRATOR_API_KEY injected server-side so it never reaches the browser. + * + * Supports GET, POST, PATCH, DELETE, PUT. + * + * Example: + * GET /api/orchestrator/mission-control/sessions + * → GET ORCHESTRATOR_URL/api/mission-control/sessions + * POST /api/orchestrator/mission-control/sessions/abc/kill + * → POST ORCHESTRATOR_URL/api/mission-control/sessions/abc/kill + */ +async function proxyToOrchestrator( + request: NextRequest, + context: { params: Promise<{ path: string[] }> } +): Promise { + const orchestratorApiKey = process.env.ORCHESTRATOR_API_KEY; + if (!orchestratorApiKey) { + return NextResponse.json( + { error: "ORCHESTRATOR_API_KEY is not configured on the web server." }, + { status: 503 } + ); + } + + const { path } = await context.params; + const upstreamPath = `/${path.join("/")}`; + const search = request.nextUrl.search; + const upstreamUrl = `${getOrchestratorUrl()}${upstreamPath}${search}`; + + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, 30_000); + + try { + const headers: Record = { + "X-API-Key": orchestratorApiKey, + }; + + const contentType = request.headers.get("Content-Type"); + if (contentType) { + headers["Content-Type"] = contentType; + } + + const hasBody = request.method !== "GET" && request.method !== "HEAD"; + const body = hasBody ? await request.text() : null; + + const upstream = await fetch(upstreamUrl, { + method: request.method, + headers, + ...(body !== null ? { body } : {}), + cache: "no-store", + signal: controller.signal, + }); + + const responseText = await upstream.text(); + return new NextResponse(responseText, { + status: upstream.status, + headers: { + "Content-Type": upstream.headers.get("Content-Type") ?? "application/json", + }, + }); + } catch (error) { + const message = + error instanceof Error && error.name === "AbortError" + ? "Orchestrator request timed out." + : "Unable to reach orchestrator."; + return NextResponse.json({ error: message }, { status: 502 }); + } finally { + clearTimeout(timeout); + } +} + +export const GET = proxyToOrchestrator; +export const POST = proxyToOrchestrator; +export const PATCH = proxyToOrchestrator; +export const PUT = proxyToOrchestrator; +export const DELETE = proxyToOrchestrator; diff --git a/apps/web/src/components/mission-control/AuditLogDrawer.tsx b/apps/web/src/components/mission-control/AuditLogDrawer.tsx index 5154c69..022d2d7 100644 --- a/apps/web/src/components/mission-control/AuditLogDrawer.tsx +++ b/apps/web/src/components/mission-control/AuditLogDrawer.tsx @@ -158,7 +158,9 @@ async function fetchAuditLog( } try { - return await apiGet(`/api/mission-control/audit-log?${params.toString()}`); + return await apiGet( + `/api/orchestrator/api/mission-control/audit-log?${params.toString()}` + ); } catch (error) { if (isRateLimitError(error)) { return createEmptyAuditLogResponse(page, "Rate limited - retrying..."); diff --git a/apps/web/src/components/mission-control/BargeInInput.test.tsx b/apps/web/src/components/mission-control/BargeInInput.test.tsx index 83bce0e..c45ba9f 100644 --- a/apps/web/src/components/mission-control/BargeInInput.test.tsx +++ b/apps/web/src/components/mission-control/BargeInInput.test.tsx @@ -59,9 +59,12 @@ describe("BargeInInput", (): void => { await user.click(screen.getByRole("button", { name: "Send" })); await waitFor((): void => { - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-1/inject", { - content: "execute plan", - }); + expect(mockApiPost).toHaveBeenCalledWith( + "/api/orchestrator/api/mission-control/sessions/session-1/inject", + { + content: "execute plan", + } + ); }); expect(onSent).toHaveBeenCalledTimes(1); @@ -83,12 +86,18 @@ describe("BargeInInput", (): void => { const calls = mockApiPost.mock.calls as [string, unknown?][]; - expect(calls[0]).toEqual(["/api/mission-control/sessions/session-2/pause", undefined]); + expect(calls[0]).toEqual([ + "/api/orchestrator/api/mission-control/sessions/session-2/pause", + undefined, + ]); expect(calls[1]).toEqual([ - "/api/mission-control/sessions/session-2/inject", + "/api/orchestrator/api/mission-control/sessions/session-2/inject", { content: "hello world" }, ]); - expect(calls[2]).toEqual(["/api/mission-control/sessions/session-2/resume", undefined]); + expect(calls[2]).toEqual([ + "/api/orchestrator/api/mission-control/sessions/session-2/resume", + undefined, + ]); }); it("submits with Enter and does not submit on Shift+Enter", async (): Promise => { @@ -105,9 +114,12 @@ describe("BargeInInput", (): void => { fireEvent.keyDown(textarea, { key: "Enter", code: "Enter", shiftKey: false }); await waitFor((): void => { - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-3/inject", { - content: "first", - }); + expect(mockApiPost).toHaveBeenCalledWith( + "/api/orchestrator/api/mission-control/sessions/session-3/inject", + { + content: "first", + } + ); }); }); diff --git a/apps/web/src/components/mission-control/BargeInInput.tsx b/apps/web/src/components/mission-control/BargeInInput.tsx index d83c735..16c7936 100644 --- a/apps/web/src/components/mission-control/BargeInInput.tsx +++ b/apps/web/src/components/mission-control/BargeInInput.tsx @@ -39,7 +39,7 @@ export function BargeInInput({ sessionId, onSent }: BargeInInputProps): React.JS } const encodedSessionId = encodeURIComponent(sessionId); - const baseEndpoint = `/api/mission-control/sessions/${encodedSessionId}`; + const baseEndpoint = `/api/orchestrator/api/mission-control/sessions/${encodedSessionId}`; let didPause = false; let didInject = false; diff --git a/apps/web/src/components/mission-control/GlobalAgentRoster.test.tsx b/apps/web/src/components/mission-control/GlobalAgentRoster.test.tsx index d7e95cd..d775d89 100644 --- a/apps/web/src/components/mission-control/GlobalAgentRoster.test.tsx +++ b/apps/web/src/components/mission-control/GlobalAgentRoster.test.tsx @@ -177,9 +177,12 @@ describe("GlobalAgentRoster", (): void => { fireEvent.click(screen.getByRole("button", { name: "Kill session killme12" })); await waitFor((): void => { - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/killme123456/kill", { - force: false, - }); + expect(mockApiPost).toHaveBeenCalledWith( + "/api/orchestrator/api/mission-control/sessions/killme123456/kill", + { + force: false, + } + ); }); }); diff --git a/apps/web/src/components/mission-control/GlobalAgentRoster.tsx b/apps/web/src/components/mission-control/GlobalAgentRoster.tsx index 41e950b..4dc69f6 100644 --- a/apps/web/src/components/mission-control/GlobalAgentRoster.tsx +++ b/apps/web/src/components/mission-control/GlobalAgentRoster.tsx @@ -83,7 +83,7 @@ function groupByProvider(sessions: MissionControlSession[]): ProviderSessionGrou async function fetchSessions(): Promise { const payload = await apiGet( - "/api/mission-control/sessions" + "/api/orchestrator/api/mission-control/sessions" ); return Array.isArray(payload) ? payload : payload.sessions; } @@ -118,9 +118,12 @@ export function GlobalAgentRoster({ const killMutation = useMutation({ mutationFn: async (sessionId: string): Promise => { - await apiPost<{ message: string }>(`/api/mission-control/sessions/${sessionId}/kill`, { - force: false, - }); + await apiPost<{ message: string }>( + `/api/orchestrator/api/mission-control/sessions/${sessionId}/kill`, + { + force: false, + } + ); return sessionId; }, onSuccess: (): void => { diff --git a/apps/web/src/components/mission-control/KillAllDialog.test.tsx b/apps/web/src/components/mission-control/KillAllDialog.test.tsx index 6bdc56a..1504112 100644 --- a/apps/web/src/components/mission-control/KillAllDialog.test.tsx +++ b/apps/web/src/components/mission-control/KillAllDialog.test.tsx @@ -112,14 +112,20 @@ describe("KillAllDialog", (): void => { await user.click(screen.getByRole("button", { name: "Kill All Agents" })); await waitFor((): void => { - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-1/kill", { - force: true, - }); + expect(mockApiPost).toHaveBeenCalledWith( + "/api/orchestrator/api/mission-control/sessions/internal-1/kill", + { + force: true, + } + ); }); - expect(mockApiPost).not.toHaveBeenCalledWith("/api/mission-control/sessions/external-1/kill", { - force: true, - }); + expect(mockApiPost).not.toHaveBeenCalledWith( + "/api/orchestrator/api/mission-control/sessions/external-1/kill", + { + force: true, + } + ); expect(onComplete).toHaveBeenCalledTimes(1); }); @@ -141,12 +147,18 @@ describe("KillAllDialog", (): void => { await user.click(screen.getByRole("button", { name: "Kill All Agents" })); await waitFor((): void => { - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-2/kill", { - force: true, - }); - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/external-2/kill", { - force: true, - }); + expect(mockApiPost).toHaveBeenCalledWith( + "/api/orchestrator/api/mission-control/sessions/internal-2/kill", + { + force: true, + } + ); + expect(mockApiPost).toHaveBeenCalledWith( + "/api/orchestrator/api/mission-control/sessions/external-2/kill", + { + force: true, + } + ); }); }); diff --git a/apps/web/src/components/mission-control/KillAllDialog.tsx b/apps/web/src/components/mission-control/KillAllDialog.tsx index b093d89..2a3a6c0 100644 --- a/apps/web/src/components/mission-control/KillAllDialog.tsx +++ b/apps/web/src/components/mission-control/KillAllDialog.tsx @@ -96,9 +96,12 @@ export function KillAllDialog({ sessions, onComplete }: KillAllDialogProps): Rea const killRequests = targetSessions.map(async (session) => { try { - await apiPost<{ message: string }>(`/api/mission-control/sessions/${session.id}/kill`, { - force: true, - }); + await apiPost<{ message: string }>( + `/api/orchestrator/api/mission-control/sessions/${session.id}/kill`, + { + force: true, + } + ); return true; } catch { return false; diff --git a/apps/web/src/components/mission-control/PanelControls.test.tsx b/apps/web/src/components/mission-control/PanelControls.test.tsx index 5c43b2b..cf9a4ab 100644 --- a/apps/web/src/components/mission-control/PanelControls.test.tsx +++ b/apps/web/src/components/mission-control/PanelControls.test.tsx @@ -89,7 +89,7 @@ describe("PanelControls", (): void => { await waitFor((): void => { expect(mockApiPost).toHaveBeenCalledWith( - "/api/mission-control/sessions/session%20with%20space/pause", + "/api/orchestrator/api/mission-control/sessions/session%20with%20space/pause", undefined ); }); @@ -114,9 +114,12 @@ describe("PanelControls", (): void => { await user.click(screen.getByRole("button", { name: "Confirm" })); await waitFor((): void => { - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-4/kill", { - force: false, - }); + expect(mockApiPost).toHaveBeenCalledWith( + "/api/orchestrator/api/mission-control/sessions/session-4/kill", + { + force: false, + } + ); }); expect(onStatusChange).toHaveBeenCalledWith("killed"); @@ -137,9 +140,12 @@ describe("PanelControls", (): void => { await user.click(screen.getByRole("button", { name: "Confirm" })); await waitFor((): void => { - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-5/kill", { - force: true, - }); + expect(mockApiPost).toHaveBeenCalledWith( + "/api/orchestrator/api/mission-control/sessions/session-5/kill", + { + force: true, + } + ); }); expect(onStatusChange).toHaveBeenCalledWith("killed"); diff --git a/apps/web/src/components/mission-control/PanelControls.tsx b/apps/web/src/components/mission-control/PanelControls.tsx index 0e3c761..fba94ee 100644 --- a/apps/web/src/components/mission-control/PanelControls.tsx +++ b/apps/web/src/components/mission-control/PanelControls.tsx @@ -50,23 +50,23 @@ export function PanelControls({ switch (action) { case "pause": await apiPost<{ message: string }>( - `/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause` + `/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause` ); return { nextStatus: "paused" }; case "resume": await apiPost<{ message: string }>( - `/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume` + `/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume` ); return { nextStatus: "active" }; case "graceful-kill": await apiPost<{ message: string }>( - `/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`, + `/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`, { force: false } ); return { nextStatus: "killed" }; case "force-kill": await apiPost<{ message: string }>( - `/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`, + `/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`, { force: true } ); return { nextStatus: "killed" };